Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ad32d8147d | |||
| b564838c2e | |||
| f407c3d46a | |||
| 41d391eebf | |||
| c0645233ef | |||
| 9a1fcad3cb | |||
| 571d80f75f | |||
| e61e653ea3 |
@@ -52,3 +52,71 @@ describe('useClientFormErrors', () => {
|
|||||||
expect(f.addressErrors.value[0]).toBeUndefined()
|
expect(f.addressErrors.value[0]).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Construit une erreur facon useApi : 422 avec violations Hydra.
|
||||||
|
function http422(path: string, message: string) {
|
||||||
|
return { response: { status: 422, _data: { violations: [{ propertyPath: path, message }] } } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `submitRows` factorise la soumission d'une collection de blocs (contacts /
|
||||||
|
* adresses / RIB) : on tente TOUS les blocs et on collecte les erreurs par index
|
||||||
|
* sans stopper au premier echec (ERP-110 / ERP-101).
|
||||||
|
*/
|
||||||
|
describe('useClientFormErrors.submitRows', () => {
|
||||||
|
it('tente TOUS les blocs et mappe les erreurs par index, sans stopper au premier echec', async () => {
|
||||||
|
const { contactErrors, submitRows } = useClientFormErrors()
|
||||||
|
const seen: number[] = []
|
||||||
|
const onUnmapped = vi.fn()
|
||||||
|
|
||||||
|
const saveRow = async (_row: unknown, index: number) => {
|
||||||
|
seen.push(index)
|
||||||
|
if (index === 1) throw http422('email', 'Email invalide')
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasError = await submitRows(
|
||||||
|
[{ a: 0 }, { a: 1 }, { a: 2 }],
|
||||||
|
contactErrors,
|
||||||
|
saveRow,
|
||||||
|
onUnmapped,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(seen).toEqual([0, 1, 2]) // tous les blocs tentes
|
||||||
|
expect(hasError).toBe(true)
|
||||||
|
expect(contactErrors.value[1]).toEqual({ email: 'Email invalide' })
|
||||||
|
expect(contactErrors.value[0]).toBeUndefined()
|
||||||
|
expect(onUnmapped).not.toHaveBeenCalled() // 422 mappee, pas de fallback
|
||||||
|
})
|
||||||
|
|
||||||
|
it('delegue le fallback onUnmappedError pour une erreur non mappable et marque hasError', async () => {
|
||||||
|
const { ribErrors, submitRows } = useClientFormErrors()
|
||||||
|
const onUnmapped = vi.fn()
|
||||||
|
|
||||||
|
const hasError = await submitRows(
|
||||||
|
[{ a: 0 }],
|
||||||
|
ribErrors,
|
||||||
|
async () => { throw { response: { status: 500, _data: {} } } },
|
||||||
|
onUnmapped,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(hasError).toBe(true)
|
||||||
|
expect(onUnmapped).toHaveBeenCalledTimes(1)
|
||||||
|
expect(ribErrors.value[0]).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saute les lignes filtrees par shouldSkip et renvoie false si tout passe', async () => {
|
||||||
|
const { contactErrors, submitRows } = useClientFormErrors()
|
||||||
|
const saved: number[] = []
|
||||||
|
|
||||||
|
const hasError = await submitRows(
|
||||||
|
[{ skip: true }, { skip: false }],
|
||||||
|
contactErrors,
|
||||||
|
async (_row, index) => { saved.push(index) },
|
||||||
|
vi.fn(),
|
||||||
|
(row: { skip: boolean }) => row.skip,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(saved).toEqual([1])
|
||||||
|
expect(hasError).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -43,6 +43,44 @@ export function useClientFormErrors() {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soumet TOUS les blocs d'une collection (contacts / adresses / RIB) en
|
||||||
|
* collectant les erreurs par index : on n'arrete PAS au premier bloc en echec
|
||||||
|
* (decision ERP-110 / ERP-101). Reinitialise le tableau d'erreurs cible, tente
|
||||||
|
* chaque ligne via `saveRow`, mappe les 422 inline (mapRowError) ou delegue le
|
||||||
|
* fallback a `onUnmappedError`. `shouldSkip` permet d'ignorer les blocs vides
|
||||||
|
* (non remplis). Retourne true si au moins un bloc a echoue (le caller ne valide
|
||||||
|
* alors pas l'onglet et n'affiche pas de toast succes).
|
||||||
|
*/
|
||||||
|
async function submitRows<T>(
|
||||||
|
rows: T[],
|
||||||
|
target: Ref<Record<string, string>[]>,
|
||||||
|
saveRow: (row: T, index: number) => Promise<void>,
|
||||||
|
onUnmappedError: (error: unknown, index: number) => void,
|
||||||
|
shouldSkip?: (row: T, index: number) => boolean,
|
||||||
|
): Promise<boolean> {
|
||||||
|
target.value = []
|
||||||
|
let hasError = false
|
||||||
|
for (let index = 0; index < rows.length; index++) {
|
||||||
|
// L'index reste borne par rows.length : la ligne existe forcement.
|
||||||
|
const row = rows[index] as T
|
||||||
|
if (shouldSkip?.(row, index)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await saveRow(row, index)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
if (!mapRowError(error, target, index)) {
|
||||||
|
onUnmappedError(error, index)
|
||||||
|
}
|
||||||
|
hasError = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasError
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mainErrors,
|
mainErrors,
|
||||||
informationErrors,
|
informationErrors,
|
||||||
@@ -51,5 +89,6 @@ export function useClientFormErrors() {
|
|||||||
addressErrors,
|
addressErrors,
|
||||||
ribErrors,
|
ribErrors,
|
||||||
mapRowError,
|
mapRowError,
|
||||||
|
submitRows,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -414,7 +414,9 @@ import {
|
|||||||
hasAtLeastOneValidContact,
|
hasAtLeastOneValidContact,
|
||||||
isBankRequiredForPaymentType,
|
isBankRequiredForPaymentType,
|
||||||
isBillingEmailRequired,
|
isBillingEmailRequired,
|
||||||
|
isContactBlank,
|
||||||
isContactNamed,
|
isContactNamed,
|
||||||
|
isRibBlank,
|
||||||
isRibRequiredForPaymentType,
|
isRibRequiredForPaymentType,
|
||||||
} from '~/modules/commercial/utils/clientFormRules'
|
} from '~/modules/commercial/utils/clientFormRules'
|
||||||
import {
|
import {
|
||||||
@@ -628,7 +630,7 @@ const {
|
|||||||
contactErrors,
|
contactErrors,
|
||||||
addressErrors,
|
addressErrors,
|
||||||
ribErrors,
|
ribErrors,
|
||||||
mapRowError,
|
submitRows,
|
||||||
} = useClientFormErrors()
|
} = useClientFormErrors()
|
||||||
|
|
||||||
// ── Bloc principal ───────────────────────────────────────────────────────────
|
// ── Bloc principal ───────────────────────────────────────────────────────────
|
||||||
@@ -742,11 +744,14 @@ async function submitContacts(): Promise<void> {
|
|||||||
}
|
}
|
||||||
removedContactIds.value = []
|
removedContactIds.value = []
|
||||||
|
|
||||||
for (let index = 0; index < contacts.value.length; index++) {
|
// On tente TOUS les blocs (collecte des erreurs par index, ERP-110). Seuls
|
||||||
const contact = contacts.value[index]
|
// les blocs TOTALEMENT vides sont ignores : un bloc partiellement rempli
|
||||||
if (!isContactNamed(contact)) continue
|
// sans nom (email seul) est soumis -> 422 RG-1.05 inline sous le bloc.
|
||||||
const body = buildContactPayload(contact)
|
const hasError = await submitRows(
|
||||||
try {
|
contacts.value,
|
||||||
|
contactErrors,
|
||||||
|
async (contact) => {
|
||||||
|
const body = buildContactPayload(contact)
|
||||||
if (contact.id === null) {
|
if (contact.id === null) {
|
||||||
const created = await api.post<{ '@id'?: string, id: number }>(
|
const created = await api.post<{ '@id'?: string, id: number }>(
|
||||||
`/clients/${clientId}/contacts`,
|
`/clients/${clientId}/contacts`,
|
||||||
@@ -759,15 +764,15 @@ async function submitContacts(): Promise<void> {
|
|||||||
else {
|
else {
|
||||||
await api.patch(`/client_contacts/${contact.id}`, body, { toast: false })
|
await api.patch(`/client_contacts/${contact.id}`, body, { toast: false })
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
catch (error) {
|
error => showError(error),
|
||||||
// 422 → erreurs inline sous les champs de CETTE ligne ; on stoppe.
|
// On ne saute QUE les amorces neuves (id null) totalement vides. Un
|
||||||
if (!mapRowError(error, contactErrors, index)) {
|
// bloc existant vide est soumis -> 422 RG-1.05 inline (sinon la modif
|
||||||
showError(error)
|
// serait perdue en silence avec un faux toast de succes).
|
||||||
}
|
contact => contact.id === null && isContactBlank(contact),
|
||||||
return
|
)
|
||||||
}
|
// Tant qu'un bloc reste en erreur : pas de toast succes.
|
||||||
}
|
if (hasError) return
|
||||||
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
@@ -824,10 +829,12 @@ async function submitAddresses(): Promise<void> {
|
|||||||
}
|
}
|
||||||
removedAddressIds.value = []
|
removedAddressIds.value = []
|
||||||
|
|
||||||
for (let index = 0; index < addresses.value.length; index++) {
|
// On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110).
|
||||||
const address = addresses.value[index]
|
const hasError = await submitRows(
|
||||||
const body = buildAddressPayload(address, isBillingEmailRequired(address))
|
addresses.value,
|
||||||
try {
|
addressErrors,
|
||||||
|
async (address) => {
|
||||||
|
const body = buildAddressPayload(address, isBillingEmailRequired(address))
|
||||||
if (address.id === null) {
|
if (address.id === null) {
|
||||||
const created = await api.post<{ id: number }>(
|
const created = await api.post<{ id: number }>(
|
||||||
`/clients/${clientId}/addresses`,
|
`/clients/${clientId}/addresses`,
|
||||||
@@ -839,14 +846,10 @@ async function submitAddresses(): Promise<void> {
|
|||||||
else {
|
else {
|
||||||
await api.patch(`/client_addresses/${address.id}`, body, { toast: false })
|
await api.patch(`/client_addresses/${address.id}`, body, { toast: false })
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
catch (error) {
|
error => showError(error),
|
||||||
if (!mapRowError(error, addressErrors, index)) {
|
)
|
||||||
showError(error)
|
if (hasError) return
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
@@ -905,6 +908,9 @@ async function submitAccounting(): Promise<void> {
|
|||||||
if (accountingReadonly.value || !canValidateAccounting.value || tabSubmitting.value) return
|
if (accountingReadonly.value || !canValidateAccounting.value || tabSubmitting.value) return
|
||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
accountingErrors.clearErrors()
|
accountingErrors.clearErrors()
|
||||||
|
// Reset des erreurs RIB des le debut : l'etape 1 (PATCH scalaires) peut
|
||||||
|
// echouer et `return` avant submitRows (qui porte sinon le reset), laissant
|
||||||
|
// des erreurs de RIB obsoletes affichees sous les blocs.
|
||||||
ribErrors.value = []
|
ribErrors.value = []
|
||||||
try {
|
try {
|
||||||
// 1) PATCH des scalaires comptables (erreurs inline sur leurs champs).
|
// 1) PATCH des scalaires comptables (erreurs inline sur leurs champs).
|
||||||
@@ -921,12 +927,14 @@ async function submitAccounting(): Promise<void> {
|
|||||||
}
|
}
|
||||||
removedRibIds.value = []
|
removedRibIds.value = []
|
||||||
|
|
||||||
// 2) POST/PATCH des RIB (erreurs inline par ligne).
|
// 2) POST/PATCH des RIB (erreurs inline par ligne, tous les blocs tentes).
|
||||||
for (let index = 0; index < ribs.value.length; index++) {
|
// Seuls les blocs RIB TOTALEMENT vides sont ignores : un RIB partiel (ex.
|
||||||
const rib = ribs.value[index]
|
// IBAN seul) est soumis -> 422 NotBlank (label / bic / iban) inline.
|
||||||
if (!ribIsComplete(rib)) continue
|
const ribHasError = await submitRows(
|
||||||
const body = buildRibPayload(rib)
|
ribs.value,
|
||||||
try {
|
ribErrors,
|
||||||
|
async (rib) => {
|
||||||
|
const body = buildRibPayload(rib)
|
||||||
if (rib.id === null) {
|
if (rib.id === null) {
|
||||||
const created = await api.post<{ id: number }>(
|
const created = await api.post<{ id: number }>(
|
||||||
`/clients/${clientId}/ribs`,
|
`/clients/${clientId}/ribs`,
|
||||||
@@ -938,14 +946,14 @@ async function submitAccounting(): Promise<void> {
|
|||||||
else {
|
else {
|
||||||
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
|
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
catch (error) {
|
error => showError(error),
|
||||||
if (!mapRowError(error, ribErrors, index)) {
|
// On ne saute QUE les amorces neuves (id null) totalement vides. Un
|
||||||
showError(error)
|
// RIB existant vide est soumis -> 422 NotBlank inline (sinon la modif
|
||||||
}
|
// serait perdue en silence avec un faux toast de succes).
|
||||||
return
|
rib => rib.id === null && isRibBlank(rib),
|
||||||
}
|
)
|
||||||
}
|
if (ribHasError) return
|
||||||
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
|
|||||||
@@ -385,7 +385,9 @@ import {
|
|||||||
hasAtLeastOneValidContact,
|
hasAtLeastOneValidContact,
|
||||||
isBankRequiredForPaymentType,
|
isBankRequiredForPaymentType,
|
||||||
isBillingEmailRequired,
|
isBillingEmailRequired,
|
||||||
|
isContactBlank,
|
||||||
isContactNamed,
|
isContactNamed,
|
||||||
|
isRibBlank,
|
||||||
isRibRequiredForPaymentType,
|
isRibRequiredForPaymentType,
|
||||||
} from '~/modules/commercial/utils/clientFormRules'
|
} from '~/modules/commercial/utils/clientFormRules'
|
||||||
import {
|
import {
|
||||||
@@ -441,7 +443,7 @@ const {
|
|||||||
contactErrors,
|
contactErrors,
|
||||||
addressErrors,
|
addressErrors,
|
||||||
ribErrors,
|
ribErrors,
|
||||||
mapRowError,
|
submitRows,
|
||||||
} = useClientFormErrors()
|
} = useClientFormErrors()
|
||||||
|
|
||||||
useHead({ title: t('commercial.clients.form.title') })
|
useHead({ title: t('commercial.clients.form.title') })
|
||||||
@@ -676,23 +678,22 @@ function askRemoveContact(index: number): void {
|
|||||||
async function submitContacts(): Promise<void> {
|
async function submitContacts(): Promise<void> {
|
||||||
if (clientId.value === null || !canValidateContacts.value || tabSubmitting.value) return
|
if (clientId.value === null || !canValidateContacts.value || tabSubmitting.value) return
|
||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
contactErrors.value = []
|
|
||||||
try {
|
try {
|
||||||
for (let index = 0; index < contacts.value.length; index++) {
|
// On tente TOUS les blocs (collecte des erreurs par index, ERP-110). Seuls
|
||||||
const contact = contacts.value[index]
|
// les blocs TOTALEMENT vides sont ignores : un bloc partiellement rempli
|
||||||
// On ignore les blocs totalement vides (ni nom ni prenom).
|
// sans nom (email seul) est soumis -> 422 RG-1.05 inline sous le bloc.
|
||||||
if (!isContactNamed(contact)) continue
|
const hasError = await submitRows(
|
||||||
|
contacts.value,
|
||||||
const body = {
|
contactErrors,
|
||||||
firstName: contact.firstName || null,
|
async (contact) => {
|
||||||
lastName: contact.lastName || null,
|
const body = {
|
||||||
jobTitle: contact.jobTitle || null,
|
firstName: contact.firstName || null,
|
||||||
phonePrimary: contact.phonePrimary || null,
|
lastName: contact.lastName || null,
|
||||||
phoneSecondary: contact.hasSecondaryPhone ? (contact.phoneSecondary || null) : null,
|
jobTitle: contact.jobTitle || null,
|
||||||
email: contact.email || null,
|
phonePrimary: contact.phonePrimary || null,
|
||||||
}
|
phoneSecondary: contact.hasSecondaryPhone ? (contact.phoneSecondary || null) : null,
|
||||||
|
email: contact.email || null,
|
||||||
try {
|
}
|
||||||
if (contact.id === null) {
|
if (contact.id === null) {
|
||||||
const created = await api.post<ContactResponse>(
|
const created = await api.post<ContactResponse>(
|
||||||
`/clients/${clientId.value}/contacts`,
|
`/clients/${clientId.value}/contacts`,
|
||||||
@@ -705,16 +706,15 @@ async function submitContacts(): Promise<void> {
|
|||||||
else {
|
else {
|
||||||
await api.patch(`/client_contacts/${contact.id}`, body, { toast: false })
|
await api.patch(`/client_contacts/${contact.id}`, body, { toast: false })
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
catch (error) {
|
error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
|
||||||
// 422 → erreurs inline sous les champs de CETTE ligne ; on stoppe
|
// On ne saute QUE les amorces neuves (id null) totalement vides. Un
|
||||||
// a la premiere ligne en echec (les suivantes ne sont pas tentees).
|
// bloc existant vide est soumis -> 422 RG-1.05 inline (sinon la modif
|
||||||
if (!mapRowError(error, contactErrors, index)) {
|
// serait perdue en silence avec un faux toast de succes).
|
||||||
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
|
contact => contact.id === null && isContactBlank(contact),
|
||||||
}
|
)
|
||||||
return
|
// Tant qu'un bloc reste en erreur : pas de validation d'onglet ni de toast succes.
|
||||||
}
|
if (hasError) return
|
||||||
}
|
|
||||||
completeTab('contact')
|
completeTab('contact')
|
||||||
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||||
}
|
}
|
||||||
@@ -784,26 +784,26 @@ function onAddressDegraded(): void {
|
|||||||
async function submitAddresses(): Promise<void> {
|
async function submitAddresses(): Promise<void> {
|
||||||
if (clientId.value === null || !canValidateAddresses.value || tabSubmitting.value) return
|
if (clientId.value === null || !canValidateAddresses.value || tabSubmitting.value) return
|
||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
addressErrors.value = []
|
|
||||||
try {
|
try {
|
||||||
for (let index = 0; index < addresses.value.length; index++) {
|
// On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110).
|
||||||
const address = addresses.value[index]
|
const hasError = await submitRows(
|
||||||
const body = {
|
addresses.value,
|
||||||
isProspect: address.isProspect,
|
addressErrors,
|
||||||
isDelivery: address.isDelivery,
|
async (address) => {
|
||||||
isBilling: address.isBilling,
|
const body = {
|
||||||
country: address.country,
|
isProspect: address.isProspect,
|
||||||
postalCode: address.postalCode || null,
|
isDelivery: address.isDelivery,
|
||||||
city: address.city || null,
|
isBilling: address.isBilling,
|
||||||
street: address.street || null,
|
country: address.country,
|
||||||
streetComplement: address.streetComplement || null,
|
postalCode: address.postalCode || null,
|
||||||
categories: address.categoryIris,
|
city: address.city || null,
|
||||||
sites: address.siteIris,
|
street: address.street || null,
|
||||||
contacts: address.contactIris,
|
streetComplement: address.streetComplement || null,
|
||||||
billingEmail: isBillingEmailRequired(address) ? (address.billingEmail || null) : null,
|
categories: address.categoryIris,
|
||||||
}
|
sites: address.siteIris,
|
||||||
|
contacts: address.contactIris,
|
||||||
try {
|
billingEmail: isBillingEmailRequired(address) ? (address.billingEmail || null) : null,
|
||||||
|
}
|
||||||
if (address.id === null) {
|
if (address.id === null) {
|
||||||
const created = await api.post<{ id: number }>(
|
const created = await api.post<{ id: number }>(
|
||||||
`/clients/${clientId.value}/addresses`,
|
`/clients/${clientId.value}/addresses`,
|
||||||
@@ -815,14 +815,10 @@ async function submitAddresses(): Promise<void> {
|
|||||||
else {
|
else {
|
||||||
await api.patch(`/client_addresses/${address.id}`, body, { toast: false })
|
await api.patch(`/client_addresses/${address.id}`, body, { toast: false })
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
catch (error) {
|
error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
|
||||||
if (!mapRowError(error, addressErrors, index)) {
|
)
|
||||||
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
|
if (hasError) return
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
completeTab('address')
|
completeTab('address')
|
||||||
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||||
}
|
}
|
||||||
@@ -893,6 +889,9 @@ async function submitAccounting(): Promise<void> {
|
|||||||
if (clientId.value === null || !canValidateAccounting.value || tabSubmitting.value) return
|
if (clientId.value === null || !canValidateAccounting.value || tabSubmitting.value) return
|
||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
accountingErrors.clearErrors()
|
accountingErrors.clearErrors()
|
||||||
|
// Reset des erreurs RIB des le debut : l'etape 1 (PATCH scalaires) peut
|
||||||
|
// echouer et `return` avant submitRows (qui porte sinon le reset), laissant
|
||||||
|
// des erreurs de RIB obsoletes affichees sous les blocs.
|
||||||
ribErrors.value = []
|
ribErrors.value = []
|
||||||
try {
|
try {
|
||||||
// 1) PATCH des scalaires comptables (erreurs inline sur leurs champs).
|
// 1) PATCH des scalaires comptables (erreurs inline sur leurs champs).
|
||||||
@@ -912,30 +911,33 @@ async function submitAccounting(): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) POST/PATCH des RIB (erreurs inline par ligne).
|
// 2) POST/PATCH des RIB (erreurs inline par ligne, tous les blocs tentes).
|
||||||
for (let index = 0; index < ribs.value.length; index++) {
|
// Seuls les blocs RIB TOTALEMENT vides sont ignores : un RIB partiel (ex.
|
||||||
const rib = ribs.value[index]
|
// IBAN seul) est soumis -> 422 NotBlank (label / bic / iban) inline.
|
||||||
if (!ribIsComplete(rib)) continue
|
const ribHasError = await submitRows(
|
||||||
try {
|
ribs.value,
|
||||||
|
ribErrors,
|
||||||
|
async (rib) => {
|
||||||
|
const body = { label: rib.label, bic: rib.bic, iban: rib.iban }
|
||||||
if (rib.id === null) {
|
if (rib.id === null) {
|
||||||
const created = await api.post<{ id: number }>(
|
const created = await api.post<{ id: number }>(
|
||||||
`/clients/${clientId.value}/ribs`,
|
`/clients/${clientId.value}/ribs`,
|
||||||
{ label: rib.label, bic: rib.bic, iban: rib.iban },
|
body,
|
||||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
)
|
)
|
||||||
rib.id = created.id
|
rib.id = created.id
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
await api.patch(`/client_ribs/${rib.id}`, { label: rib.label, bic: rib.bic, iban: rib.iban }, { toast: false })
|
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
catch (error) {
|
error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
|
||||||
if (!mapRowError(error, ribErrors, index)) {
|
// On ne saute QUE les amorces neuves (id null) totalement vides. Un
|
||||||
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
|
// RIB existant vide est soumis -> 422 NotBlank inline (sinon la modif
|
||||||
}
|
// serait perdue en silence avec un faux toast de succes).
|
||||||
return
|
rib => rib.id === null && isRibBlank(rib),
|
||||||
}
|
)
|
||||||
}
|
if (ribHasError) return
|
||||||
|
|
||||||
completeTab('accounting')
|
completeTab('accounting')
|
||||||
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||||
|
|||||||
@@ -7,11 +7,27 @@ import {
|
|||||||
hasAtLeastOneValidContact,
|
hasAtLeastOneValidContact,
|
||||||
isBankRequiredForPaymentType,
|
isBankRequiredForPaymentType,
|
||||||
isBillingEmailRequired,
|
isBillingEmailRequired,
|
||||||
|
isBlankRow,
|
||||||
|
isContactBlank,
|
||||||
isContactNamed,
|
isContactNamed,
|
||||||
|
isRibBlank,
|
||||||
isRibRequiredForPaymentType,
|
isRibRequiredForPaymentType,
|
||||||
type ContactDraft,
|
type ContactDraft,
|
||||||
|
type ContactFillableDraft,
|
||||||
} from '../clientFormRules'
|
} from '../clientFormRules'
|
||||||
|
|
||||||
|
/** Bloc contact totalement vide (amorce par defaut). */
|
||||||
|
function blankContact(): ContactFillableDraft {
|
||||||
|
return {
|
||||||
|
firstName: null,
|
||||||
|
lastName: null,
|
||||||
|
jobTitle: null,
|
||||||
|
phonePrimary: null,
|
||||||
|
phoneSecondary: null,
|
||||||
|
email: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe('buildClientFormTabKeys (gating onglet Comptabilite + onglets edit-only)', () => {
|
describe('buildClientFormTabKeys (gating onglet Comptabilite + onglets edit-only)', () => {
|
||||||
it('inclut l onglet accounting si l utilisateur a accounting.view', () => {
|
it('inclut l onglet accounting si l utilisateur a accounting.view', () => {
|
||||||
expect(buildClientFormTabKeys(true)).toContain('accounting')
|
expect(buildClientFormTabKeys(true)).toContain('accounting')
|
||||||
@@ -59,6 +75,49 @@ describe('isContactNamed (RG-1.05)', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('isBlankRow (primitive : toutes les valeurs vides)', () => {
|
||||||
|
it('vrai si toutes les valeurs sont nulles / vides / espaces', () => {
|
||||||
|
expect(isBlankRow([null, undefined, '', ' '])).toBe(true)
|
||||||
|
expect(isBlankRow([])).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('faux des qu une valeur porte un caractere non-espace', () => {
|
||||||
|
expect(isBlankRow([null, 'x', ''])).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isRibBlank (bloc RIB totalement vide vs partiellement rempli)', () => {
|
||||||
|
it('vrai si label / bic / iban sont tous vides', () => {
|
||||||
|
expect(isRibBlank({ label: null, bic: null, iban: null })).toBe(true)
|
||||||
|
expect(isRibBlank({ label: ' ', bic: '', iban: null })).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('faux si un IBAN seul est saisi (bloc a soumettre -> 422 NotBlank inline)', () => {
|
||||||
|
expect(isRibBlank({ label: null, bic: null, iban: 'FR1420041010050500013M02606' })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('faux si seul le libelle est saisi', () => {
|
||||||
|
expect(isRibBlank({ label: 'Compte courant', bic: null, iban: null })).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isContactBlank (bloc totalement vide vs partiellement rempli)', () => {
|
||||||
|
it('vrai si aucun champ saisissable n est rempli', () => {
|
||||||
|
expect(isContactBlank(blankContact())).toBe(true)
|
||||||
|
expect(isContactBlank({ ...blankContact(), firstName: ' ', email: '' })).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('faux si un email seul est saisi (bloc a soumettre -> 422 RG-1.05 inline)', () => {
|
||||||
|
expect(isContactBlank({ ...blankContact(), email: 'jean@acme.fr' })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('faux si seul un telephone, une fonction ou un nom est saisi', () => {
|
||||||
|
expect(isContactBlank({ ...blankContact(), phonePrimary: '0612345678' })).toBe(false)
|
||||||
|
expect(isContactBlank({ ...blankContact(), jobTitle: 'Directeur' })).toBe(false)
|
||||||
|
expect(isContactBlank({ ...blankContact(), firstName: 'Alice' })).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('hasAtLeastOneValidContact (RG-1.14)', () => {
|
describe('hasAtLeastOneValidContact (RG-1.14)', () => {
|
||||||
it('faux sur une liste vide', () => {
|
it('faux sur une liste vide', () => {
|
||||||
expect(hasAtLeastOneValidContact([])).toBe(false)
|
expect(hasAtLeastOneValidContact([])).toBe(false)
|
||||||
|
|||||||
@@ -86,6 +86,58 @@ export function hasAtLeastOneValidContact(contacts: ContactDraft[]): boolean {
|
|||||||
return contacts.some(isContactNamed)
|
return contacts.some(isContactNamed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Primitive reutilisable : vrai si TOUTES les valeurs fournies sont vides (null /
|
||||||
|
* undefined / espaces uniquement). Sert a detecter un bloc de collection
|
||||||
|
* totalement vide (amorce non remplie). Un bloc qui porte la moindre donnee
|
||||||
|
* n'est PAS « blank » : il doit etre soumis pour declencher sa 422 inline plutot
|
||||||
|
* que d'etre saute silencieusement.
|
||||||
|
*/
|
||||||
|
export function isBlankRow(values: (string | null | undefined)[]): boolean {
|
||||||
|
return values.every(value => !isFilled(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Champs saisissables d'un bloc contact (pour detecter un bloc totalement vide). */
|
||||||
|
export interface ContactFillableDraft extends ContactDraft {
|
||||||
|
jobTitle: string | null
|
||||||
|
phonePrimary: string | null
|
||||||
|
phoneSecondary: string | null
|
||||||
|
email: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrai si AUCUN champ saisissable du bloc contact n'est rempli. Distingue un bloc
|
||||||
|
* d'amorce vide (a ignorer au submit) d'un bloc partiellement rempli sans nom
|
||||||
|
* (email / telephone / fonction seul) : ce dernier doit etre soumis pour
|
||||||
|
* declencher la 422 RG-1.05 (« prenom ou nom obligatoire ») affichee inline.
|
||||||
|
*/
|
||||||
|
export function isContactBlank(contact: ContactFillableDraft): boolean {
|
||||||
|
return isBlankRow([
|
||||||
|
contact.firstName,
|
||||||
|
contact.lastName,
|
||||||
|
contact.jobTitle,
|
||||||
|
contact.phonePrimary,
|
||||||
|
contact.phoneSecondary,
|
||||||
|
contact.email,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Champs saisissables d'un bloc RIB (pour detecter un bloc totalement vide). */
|
||||||
|
export interface RibFillableDraft {
|
||||||
|
label: string | null
|
||||||
|
bic: string | null
|
||||||
|
iban: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrai si AUCUN champ du bloc RIB n'est rempli. Un RIB partiellement rempli (ex.
|
||||||
|
* IBAN seul) n'est PAS « blank » : il doit etre soumis pour declencher les 422
|
||||||
|
* NotBlank (label / bic / iban) inline plutot que d'etre saute silencieusement.
|
||||||
|
*/
|
||||||
|
export function isRibBlank(rib: RibFillableDraft): boolean {
|
||||||
|
return isBlankRow([rib.label, rib.bic, rib.iban])
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RG-1.06/07/08 : une adresse de prospection est exclusive d'une adresse de
|
* RG-1.06/07/08 : une adresse de prospection est exclusive d'une adresse de
|
||||||
* livraison/facturation. Prospect n'est selectionnable que si ni Livraison ni
|
* livraison/facturation. Prospect n'est selectionnable que si ni Livraison ni
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||||||
uriVariables: [
|
uriVariables: [
|
||||||
'clientId' => new Link(fromClass: Client::class, toProperty: 'client'),
|
'clientId' => new Link(fromClass: Client::class, toProperty: 'client'),
|
||||||
],
|
],
|
||||||
|
// read:false : pas de stade lecture du parent. Le Link toProperty
|
||||||
|
// resoudrait l'enfant (SELECT ClientAddress ... WHERE client = :id) et
|
||||||
|
// casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
|
||||||
|
// manuellement par ClientAddressProcessor::linkParent (404 si absent).
|
||||||
|
read: false,
|
||||||
security: "is_granted('commercial.clients.manage')",
|
security: "is_granted('commercial.clients.manage')",
|
||||||
normalizationContext: ['groups' => ['client_address:read']],
|
normalizationContext: ['groups' => ['client_address:read']],
|
||||||
denormalizationContext: ['groups' => ['client_address:write']],
|
denormalizationContext: ['groups' => ['client_address:write']],
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
uriVariables: [
|
uriVariables: [
|
||||||
'clientId' => new Link(fromClass: Client::class, toProperty: 'client'),
|
'clientId' => new Link(fromClass: Client::class, toProperty: 'client'),
|
||||||
],
|
],
|
||||||
|
// read:false : pas de stade lecture du parent. Le Link toProperty
|
||||||
|
// resoudrait l'enfant (SELECT ClientContact ... WHERE client = :id) et
|
||||||
|
// casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
|
||||||
|
// manuellement par ClientContactProcessor::linkParent (404 si absent).
|
||||||
|
read: false,
|
||||||
security: "is_granted('commercial.clients.manage')",
|
security: "is_granted('commercial.clients.manage')",
|
||||||
normalizationContext: ['groups' => ['client_contact:read']],
|
normalizationContext: ['groups' => ['client_contact:read']],
|
||||||
denormalizationContext: ['groups' => ['client_contact:write']],
|
denormalizationContext: ['groups' => ['client_contact:write']],
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
uriVariables: [
|
uriVariables: [
|
||||||
'clientId' => new Link(fromClass: Client::class, toProperty: 'client'),
|
'clientId' => new Link(fromClass: Client::class, toProperty: 'client'),
|
||||||
],
|
],
|
||||||
|
// read:false : pas de stade lecture du parent. Le Link toProperty
|
||||||
|
// resoudrait l'enfant (SELECT ClientRib ... WHERE client = :id) et
|
||||||
|
// casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
|
||||||
|
// manuellement par ClientRibProcessor::linkParent (404 si absent).
|
||||||
|
read: false,
|
||||||
security: "is_granted('commercial.clients.accounting.manage')",
|
security: "is_granted('commercial.clients.accounting.manage')",
|
||||||
normalizationContext: ['groups' => ['client_rib:read']],
|
normalizationContext: ['groups' => ['client_rib:read']],
|
||||||
denormalizationContext: ['groups' => ['client_rib:write']],
|
denormalizationContext: ['groups' => ['client_rib:write']],
|
||||||
|
|||||||
+8
-2
@@ -12,6 +12,7 @@ use App\Module\Commercial\Domain\Entity\Client;
|
|||||||
use App\Module\Commercial\Domain\Entity\ClientAddress;
|
use App\Module\Commercial\Domain\Entity\ClientAddress;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processor d'ecriture de la sous-ressource Adresse d'un client (M1, § 4.5).
|
* Processor d'ecriture de la sous-ressource Adresse d'un client (M1, § 4.5).
|
||||||
@@ -75,9 +76,14 @@ final class ClientAddressProcessor implements ProcessorInterface
|
|||||||
? $clientId
|
? $clientId
|
||||||
: $this->em->getRepository(Client::class)->find($clientId);
|
: $this->em->getRepository(Client::class)->find($clientId);
|
||||||
|
|
||||||
if ($client instanceof Client) {
|
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
|
||||||
$address->setClient($client);
|
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
|
||||||
|
// contrainte client_id NOT NULL).
|
||||||
|
if (!$client instanceof Client) {
|
||||||
|
throw new NotFoundHttpException('Client introuvable.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$address->setClient($client);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+8
-2
@@ -14,6 +14,7 @@ use App\Module\Commercial\Domain\Entity\ClientContact;
|
|||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
use Symfony\Component\Validator\ConstraintViolation;
|
use Symfony\Component\Validator\ConstraintViolation;
|
||||||
use Symfony\Component\Validator\ConstraintViolationList;
|
use Symfony\Component\Validator\ConstraintViolationList;
|
||||||
|
|
||||||
@@ -88,9 +89,14 @@ final class ClientContactProcessor implements ProcessorInterface
|
|||||||
? $clientId
|
? $clientId
|
||||||
: $this->em->getRepository(Client::class)->find($clientId);
|
: $this->em->getRepository(Client::class)->find($clientId);
|
||||||
|
|
||||||
if ($client instanceof Client) {
|
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
|
||||||
$contact->setClient($client);
|
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
|
||||||
|
// contrainte client_id NOT NULL).
|
||||||
|
if (!$client instanceof Client) {
|
||||||
|
throw new NotFoundHttpException('Client introuvable.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$contact->setClient($client);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+8
-2
@@ -12,6 +12,7 @@ use App\Module\Commercial\Domain\Entity\ClientRib;
|
|||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processor d'ecriture de la sous-ressource RIB d'un client (M1, § 4.5).
|
* Processor d'ecriture de la sous-ressource RIB d'un client (M1, § 4.5).
|
||||||
@@ -77,9 +78,14 @@ final class ClientRibProcessor implements ProcessorInterface
|
|||||||
? $clientId
|
? $clientId
|
||||||
: $this->em->getRepository(Client::class)->find($clientId);
|
: $this->em->getRepository(Client::class)->find($clientId);
|
||||||
|
|
||||||
if ($client instanceof Client) {
|
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
|
||||||
$rib->setClient($client);
|
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
|
||||||
|
// contrainte client_id NOT NULL).
|
||||||
|
if (!$client instanceof Client) {
|
||||||
|
throw new NotFoundHttpException('Client introuvable.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$rib->setClient($client);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Tests\Module\Commercial\Api;
|
namespace App\Tests\Module\Commercial\Api;
|
||||||
|
|
||||||
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
|
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
|
||||||
|
use App\Module\Commercial\Domain\Entity\ClientAddress;
|
||||||
use App\Module\Commercial\Domain\Entity\ClientContact;
|
use App\Module\Commercial\Domain\Entity\ClientContact;
|
||||||
use App\Module\Commercial\Domain\Entity\ClientRib;
|
use App\Module\Commercial\Domain\Entity\ClientRib;
|
||||||
use App\Module\Commercial\Domain\Entity\PaymentType;
|
use App\Module\Commercial\Domain\Entity\PaymentType;
|
||||||
@@ -94,6 +95,70 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
|
|||||||
self::assertSame('L\'adresse email n\'est pas valide.', $byPath['email']);
|
self::assertSame('L\'adresse email n\'est pas valide.', $byPath['email']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression ERP-110 (bug subresource Link toProperty) : POST d'un contact sur
|
||||||
|
* un client qui en a DEJA >= 2 ne doit pas exploser en 500
|
||||||
|
* (NonUniqueResultException sur la resolution du parent), mais creer (201).
|
||||||
|
*/
|
||||||
|
public function testPostContactOnClientWithTwoExistingContactsReturns201(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$seed = $this->seedClient('Contact Multi');
|
||||||
|
$this->seedContact($seed, 'Alpha');
|
||||||
|
$this->seedContact($seed, 'Beta');
|
||||||
|
|
||||||
|
$client->request('POST', '/api/clients/'.$seed->getId().'/contacts', [
|
||||||
|
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||||
|
'json' => ['firstName' => 'Gamma'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meme contexte (>= 2 contacts existants) : un email invalide doit produire
|
||||||
|
* une 422 par champ (la validation est bien atteinte), pas une 500.
|
||||||
|
*/
|
||||||
|
public function testPostInvalidContactOnPopulatedClientReturns422OnField(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$seed = $this->seedClient('Contact Multi Bad');
|
||||||
|
$this->seedContact($seed, 'Alpha');
|
||||||
|
$this->seedContact($seed, 'Beta');
|
||||||
|
|
||||||
|
$response = $client->request('POST', '/api/clients/'.$seed->getId().'/contacts', [
|
||||||
|
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||||
|
'json' => ['firstName' => 'Gamma', 'email' => 'pas-un-email'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
$byPath = [];
|
||||||
|
foreach ($response->toArray(false)['violations'] ?? [] as $v) {
|
||||||
|
$byPath[$v['propertyPath']] = $v['message'];
|
||||||
|
}
|
||||||
|
self::assertArrayHasKey('email', $byPath);
|
||||||
|
self::assertSame('L\'adresse email n\'est pas valide.', $byPath['email']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ERP-110 : avec read:false sur le POST, un parent introuvable n'est plus
|
||||||
|
* intercepte au stade lecture. Le 404 est desormais porte par
|
||||||
|
* ClientContactProcessor::linkParent (sinon 500 au persist sur client_id
|
||||||
|
* NOT NULL). Le payload est valide pour atteindre le processor (apres la
|
||||||
|
* validation).
|
||||||
|
*/
|
||||||
|
public function testPostContactOnMissingClientReturns404(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
|
||||||
|
$client->request('POST', '/api/clients/999999/contacts', [
|
||||||
|
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||||
|
'json' => ['firstName' => 'Orphan'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(404);
|
||||||
|
}
|
||||||
|
|
||||||
public function testPatchContactNormalizes(): void
|
public function testPatchContactNormalizes(): void
|
||||||
{
|
{
|
||||||
$client = $this->createAdminClient();
|
$client = $this->createAdminClient();
|
||||||
@@ -201,6 +266,61 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
|
|||||||
self::assertResponseStatusCodeSame(422);
|
self::assertResponseStatusCodeSame(422);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression ERP-110 : POST d'une adresse sur un client qui en a DEJA >= 2 ne
|
||||||
|
* doit pas exploser en 500 (NonUniqueResult sur la resolution du parent). Le
|
||||||
|
* POST porte un site + une categorie (RG-1.10 / RG-1.29) pour etre valide.
|
||||||
|
*/
|
||||||
|
public function testPostAddressOnClientWithTwoExistingAddressesReturns201(): void
|
||||||
|
{
|
||||||
|
$this->skipIfSitesModuleDisabled();
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$seed = $this->seedClient('Addr Multi');
|
||||||
|
$siteIri = $this->firstSiteIri();
|
||||||
|
$category = $this->createCategory('SECTEUR');
|
||||||
|
$this->seedAddress($seed, 'Bordeaux');
|
||||||
|
$this->seedAddress($seed, 'Lyon');
|
||||||
|
|
||||||
|
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||||
|
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||||
|
'json' => [
|
||||||
|
'postalCode' => '75001',
|
||||||
|
'city' => 'Paris',
|
||||||
|
'street' => '2 rue Neuve',
|
||||||
|
'sites' => [$siteIri],
|
||||||
|
'categories' => ['/api/categories/'.$category->getId()],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ERP-110 : POST adresse sur un client inexistant -> 404 porte par
|
||||||
|
* ClientAddressProcessor::linkParent (read:false). Payload valide (site +
|
||||||
|
* categorie, RG-1.10 / RG-1.29) pour atteindre le processor.
|
||||||
|
*/
|
||||||
|
public function testPostAddressOnMissingClientReturns404(): void
|
||||||
|
{
|
||||||
|
$this->skipIfSitesModuleDisabled();
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$siteIri = $this->firstSiteIri();
|
||||||
|
$category = $this->createCategory('SECTEUR');
|
||||||
|
|
||||||
|
$client->request('POST', '/api/clients/999999/addresses', [
|
||||||
|
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||||
|
'json' => [
|
||||||
|
'postalCode' => '75001',
|
||||||
|
'city' => 'Paris',
|
||||||
|
'street' => '2 rue Neuve',
|
||||||
|
'sites' => [$siteIri],
|
||||||
|
'categories' => ['/api/categories/'.$category->getId()],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(404);
|
||||||
|
}
|
||||||
|
|
||||||
// === RIBs ===
|
// === RIBs ===
|
||||||
|
|
||||||
public function testPostRibByAdminReturns201(): void
|
public function testPostRibByAdminReturns201(): void
|
||||||
@@ -239,6 +359,43 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
|
|||||||
self::assertResponseStatusCodeSame(422);
|
self::assertResponseStatusCodeSame(422);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression ERP-110 : POST d'un RIB sur un client qui en a DEJA >= 2 ne doit
|
||||||
|
* pas exploser en 500 (NonUniqueResult sur la resolution du parent). L'admin
|
||||||
|
* porte commercial.clients.accounting.manage requis par le POST.
|
||||||
|
*/
|
||||||
|
public function testPostRibOnClientWithTwoExistingRibsReturns201(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$seed = $this->seedClient('Rib Multi');
|
||||||
|
$this->seedRib($seed, 'Compte 1');
|
||||||
|
$this->seedRib($seed, 'Compte 2');
|
||||||
|
|
||||||
|
$client->request('POST', '/api/clients/'.$seed->getId().'/ribs', [
|
||||||
|
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||||
|
'json' => ['label' => 'Compte 3', 'bic' => self::VALID_BIC, 'iban' => self::VALID_IBAN],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ERP-110 : POST RIB sur un client inexistant -> 404 porte par
|
||||||
|
* ClientRibProcessor::linkParent (read:false). L'admin porte
|
||||||
|
* commercial.clients.accounting.manage ; payload valide (BIC / IBAN).
|
||||||
|
*/
|
||||||
|
public function testPostRibOnMissingClientReturns404(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
|
||||||
|
$client->request('POST', '/api/clients/999999/ribs', [
|
||||||
|
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||||
|
'json' => ['label' => 'Orphan', 'bic' => self::VALID_BIC, 'iban' => self::VALID_IBAN],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(404);
|
||||||
|
}
|
||||||
|
|
||||||
public function testDeleteRibNonLcrReturns204(): void
|
public function testDeleteRibNonLcrReturns204(): void
|
||||||
{
|
{
|
||||||
$client = $this->createAdminClient();
|
$client = $this->createAdminClient();
|
||||||
@@ -306,13 +463,34 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seede un ClientRib valide rattache a un client (sans passer par l'API).
|
* Seede une adresse minimale valide en base (sans passer par l'API) : seules
|
||||||
|
* les colonnes NOT NULL sont posees (CP / ville / rue). Les M2M sites /
|
||||||
|
* categories restent vides — non contraints en base, suffisant pour peupler
|
||||||
|
* un client de plusieurs adresses.
|
||||||
*/
|
*/
|
||||||
private function seedRib(ClientEntity $client): ClientRib
|
private function seedAddress(ClientEntity $client, string $city): ClientAddress
|
||||||
|
{
|
||||||
|
$em = $this->getEm();
|
||||||
|
$address = new ClientAddress();
|
||||||
|
$address->setClient($client);
|
||||||
|
$address->setPostalCode('33000');
|
||||||
|
$address->setCity($city);
|
||||||
|
$address->setStreet('1 rue du Test');
|
||||||
|
$em->persist($address);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
return $address;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seede un ClientRib valide rattache a un client (sans passer par l'API). Le
|
||||||
|
* libelle est parametrable pour seeder plusieurs RIB distincts.
|
||||||
|
*/
|
||||||
|
private function seedRib(ClientEntity $client, string $label = 'Seed RIB'): ClientRib
|
||||||
{
|
{
|
||||||
$em = $this->getEm();
|
$em = $this->getEm();
|
||||||
$rib = new ClientRib();
|
$rib = new ClientRib();
|
||||||
$rib->setLabel('Seed RIB');
|
$rib->setLabel($label);
|
||||||
$rib->setBic(self::VALID_BIC);
|
$rib->setBic(self::VALID_BIC);
|
||||||
$rib->setIban(self::VALID_IBAN);
|
$rib->setIban(self::VALID_IBAN);
|
||||||
$rib->setClient($client);
|
$rib->setClient($client);
|
||||||
|
|||||||
Reference in New Issue
Block a user