Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d8a633eee | |||
| df9451a5f4 | |||
| cb12490ba0 | |||
| a442d124a3 | |||
| 431d831c8b | |||
| 3f356f0679 |
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.106'
|
app.version: '0.1.109'
|
||||||
|
|||||||
@@ -26,13 +26,18 @@
|
|||||||
:error="errors?.firstName"
|
:error="errors?.firstName"
|
||||||
@update:model-value="(v: string) => update('firstName', v)"
|
@update:model-value="(v: string) => update('firstName', v)"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
|
||||||
:model-value="model.jobTitle"
|
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
|
||||||
:label="t('commercial.clients.form.contact.jobTitle')"
|
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
||||||
:readonly="readonly"
|
<div class="col-span-2">
|
||||||
:error="errors?.jobTitle"
|
<MalioInputText
|
||||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
:model-value="model.jobTitle"
|
||||||
/>
|
:label="t('commercial.clients.form.contact.jobTitle')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:error="errors?.jobTitle"
|
||||||
|
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<MalioInputEmail
|
<MalioInputEmail
|
||||||
:model-value="model.email"
|
:model-value="model.email"
|
||||||
:label="t('commercial.clients.form.contact.email')"
|
:label="t('commercial.clients.form.contact.email')"
|
||||||
|
|||||||
@@ -25,13 +25,18 @@
|
|||||||
:error="errors?.firstName"
|
:error="errors?.firstName"
|
||||||
@update:model-value="(v: string) => update('firstName', v)"
|
@update:model-value="(v: string) => update('firstName', v)"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
|
||||||
:model-value="model.jobTitle"
|
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
|
||||||
:label="t('commercial.suppliers.form.contact.jobTitle')"
|
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
||||||
:readonly="readonly"
|
<div class="col-span-2">
|
||||||
:error="errors?.jobTitle"
|
<MalioInputText
|
||||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
:model-value="model.jobTitle"
|
||||||
/>
|
:label="t('commercial.suppliers.form.contact.jobTitle')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:error="errors?.jobTitle"
|
||||||
|
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<MalioInputEmail
|
<MalioInputEmail
|
||||||
:model-value="model.email"
|
:model-value="model.email"
|
||||||
:label="t('commercial.suppliers.form.contact.email')"
|
:label="t('commercial.suppliers.form.contact.email')"
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
|
|||||||
if (url === '/sites') {
|
if (url === '/sites') {
|
||||||
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault', postalCode: '86100' }] })
|
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault', postalCode: '86100' }] })
|
||||||
}
|
}
|
||||||
|
if (url === '/countries') {
|
||||||
|
// Pays : value === label === name (l'adresse stocke le nom).
|
||||||
|
return Promise.resolve({ member: [{ '@id': '/api/countries/1', code: 'FR', name: 'France' }] })
|
||||||
|
}
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
member: [{ '@id': '/api/x/1', code: 'X', label: 'Libelle X' }],
|
member: [{ '@id': '/api/x/1', code: 'X', label: 'Libelle X' }],
|
||||||
})
|
})
|
||||||
@@ -44,6 +48,8 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
|
|||||||
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: '86' }])
|
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: '86' }])
|
||||||
expect(refs.tvaModes.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }])
|
expect(refs.tvaModes.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }])
|
||||||
expect(refs.banks.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }])
|
expect(refs.banks.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }])
|
||||||
|
// Pays : value = nom du pays (et non l'IRI).
|
||||||
|
expect(refs.countries.value).toEqual([{ value: 'France', label: 'France' }])
|
||||||
|
|
||||||
// Seul le select en echec reste vide.
|
// Seul le select en echec reste vide.
|
||||||
expect(refs.categories.value).toEqual([])
|
expect(refs.categories.value).toEqual([])
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ref } from 'vue'
|
|||||||
/**
|
/**
|
||||||
* Charge les referentiels (listes courtes) alimentant les selects de l'ecran
|
* Charge les referentiels (listes courtes) alimentant les selects de l'ecran
|
||||||
* « Ajouter un client » : categories, sites, modes de TVA, delais et types de
|
* « Ajouter un client » : categories, sites, modes de TVA, delais et types de
|
||||||
* reglement, banques, et les listes distributeurs / courtiers.
|
* reglement, banques, pays, et les listes distributeurs / courtiers.
|
||||||
*
|
*
|
||||||
* Toutes les collections sont recuperees en entier via l'echappatoire prevue
|
* Toutes les collections sont recuperees en entier via l'echappatoire prevue
|
||||||
* `?pagination=false` (referentiels de quelques dizaines d'entrees max), avec
|
* `?pagination=false` (referentiels de quelques dizaines d'entrees max), avec
|
||||||
@@ -57,6 +57,11 @@ interface ClientMember extends HydraMember {
|
|||||||
companyName: string
|
companyName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CountryMember extends HydraMember {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
|
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
|
||||||
|
|
||||||
export function useClientReferentials() {
|
export function useClientReferentials() {
|
||||||
@@ -68,6 +73,7 @@ export function useClientReferentials() {
|
|||||||
const paymentDelays = ref<RefOption[]>([])
|
const paymentDelays = ref<RefOption[]>([])
|
||||||
const paymentTypes = ref<PaymentTypeOption[]>([])
|
const paymentTypes = ref<PaymentTypeOption[]>([])
|
||||||
const banks = ref<RefOption[]>([])
|
const banks = ref<RefOption[]>([])
|
||||||
|
const countries = ref<RefOption[]>([])
|
||||||
const distributors = ref<ClientOption[]>([])
|
const distributors = ref<ClientOption[]>([])
|
||||||
const brokers = ref<ClientOption[]>([])
|
const brokers = ref<ClientOption[]>([])
|
||||||
|
|
||||||
@@ -116,6 +122,12 @@ export function useClientReferentials() {
|
|||||||
.then((types) => { paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code })) }),
|
.then((types) => { paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code })) }),
|
||||||
fetchAll<ReferentialMember>('/banks')
|
fetchAll<ReferentialMember>('/banks')
|
||||||
.then((banksList) => { banks.value = banksList.map(b => ({ value: b['@id'], label: b.label })) }),
|
.then((banksList) => { banks.value = banksList.map(b => ({ value: b['@id'], label: b.label })) }),
|
||||||
|
// Pays (ERP-116) : la valeur d'option est le NOM du pays (et non l'IRI),
|
||||||
|
// car l'adresse stocke `country` en chaine libre (« France »...). On
|
||||||
|
// conserve ainsi la compatibilite avec les adresses existantes sans FK
|
||||||
|
// ni migration de donnees a ce stade. value === label.
|
||||||
|
fetchAll<CountryMember>('/countries')
|
||||||
|
.then((list) => { countries.value = list.map(c => ({ value: c.name, label: c.name })) }),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +156,7 @@ export function useClientReferentials() {
|
|||||||
paymentDelays,
|
paymentDelays,
|
||||||
paymentTypes,
|
paymentTypes,
|
||||||
banks,
|
banks,
|
||||||
|
countries,
|
||||||
distributors,
|
distributors,
|
||||||
brokers,
|
brokers,
|
||||||
loadCommon,
|
loadCommon,
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ interface ReferentialMember extends HydraMember {
|
|||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CountryMember extends HydraMember {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
|
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
|
||||||
|
|
||||||
export function useSupplierReferentials() {
|
export function useSupplierReferentials() {
|
||||||
@@ -62,6 +67,7 @@ export function useSupplierReferentials() {
|
|||||||
const paymentDelays = ref<RefOption[]>([])
|
const paymentDelays = ref<RefOption[]>([])
|
||||||
const paymentTypes = ref<PaymentTypeOption[]>([])
|
const paymentTypes = ref<PaymentTypeOption[]>([])
|
||||||
const banks = ref<RefOption[]>([])
|
const banks = ref<RefOption[]>([])
|
||||||
|
const countries = ref<RefOption[]>([])
|
||||||
|
|
||||||
/** Recupere une collection complete (pagination desactivee) en Hydra. */
|
/** Recupere une collection complete (pagination desactivee) en Hydra. */
|
||||||
async function fetchAll<T extends HydraMember>(
|
async function fetchAll<T extends HydraMember>(
|
||||||
@@ -103,6 +109,13 @@ export function useSupplierReferentials() {
|
|||||||
.then((types) => { paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code })) }),
|
.then((types) => { paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code })) }),
|
||||||
fetchAll<ReferentialMember>('/banks')
|
fetchAll<ReferentialMember>('/banks')
|
||||||
.then((banksList) => { banks.value = banksList.map(b => ({ value: b['@id'], label: b.label })) }),
|
.then((banksList) => { banks.value = banksList.map(b => ({ value: b['@id'], label: b.label })) }),
|
||||||
|
// Pays (ERP-116) : la valeur d'option est le NOM du pays (et non l'IRI),
|
||||||
|
// car l'adresse stocke `country` en chaine libre (« France »...). On
|
||||||
|
// conserve ainsi la compatibilite avec les adresses existantes sans FK
|
||||||
|
// ni migration de donnees a ce stade. value === label. Aligne sur les
|
||||||
|
// clients (`useClientReferentials`) pour une liste de pays identique.
|
||||||
|
fetchAll<CountryMember>('/countries')
|
||||||
|
.then((list) => { countries.value = list.map(c => ({ value: c.name, label: c.name })) }),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +126,7 @@ export function useSupplierReferentials() {
|
|||||||
paymentDelays,
|
paymentDelays,
|
||||||
paymentTypes,
|
paymentTypes,
|
||||||
banks,
|
banks,
|
||||||
|
countries,
|
||||||
loadCommon,
|
loadCommon,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -553,10 +553,21 @@ const contactOptions = computed<RefOption[]>(() =>
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
const countryOptions: RefOption[] = [
|
// Pays : referentiel `country` charge via l'API (ERP-116), en remplacement de
|
||||||
{ value: 'France', label: 'France' },
|
// l'ancienne liste codee en dur. Valeur = nom du pays (l'adresse stocke
|
||||||
{ value: 'Espagne', label: 'Espagne' },
|
// `country` en chaine libre, donc value === label). On merge la valeur deja
|
||||||
]
|
// stockee sur chaque adresse (embed) — comme les autres selects de cet ecran —
|
||||||
|
// pour ne pas vider le select si `/countries` echoue (resilience ERP-102) ou si
|
||||||
|
// un pays historique n'appartient pas au referentiel.
|
||||||
|
const embedCountryOptions = computed<RefOption[]>(() =>
|
||||||
|
mergeOptions([], (client.value?.addresses ?? [])
|
||||||
|
.map(a => a.country)
|
||||||
|
.filter((c): c is string => !!c)
|
||||||
|
.map(c => ({ value: c, label: c }))),
|
||||||
|
)
|
||||||
|
const countryOptions = computed<RefOption[]>(() =>
|
||||||
|
mergeOptions(referentials.countries.value, embedCountryOptions.value),
|
||||||
|
)
|
||||||
|
|
||||||
const relationOptions = computed<RefOption[]>(() => [
|
const relationOptions = computed<RefOption[]>(() => [
|
||||||
{ value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') },
|
{ value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') },
|
||||||
@@ -901,17 +912,16 @@ const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
|
|||||||
function onPaymentTypeChange(value: string | number | null): void {
|
function onPaymentTypeChange(value: string | number | null): void {
|
||||||
accounting.paymentTypeIri = value === null ? null : String(value)
|
accounting.paymentTypeIri = value === null ? null : String(value)
|
||||||
if (!isBankRequired.value) accounting.bankIri = null
|
if (!isBankRequired.value) accounting.bankIri = null
|
||||||
// Les RIB n'ont de sens que pour une LCR (RG-1.13) : on amorce un bloc vide
|
// ERP-121 : un RIB est une coordonnee bancaire du client, decouplee du mode de
|
||||||
// quand LCR est choisi, sinon on vide la liste — les RIB deja persistes sont
|
// reglement. Au passage hors-LCR on ne SUPPRIME plus les RIB existants : ils
|
||||||
// marques pour suppression serveur au prochain enregistrement.
|
// restent en base, simplement masques a l'ecran (visibleRibs = []), et
|
||||||
|
// reapparaissent tels quels si l'on repasse en LCR. Seule la corbeille d'un
|
||||||
|
// bloc (askRemoveRib) retire reellement un RIB.
|
||||||
if (isRibRequired.value) {
|
if (isRibRequired.value) {
|
||||||
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
for (const rib of ribs.value) {
|
// Hors-LCR : on nettoie seulement les erreurs inline (plus affichees).
|
||||||
if (rib.id != null) removedRibIds.value.push(rib.id)
|
|
||||||
}
|
|
||||||
ribs.value = []
|
|
||||||
ribErrors.value = []
|
ribErrors.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -940,50 +950,58 @@ function askRemoveRib(index: number): void {
|
|||||||
/**
|
/**
|
||||||
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
|
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
|
||||||
* PATCH des scalaires (groupe client:write:accounting, exige accounting.manage cote
|
* PATCH des scalaires (groupe client:write:accounting, exige accounting.manage cote
|
||||||
* back) PUIS DELETE des RIB retires. Les RIB crees d'abord : le back valide RG-1.13
|
* back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le back
|
||||||
* (LCR => au moins un RIB persiste) sur le PATCH scalaires ; les suppressions en
|
* valide RG-1.13 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
|
||||||
* dernier (le guard back n'autorise la suppression du dernier RIB qu'une fois quitte
|
*
|
||||||
* LCR). Aucun champ main/information dans le payload (mode strict RG-1.28 : sinon
|
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
|
||||||
* 403 sur tout le payload).
|
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
|
||||||
|
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES
|
||||||
|
* (corbeille d'un bloc, toujours sous LCR), plus l'auto-suppression au changement
|
||||||
|
* de type de reglement. Aucun champ main/information dans le payload (mode strict
|
||||||
|
* RG-1.28 : sinon 403 sur tout le payload).
|
||||||
*/
|
*/
|
||||||
async function submitAccounting(): Promise<void> {
|
async function submitAccounting(): Promise<void> {
|
||||||
if (accountingReadonly.value || tabSubmitting.value) return
|
if (accountingReadonly.value || tabSubmitting.value) return
|
||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
accountingErrors.clearErrors()
|
accountingErrors.clearErrors()
|
||||||
try {
|
try {
|
||||||
// 1) POST/PATCH des RIB d'abord (erreurs inline par ligne, tous les blocs
|
// 1) POST/PATCH des RIB d'abord — UNIQUEMENT sous LCR (erreurs inline par
|
||||||
// tentes). Le back exige >=1 RIB persiste pour valider une LCR a l'etape 2.
|
// ligne, tous les blocs tentes). Le back exige >=1 RIB persiste pour valider
|
||||||
|
// une LCR a l'etape 2. Hors-LCR (ERP-121), les RIB sont des coordonnees
|
||||||
|
// dormantes : rien d'editable n'est affiche, on ne les re-soumet pas.
|
||||||
// On ne saute une amorce neuve vide QUE s'il reste un autre RIB soumettable :
|
// On ne saute une amorce neuve vide QUE s'il reste un autre RIB soumettable :
|
||||||
// sinon (ex. l'unique RIB existant supprime, remplace par un bloc vide), on la
|
// sinon (ex. l'unique RIB existant supprime, remplace par un bloc vide), on la
|
||||||
// soumet pour declencher la 422 NotBlank inline plutot que de laisser le DELETE
|
// soumet pour declencher la 422 NotBlank inline plutot que de laisser le DELETE
|
||||||
// echouer en « dernier RIB d'une LCR » (message plat sans propertyPath).
|
// echouer en « dernier RIB d'une LCR » (message plat sans propertyPath).
|
||||||
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
|
if (isRibRequired.value) {
|
||||||
const ribHasError = await submitRows(
|
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
|
||||||
ribs.value,
|
const ribHasError = await submitRows(
|
||||||
ribErrors,
|
ribs.value,
|
||||||
async (rib) => {
|
ribErrors,
|
||||||
// Edition d'un RIB existant : champ requis vide envoye en `''` (NotBlank
|
async (rib) => {
|
||||||
// 422) au lieu d'etre omis (sinon le PATCH garderait l'ancienne valeur).
|
// Edition d'un RIB existant : champ requis vide envoye en `''` (NotBlank
|
||||||
const body = buildRibPayload(rib, { forUpdate: rib.id !== null })
|
// 422) au lieu d'etre omis (sinon le PATCH garderait l'ancienne valeur).
|
||||||
if (rib.id === null) {
|
const body = buildRibPayload(rib, { forUpdate: rib.id !== null })
|
||||||
const created = await api.post<{ id: number }>(
|
if (rib.id === null) {
|
||||||
`/clients/${clientId}/ribs`,
|
const created = await api.post<{ id: number }>(
|
||||||
body,
|
`/clients/${clientId}/ribs`,
|
||||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
body,
|
||||||
)
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
rib.id = created.id
|
)
|
||||||
}
|
rib.id = created.id
|
||||||
else {
|
}
|
||||||
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
|
else {
|
||||||
}
|
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
|
||||||
},
|
}
|
||||||
error => showError(error),
|
},
|
||||||
// On ne saute une amorce neuve (id null) totalement vide que si un autre RIB
|
error => showError(error),
|
||||||
// est soumettable. Un RIB existant vide est toujours soumis -> 422 NotBlank
|
// On ne saute une amorce neuve (id null) totalement vide que si un autre RIB
|
||||||
// inline (sinon la modif serait perdue en silence avec un faux toast succes).
|
// est soumettable. Un RIB existant vide est toujours soumis -> 422 NotBlank
|
||||||
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
|
// inline (sinon la modif serait perdue en silence avec un faux toast succes).
|
||||||
)
|
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
|
||||||
if (ribHasError) return
|
)
|
||||||
|
if (ribHasError) return
|
||||||
|
}
|
||||||
|
|
||||||
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
|
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
|
||||||
try {
|
try {
|
||||||
@@ -994,8 +1012,9 @@ async function submitAccounting(): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) DELETE des RIB retires : APRES le PATCH scalaires (si on quitte LCR, le
|
// 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
|
||||||
// guard back n'autorise la suppression du dernier RIB qu'une fois le type change).
|
// PATCH scalaires (le guard back refuse la suppression du dernier RIB d'une
|
||||||
|
// LCR). ERP-121 : plus aucune suppression automatique au passage hors-LCR.
|
||||||
for (const id of removedRibIds.value) {
|
for (const id of removedRibIds.value) {
|
||||||
await api.delete(`/client_ribs/${id}`, {}, { toast: false })
|
await api.delete(`/client_ribs/${id}`, {}, { toast: false })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -280,7 +280,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useClient } from '~/modules/commercial/composables/useClient'
|
import { useClient } from '~/modules/commercial/composables/useClient'
|
||||||
import { buildClientFormTabKeys } from '~/modules/commercial/utils/clientFormRules'
|
import { buildClientFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/clientFormRules'
|
||||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||||
import {
|
import {
|
||||||
canEditClient,
|
canEditClient,
|
||||||
@@ -290,6 +290,7 @@ import {
|
|||||||
mapAddressView,
|
mapAddressView,
|
||||||
mapContactToDraft,
|
mapContactToDraft,
|
||||||
mapRibToDraft,
|
mapRibToDraft,
|
||||||
|
paymentTypeCodeOf,
|
||||||
referentialOptionOf,
|
referentialOptionOf,
|
||||||
relationOf,
|
relationOf,
|
||||||
showArchiveAction,
|
showArchiveAction,
|
||||||
@@ -355,9 +356,16 @@ const addressViews = computed(() => {
|
|||||||
return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }]
|
return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }]
|
||||||
})
|
})
|
||||||
// Exception au placeholder ci-dessus : on n'affiche AUCUN bloc RIB quand le
|
// Exception au placeholder ci-dessus : on n'affiche AUCUN bloc RIB quand le
|
||||||
// client n'en a pas (un RIB n'existe que pour un reglement LCR — RG-1.13). Pas
|
// client n'en a pas. Pas de bloc vierge fantome en consultation.
|
||||||
// de bloc vierge fantome en consultation.
|
// ERP-121 : un client peut desormais conserver des RIB « dormants » apres etre
|
||||||
const ribs = computed(() => (client.value?.ribs ?? []).map(mapRibToDraft))
|
// repasse hors-LCR (on ne les supprime plus). En consultation, decision metier =
|
||||||
|
// on les masque TOTALEMENT : on n'affiche les RIB que si le type de reglement
|
||||||
|
// courant est LCR (le `code` est embarque sous client:read:accounting).
|
||||||
|
const ribs = computed(() =>
|
||||||
|
isRibRequiredForPaymentType(paymentTypeCodeOf(client.value?.paymentType))
|
||||||
|
? (client.value?.ribs ?? []).map(mapRibToDraft)
|
||||||
|
: [],
|
||||||
|
)
|
||||||
// Draft comptable (tout null si l'utilisateur n'a pas accounting.view).
|
// Draft comptable (tout null si l'utilisateur n'a pas accounting.view).
|
||||||
const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as ClientDetail)))
|
const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as ClientDetail)))
|
||||||
|
|
||||||
@@ -384,10 +392,18 @@ const relationOptions = computed<SelectOption[]>(() => [
|
|||||||
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') },
|
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') },
|
||||||
])
|
])
|
||||||
|
|
||||||
const countryOptions: SelectOption[] = [
|
// Pays (ERP-116) : options construites depuis l'EMBED des adresses (jamais via
|
||||||
{ value: 'France', label: 'France' },
|
// GET /countries, sur le meme principe que les autres selects de consultation
|
||||||
{ value: 'Espagne', label: 'Espagne' },
|
// — en 403 pour les roles metier non-admin). Valeur = nom du pays stocke tel
|
||||||
]
|
// quel dans l'adresse, donc value === label ; suffit a afficher le libelle en
|
||||||
|
// lecture seule.
|
||||||
|
const countryOptions = computed<SelectOption[]>(() =>
|
||||||
|
[...new Set(
|
||||||
|
(client.value?.addresses ?? [])
|
||||||
|
.map(a => a.country)
|
||||||
|
.filter((c): c is string => !!c),
|
||||||
|
)].map(c => ({ value: c, label: c })),
|
||||||
|
)
|
||||||
|
|
||||||
// Selects comptables : libelle issu de l'embed (option unique ou vide).
|
// Selects comptables : libelle issu de l'embed (option unique ou vide).
|
||||||
const tvaModeOptions = computed(() => referentialOptionOf(client.value?.tvaMode))
|
const tvaModeOptions = computed(() => referentialOptionOf(client.value?.tvaMode))
|
||||||
|
|||||||
@@ -778,11 +778,17 @@ const contactOptions = computed<RefOption[]>(() =>
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Pays disponibles (France preselectionnee par defaut sur chaque adresse).
|
// Pays disponibles : referentiel `country` charge via l'API (ERP-116), en
|
||||||
const countryOptions: RefOption[] = [
|
// remplacement de l'ancienne liste codee en dur. France reste preselectionnee
|
||||||
{ value: 'France', label: 'France' },
|
// par defaut sur chaque adresse (cf. valeur initiale du draft d'adresse) : on
|
||||||
{ value: 'Espagne', label: 'Espagne' },
|
// garantit donc sa presence en fallback si `/countries` echoue (resilience
|
||||||
]
|
// ERP-102), pour ne pas afficher un select vide sur une valeur deja soumise.
|
||||||
|
const countryOptions = computed<RefOption[]>(() => {
|
||||||
|
const list = referentials.countries.value
|
||||||
|
return list.some(c => c.value === 'France')
|
||||||
|
? list
|
||||||
|
: [{ value: 'France', label: 'France' }, ...list]
|
||||||
|
})
|
||||||
|
|
||||||
// « + Adresse » desactive tant que la derniere adresse n'est pas valide.
|
// « + Adresse » desactive tant que la derniere adresse n'est pas valide.
|
||||||
const canAddAddress = computed(() => {
|
const canAddAddress = computed(() => {
|
||||||
@@ -875,13 +881,14 @@ function onPaymentTypeChange(value: string | number | null): void {
|
|||||||
accounting.paymentTypeIri = value === null ? null : String(value)
|
accounting.paymentTypeIri = value === null ? null : String(value)
|
||||||
// La banque n'a de sens que pour un virement : on la vide sinon (RG-1.12).
|
// La banque n'a de sens que pour un virement : on la vide sinon (RG-1.12).
|
||||||
if (!isBankRequired.value) accounting.bankIri = null
|
if (!isBankRequired.value) accounting.bankIri = null
|
||||||
// Les RIB n'ont de sens que pour une LCR (RG-1.13) : on amorce un bloc vide
|
// ERP-121 : on ne jette plus la saisie RIB au passage hors-LCR. Les blocs sont
|
||||||
// quand LCR est choisi, on vide la liste sinon (pas de RIB fantome soumis).
|
// masques (visibleRibs = []) mais conserves, et reapparaissent si l'on repasse
|
||||||
|
// en LCR. Ils ne sont persistes qu'a la validation SOUS LCR (cf. submitAccounting),
|
||||||
|
// donc une saisie abandonnee hors-LCR ne cree aucun RIB orphelin.
|
||||||
if (isRibRequired.value) {
|
if (isRibRequired.value) {
|
||||||
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
ribs.value = []
|
|
||||||
ribErrors.value = []
|
ribErrors.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -918,36 +925,41 @@ async function submitAccounting(): Promise<void> {
|
|||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
accountingErrors.clearErrors()
|
accountingErrors.clearErrors()
|
||||||
try {
|
try {
|
||||||
// 1) POST/PATCH des RIB d'abord (erreurs inline par ligne, tous les blocs
|
// 1) POST/PATCH des RIB d'abord — UNIQUEMENT sous LCR (erreurs inline par
|
||||||
// tentes). Le back exige >=1 RIB persiste pour valider une LCR a l'etape 2.
|
// ligne, tous les blocs tentes). Le back exige >=1 RIB persiste pour valider
|
||||||
|
// une LCR a l'etape 2. Hors-LCR (ERP-121), une saisie RIB eventuellement
|
||||||
|
// restee dans le brouillon est masquee et n'est PAS persistee (pas de RIB
|
||||||
|
// orphelin sur un client en virement).
|
||||||
// On ne saute une amorce neuve vide QUE s'il reste un autre RIB soumettable :
|
// On ne saute une amorce neuve vide QUE s'il reste un autre RIB soumettable :
|
||||||
// sinon (LCR sans aucun RIB rempli) on la soumet -> 422 NotBlank inline.
|
// sinon (LCR sans aucun RIB rempli) on la soumet -> 422 NotBlank inline.
|
||||||
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
|
if (isRibRequired.value) {
|
||||||
const ribHasError = await submitRows(
|
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
|
||||||
ribs.value,
|
const ribHasError = await submitRows(
|
||||||
ribErrors,
|
ribs.value,
|
||||||
async (rib) => {
|
ribErrors,
|
||||||
// Payload partage avec l'edition (buildRibPayload, ERP-119).
|
async (rib) => {
|
||||||
const body = buildRibPayload(rib)
|
// Payload partage avec l'edition (buildRibPayload, ERP-119).
|
||||||
if (rib.id === null) {
|
const body = buildRibPayload(rib)
|
||||||
const created = await api.post<{ id: number }>(
|
if (rib.id === null) {
|
||||||
`/clients/${clientId.value}/ribs`,
|
const created = await api.post<{ id: number }>(
|
||||||
body,
|
`/clients/${clientId.value}/ribs`,
|
||||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
body,
|
||||||
)
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
rib.id = created.id
|
)
|
||||||
}
|
rib.id = created.id
|
||||||
else {
|
}
|
||||||
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
|
else {
|
||||||
}
|
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
|
||||||
},
|
}
|
||||||
error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
|
},
|
||||||
// On ne saute une amorce neuve (id null) totalement vide que si un autre RIB
|
error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
|
||||||
// est soumettable. Un RIB existant vide est toujours soumis -> 422 NotBlank
|
// On ne saute une amorce neuve (id null) totalement vide que si un autre RIB
|
||||||
// inline (sinon la modif serait perdue en silence avec un faux toast succes).
|
// est soumettable. Un RIB existant vide est toujours soumis -> 422 NotBlank
|
||||||
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
|
// inline (sinon la modif serait perdue en silence avec un faux toast succes).
|
||||||
)
|
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
|
||||||
if (ribHasError) return
|
)
|
||||||
|
if (ribHasError) return
|
||||||
|
}
|
||||||
|
|
||||||
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
|
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -515,10 +515,19 @@ const contactOptions = computed<RefOption[]>(() =>
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
const countryOptions: RefOption[] = [
|
// Pays : referentiel `country` charge via l'API (ERP-116), aligne sur l'ecran
|
||||||
{ value: 'France', label: 'France' },
|
// client. On merge la valeur deja stockee sur chaque adresse (embed) — comme les
|
||||||
{ value: 'Espagne', label: 'Espagne' },
|
// autres selects de cet ecran — pour ne pas vider le select si `/countries`
|
||||||
]
|
// echoue (resilience ERP-102) ou si un pays historique n'est plus au referentiel.
|
||||||
|
const embedCountryOptions = computed<RefOption[]>(() =>
|
||||||
|
mergeOptions([], (supplier.value?.addresses ?? [])
|
||||||
|
.map(a => a.country)
|
||||||
|
.filter((c): c is string => !!c)
|
||||||
|
.map(c => ({ value: c, label: c }))),
|
||||||
|
)
|
||||||
|
const countryOptions = computed<RefOption[]>(() =>
|
||||||
|
mergeOptions(referentials.countries.value, embedCountryOptions.value),
|
||||||
|
)
|
||||||
|
|
||||||
// Selects comptables : referentiel UNION valeur courante de l'embed (libelle).
|
// Selects comptables : referentiel UNION valeur courante de l'embed (libelle).
|
||||||
const tvaModeOptions = computed(() => mergeOptions(referentials.tvaModes.value, referentialOptionOf(supplier.value?.tvaMode)))
|
const tvaModeOptions = computed(() => mergeOptions(referentials.tvaModes.value, referentialOptionOf(supplier.value?.tvaMode)))
|
||||||
@@ -792,17 +801,16 @@ const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
|
|||||||
function onPaymentTypeChange(value: string | number | null): void {
|
function onPaymentTypeChange(value: string | number | null): void {
|
||||||
accounting.paymentTypeIri = value === null ? null : String(value)
|
accounting.paymentTypeIri = value === null ? null : String(value)
|
||||||
if (!isBankRequired.value) accounting.bankIri = null
|
if (!isBankRequired.value) accounting.bankIri = null
|
||||||
// Les RIB n'ont de sens que pour une LCR (RG-2.08) : on amorce un bloc vide
|
// ERP-121 : un RIB est une coordonnee bancaire du fournisseur, decouplee du mode
|
||||||
// quand LCR est choisi, sinon on vide la liste — les RIB deja persistes sont
|
// de reglement. Au passage hors-LCR on ne SUPPRIME plus les RIB existants : ils
|
||||||
// marques pour suppression serveur au prochain enregistrement.
|
// restent en base, simplement masques a l'ecran (visibleRibs = []), et
|
||||||
|
// reapparaissent tels quels si l'on repasse en LCR. Seule la corbeille d'un
|
||||||
|
// bloc (askRemoveRib) retire reellement un RIB.
|
||||||
if (isRibRequired.value) {
|
if (isRibRequired.value) {
|
||||||
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
for (const rib of ribs.value) {
|
// Hors-LCR : on nettoie seulement les erreurs inline (plus affichees).
|
||||||
if (rib.id != null) removedRibIds.value.push(rib.id)
|
|
||||||
}
|
|
||||||
ribs.value = []
|
|
||||||
ribErrors.value = []
|
ribErrors.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -831,44 +839,53 @@ function askRemoveRib(index: number): void {
|
|||||||
/**
|
/**
|
||||||
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
|
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
|
||||||
* PATCH des scalaires (groupe supplier:write:accounting, exige accounting.manage
|
* PATCH des scalaires (groupe supplier:write:accounting, exige accounting.manage
|
||||||
* cote back) PUIS DELETE des RIB retires. Les RIB crees d'abord : le back valide
|
* cote back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le
|
||||||
* RG-2.08 (LCR => au moins un RIB persiste) sur le PATCH scalaires. Aucun champ
|
* back valide RG-2.08 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
|
||||||
* main/information dans le payload (mode strict RG-2.16 : sinon 403 sur tout le payload).
|
*
|
||||||
|
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
|
||||||
|
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
|
||||||
|
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES
|
||||||
|
* (corbeille d'un bloc, toujours sous LCR). Aucun champ main/information dans le
|
||||||
|
* payload (mode strict RG-2.16 : sinon 403 sur tout le payload).
|
||||||
*/
|
*/
|
||||||
async function submitAccounting(): Promise<void> {
|
async function submitAccounting(): Promise<void> {
|
||||||
if (accountingReadonly.value || tabSubmitting.value) return
|
if (accountingReadonly.value || tabSubmitting.value) return
|
||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
accountingErrors.clearErrors()
|
accountingErrors.clearErrors()
|
||||||
try {
|
try {
|
||||||
// 1) POST/PATCH des RIB d'abord (erreurs inline par ligne, tous les blocs
|
// 1) POST/PATCH des RIB d'abord — UNIQUEMENT sous LCR (erreurs inline par
|
||||||
// tentes). On ne saute une amorce neuve vide QUE s'il reste un autre RIB
|
// ligne, tous les blocs tentes). Hors-LCR (ERP-121), les RIB sont des
|
||||||
|
// coordonnees dormantes : rien d'editable n'est affiche, on ne les re-soumet
|
||||||
|
// pas. On ne saute une amorce neuve vide QUE s'il reste un autre RIB
|
||||||
// soumettable : sinon (ex. l'unique RIB existant supprime, remplace par un
|
// soumettable : sinon (ex. l'unique RIB existant supprime, remplace par un
|
||||||
// bloc vide), on la soumet pour declencher la 422 NotBlank inline plutot que
|
// bloc vide), on la soumet pour declencher la 422 NotBlank inline plutot que
|
||||||
// de laisser le DELETE echouer en « dernier RIB d'une LCR » (message plat).
|
// de laisser le DELETE echouer en « dernier RIB d'une LCR » (message plat).
|
||||||
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
|
if (isRibRequired.value) {
|
||||||
const ribHasError = await submitRows(
|
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
|
||||||
ribs.value,
|
const ribHasError = await submitRows(
|
||||||
ribErrors,
|
ribs.value,
|
||||||
async (rib) => {
|
ribErrors,
|
||||||
// Edition d'un RIB existant : champ requis vide envoye en `''` (NotBlank
|
async (rib) => {
|
||||||
// 422) au lieu d'etre omis (sinon le PATCH garderait l'ancienne valeur).
|
// Edition d'un RIB existant : champ requis vide envoye en `''` (NotBlank
|
||||||
const body = buildRibPayload(rib, { forUpdate: rib.id !== null })
|
// 422) au lieu d'etre omis (sinon le PATCH garderait l'ancienne valeur).
|
||||||
if (rib.id === null) {
|
const body = buildRibPayload(rib, { forUpdate: rib.id !== null })
|
||||||
const created = await api.post<{ id: number }>(
|
if (rib.id === null) {
|
||||||
`/suppliers/${supplierId}/ribs`,
|
const created = await api.post<{ id: number }>(
|
||||||
body,
|
`/suppliers/${supplierId}/ribs`,
|
||||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
body,
|
||||||
)
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
rib.id = created.id
|
)
|
||||||
}
|
rib.id = created.id
|
||||||
else {
|
}
|
||||||
await api.patch(`/supplier_ribs/${rib.id}`, body, { toast: false })
|
else {
|
||||||
}
|
await api.patch(`/supplier_ribs/${rib.id}`, body, { toast: false })
|
||||||
},
|
}
|
||||||
error => showError(error),
|
},
|
||||||
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
|
error => showError(error),
|
||||||
)
|
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
|
||||||
if (ribHasError) return
|
)
|
||||||
|
if (ribHasError) return
|
||||||
|
}
|
||||||
|
|
||||||
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
|
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
|
||||||
try {
|
try {
|
||||||
@@ -879,8 +896,9 @@ async function submitAccounting(): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) DELETE des RIB retires : APRES le PATCH scalaires (si on quitte LCR, le
|
// 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
|
||||||
// guard back n'autorise la suppression du dernier RIB qu'une fois le type change).
|
// PATCH scalaires (le guard back refuse la suppression du dernier RIB d'une
|
||||||
|
// LCR). ERP-121 : plus aucune suppression automatique au passage hors-LCR.
|
||||||
for (const id of removedRibIds.value) {
|
for (const id of removedRibIds.value) {
|
||||||
await api.delete(`/supplier_ribs/${id}`, {}, { toast: false })
|
await api.delete(`/supplier_ribs/${id}`, {}, { toast: false })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -263,7 +263,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useSupplier } from '~/modules/commercial/composables/useSupplier'
|
import { useSupplier } from '~/modules/commercial/composables/useSupplier'
|
||||||
import { buildSupplierFormTabKeys } from '~/modules/commercial/utils/supplierFormRules'
|
import { buildSupplierFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/supplierFormRules'
|
||||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||||
import {
|
import {
|
||||||
canEditSupplier,
|
canEditSupplier,
|
||||||
@@ -274,6 +274,7 @@ import {
|
|||||||
mapAddressView,
|
mapAddressView,
|
||||||
mapContactToDraft,
|
mapContactToDraft,
|
||||||
mapRibToDraft,
|
mapRibToDraft,
|
||||||
|
paymentTypeCodeOf,
|
||||||
referentialOptionOf,
|
referentialOptionOf,
|
||||||
showArchiveAction,
|
showArchiveAction,
|
||||||
showRestoreAction,
|
showRestoreAction,
|
||||||
@@ -338,8 +339,15 @@ const addressViews = computed(() => {
|
|||||||
return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }]
|
return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }]
|
||||||
})
|
})
|
||||||
// Exception au placeholder ci-dessus : on n'affiche AUCUN bloc RIB quand le
|
// Exception au placeholder ci-dessus : on n'affiche AUCUN bloc RIB quand le
|
||||||
// fournisseur n'en a pas (un RIB n'existe que pour un reglement LCR — RG-2.08).
|
// fournisseur n'en a pas. ERP-121 : un fournisseur peut desormais conserver des RIB
|
||||||
const ribs = computed(() => (supplier.value?.ribs ?? []).map(mapRibToDraft))
|
// « dormants » apres etre repasse hors-LCR (on ne les supprime plus). En consultation,
|
||||||
|
// decision metier = on les masque TOTALEMENT : on n'affiche les RIB que si le type de
|
||||||
|
// reglement courant est LCR (le `code` est embarque sous supplier:read:accounting).
|
||||||
|
const ribs = computed(() =>
|
||||||
|
isRibRequiredForPaymentType(paymentTypeCodeOf(supplier.value?.paymentType))
|
||||||
|
? (supplier.value?.ribs ?? []).map(mapRibToDraft)
|
||||||
|
: [],
|
||||||
|
)
|
||||||
// Draft comptable (tout null si l'utilisateur n'a pas accounting.view).
|
// Draft comptable (tout null si l'utilisateur n'a pas accounting.view).
|
||||||
const accounting = computed(() => mapAccountingDraft(supplier.value ?? ({} as SupplierDetail)))
|
const accounting = computed(() => mapAccountingDraft(supplier.value ?? ({} as SupplierDetail)))
|
||||||
|
|
||||||
@@ -361,10 +369,16 @@ const allSiteOptions = computed<SelectOption[]>(() =>
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
const countryOptions: SelectOption[] = [
|
// Pays (consultation, lecture seule) : derive des adresses du fournisseur, comme
|
||||||
{ value: 'France', label: 'France' },
|
// l'ecran client. Le referentiel `country` (ERP-116) n'est pas charge ici, l'ecran
|
||||||
{ value: 'Espagne', label: 'Espagne' },
|
// n'affiche que les valeurs deja stockees.
|
||||||
]
|
const countryOptions = computed<SelectOption[]>(() =>
|
||||||
|
[...new Set(
|
||||||
|
(supplier.value?.addresses ?? [])
|
||||||
|
.map(a => a.country)
|
||||||
|
.filter((c): c is string => !!c),
|
||||||
|
)].map(c => ({ value: c, label: c })),
|
||||||
|
)
|
||||||
|
|
||||||
// Selects comptables : libelle issu de l'embed (option unique ou vide).
|
// Selects comptables : libelle issu de l'embed (option unique ou vide).
|
||||||
const tvaModeOptions = computed(() => referentialOptionOf(supplier.value?.tvaMode))
|
const tvaModeOptions = computed(() => referentialOptionOf(supplier.value?.tvaMode))
|
||||||
|
|||||||
@@ -646,11 +646,15 @@ const contactOptions = computed<RefOption[]>(() =>
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Pays disponibles (France preselectionnee par defaut sur chaque adresse).
|
// Pays : referentiel `country` charge via l'API (ERP-116), aligne sur l'ecran
|
||||||
const countryOptions: RefOption[] = [
|
// client. France garantie en tete pour rester preselectionnable par defaut sur
|
||||||
{ value: 'France', label: 'France' },
|
// chaque adresse meme si `/countries` echoue (resilience ERP-102).
|
||||||
{ value: 'Espagne', label: 'Espagne' },
|
const countryOptions = computed<RefOption[]>(() => {
|
||||||
]
|
const list = referentials.countries.value
|
||||||
|
return list.some(c => c.value === 'France')
|
||||||
|
? list
|
||||||
|
: [{ value: 'France', label: 'France' }, ...list]
|
||||||
|
})
|
||||||
|
|
||||||
// « + Adresse » desactive tant que la derniere adresse n'est pas valide.
|
// « + Adresse » desactive tant que la derniere adresse n'est pas valide.
|
||||||
const canAddAddress = computed(() => {
|
const canAddAddress = computed(() => {
|
||||||
@@ -741,13 +745,14 @@ function onPaymentTypeChange(value: string | number | null): void {
|
|||||||
accounting.paymentTypeIri = value === null ? null : String(value)
|
accounting.paymentTypeIri = value === null ? null : String(value)
|
||||||
// La banque n'a de sens que pour un virement : on la vide sinon (RG-2.07).
|
// La banque n'a de sens que pour un virement : on la vide sinon (RG-2.07).
|
||||||
if (!isBankRequired.value) accounting.bankIri = null
|
if (!isBankRequired.value) accounting.bankIri = null
|
||||||
// Les RIB n'ont de sens que pour une LCR (RG-2.08) : amorce un bloc vide quand
|
// ERP-121 : on ne jette plus la saisie RIB au passage hors-LCR. Les blocs sont
|
||||||
// LCR est choisi, vide la liste sinon (pas de RIB fantome soumis).
|
// masques (visibleRibs = []) mais conserves, et reapparaissent si l'on repasse
|
||||||
|
// en LCR. Ils ne sont persistes qu'a la validation SOUS LCR (cf. submitAccounting),
|
||||||
|
// donc une saisie abandonnee hors-LCR ne cree aucun RIB orphelin.
|
||||||
if (isRibRequired.value) {
|
if (isRibRequired.value) {
|
||||||
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
ribs.value = []
|
|
||||||
ribErrors.value = []
|
ribErrors.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -782,31 +787,36 @@ async function submitAccounting(): Promise<void> {
|
|||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
accountingErrors.clearErrors()
|
accountingErrors.clearErrors()
|
||||||
try {
|
try {
|
||||||
// 1) POST/PATCH des RIB d'abord (erreurs inline par ligne). On ne saute une
|
// 1) POST/PATCH des RIB d'abord — UNIQUEMENT sous LCR (erreurs inline par
|
||||||
// amorce neuve vide QUE s'il reste un autre RIB soumettable : sinon (LCR sans
|
// ligne). Hors-LCR (ERP-121), une saisie RIB eventuellement restee dans le
|
||||||
// aucun RIB rempli) on la soumet pour declencher la 422 NotBlank inline.
|
// brouillon est masquee et n'est PAS persistee (pas de RIB orphelin sur un
|
||||||
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
|
// fournisseur en virement). On ne saute une amorce neuve vide QUE s'il reste
|
||||||
const ribHasError = await submitRows(
|
// un autre RIB soumettable : sinon (LCR sans aucun RIB rempli) on la soumet
|
||||||
ribs.value,
|
// pour declencher la 422 NotBlank inline.
|
||||||
ribErrors,
|
if (isRibRequired.value) {
|
||||||
async (rib) => {
|
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
|
||||||
const body = buildRibPayload(rib)
|
const ribHasError = await submitRows(
|
||||||
if (rib.id === null) {
|
ribs.value,
|
||||||
const created = await api.post<{ id: number }>(
|
ribErrors,
|
||||||
`/suppliers/${supplierId.value}/ribs`,
|
async (rib) => {
|
||||||
body,
|
const body = buildRibPayload(rib)
|
||||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
if (rib.id === null) {
|
||||||
)
|
const created = await api.post<{ id: number }>(
|
||||||
rib.id = created.id
|
`/suppliers/${supplierId.value}/ribs`,
|
||||||
}
|
body,
|
||||||
else {
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
await api.patch(`/supplier_ribs/${rib.id}`, body, { toast: false })
|
)
|
||||||
}
|
rib.id = created.id
|
||||||
},
|
}
|
||||||
error => toast.error({ title: t('commercial.suppliers.toast.error'), message: apiErrorMessage(error) }),
|
else {
|
||||||
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
|
await api.patch(`/supplier_ribs/${rib.id}`, body, { toast: false })
|
||||||
)
|
}
|
||||||
if (ribHasError) return
|
},
|
||||||
|
error => toast.error({ title: t('commercial.suppliers.toast.error'), message: apiErrorMessage(error) }),
|
||||||
|
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
|
||||||
|
)
|
||||||
|
if (ribHasError) return
|
||||||
|
}
|
||||||
|
|
||||||
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
|
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
mapAddressView,
|
mapAddressView,
|
||||||
mapContactToDraft,
|
mapContactToDraft,
|
||||||
mapRibToDraft,
|
mapRibToDraft,
|
||||||
|
paymentTypeCodeOf,
|
||||||
referentialOptionOf,
|
referentialOptionOf,
|
||||||
relationOf,
|
relationOf,
|
||||||
showArchiveAction,
|
showArchiveAction,
|
||||||
@@ -233,3 +234,17 @@ describe('showArchiveAction / showRestoreAction', () => {
|
|||||||
expect(showRestoreAction(can([]), true)).toBe(false)
|
expect(showRestoreAction(can([]), true)).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', () => {
|
||||||
|
it('retourne le code metier quand le type de reglement est embarque', () => {
|
||||||
|
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1', code: 'LCR' })).toBe('LCR')
|
||||||
|
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/2', code: 'VIREMENT' })).toBe('VIREMENT')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retourne null pour un IRI nu, un objet sans code, ou une relation absente', () => {
|
||||||
|
expect(paymentTypeCodeOf('/api/payment_types/1')).toBeNull()
|
||||||
|
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1' })).toBeNull()
|
||||||
|
expect(paymentTypeCodeOf(null)).toBeNull()
|
||||||
|
expect(paymentTypeCodeOf(undefined)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
mapAddressView,
|
mapAddressView,
|
||||||
mapContactToDraft,
|
mapContactToDraft,
|
||||||
mapRibToDraft,
|
mapRibToDraft,
|
||||||
|
paymentTypeCodeOf,
|
||||||
referentialOptionOf,
|
referentialOptionOf,
|
||||||
showArchiveAction,
|
showArchiveAction,
|
||||||
showRestoreAction,
|
showRestoreAction,
|
||||||
@@ -222,3 +223,17 @@ describe('showArchiveAction / showRestoreAction', () => {
|
|||||||
expect(showRestoreAction(can([]), true)).toBe(false)
|
expect(showRestoreAction(can([]), true)).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', () => {
|
||||||
|
it('retourne le code metier quand le type de reglement est embarque', () => {
|
||||||
|
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1', code: 'LCR' })).toBe('LCR')
|
||||||
|
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/2', code: 'VIREMENT' })).toBe('VIREMENT')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retourne null pour un IRI nu, un objet sans code, ou une relation absente', () => {
|
||||||
|
expect(paymentTypeCodeOf('/api/payment_types/1')).toBeNull()
|
||||||
|
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1' })).toBeNull()
|
||||||
|
expect(paymentTypeCodeOf(null)).toBeNull()
|
||||||
|
expect(paymentTypeCodeOf(undefined)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -293,6 +293,21 @@ export function referentialOptionOf(relation: Relation): SelectOption[] {
|
|||||||
return [{ value: relation['@id'], label }]
|
return [{ value: relation['@id'], label }]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Code metier d'un referentiel embarque (ex: PaymentType.code = 'LCR' / 'VIREMENT'),
|
||||||
|
* ou null si la relation est absente / serialisee en IRI nu. Type-safe : la branche
|
||||||
|
* chaine (IRI nu) et l'absence sont court-circuitees avant l'acces au code. Sert a
|
||||||
|
* conditionner l'affichage selon le type de reglement courant (ERP-121 : RIB masques
|
||||||
|
* hors-LCR en consultation).
|
||||||
|
*/
|
||||||
|
export function paymentTypeCodeOf(relation: Relation): string | null {
|
||||||
|
if (!relation || typeof relation === 'string') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (relation.code as string | undefined) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
/** Vue d'une adresse (brouillon + options de select propres a l'adresse). */
|
/** Vue d'une adresse (brouillon + options de select propres a l'adresse). */
|
||||||
export function mapAddressView(address: AddressRead): AddressView {
|
export function mapAddressView(address: AddressRead): AddressView {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -268,6 +268,21 @@ export function referentialOptionOf(relation: Relation): SelectOption[] {
|
|||||||
return [{ value: relation['@id'], label }]
|
return [{ value: relation['@id'], label }]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Code metier d'un referentiel embarque (ex: PaymentType.code = 'LCR' / 'VIREMENT'),
|
||||||
|
* ou null si la relation est absente / serialisee en IRI nu. Type-safe : la branche
|
||||||
|
* chaine (IRI nu) et l'absence sont court-circuitees avant l'acces au code. Sert a
|
||||||
|
* conditionner l'affichage selon le type de reglement courant (ERP-121 : RIB masques
|
||||||
|
* hors-LCR en consultation).
|
||||||
|
*/
|
||||||
|
export function paymentTypeCodeOf(relation: Relation): string | null {
|
||||||
|
if (!relation || typeof relation === 'string') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (relation.code as string | undefined) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
/** Vue d'une adresse (brouillon + options de select propres a l'adresse). */
|
/** Vue d'une adresse (brouillon + options de select propres a l'adresse). */
|
||||||
export function mapAddressView(address: AddressRead): AddressView {
|
export function mapAddressView(address: AddressRead): AddressView {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ERP-116 — Referentiel Pays (Country), 1re iteration : creation de la table
|
||||||
|
* `country` + seed des 7 pays (France, Allemagne, Belgique, Espagne, Italie,
|
||||||
|
* Royaume-Uni, Suisse). Devient la source unique du select pays, en
|
||||||
|
* remplacement de la liste codee en dur cote front.
|
||||||
|
*
|
||||||
|
* Perimetre minimal voulu : code ISO 3166-1 alpha-2 + libelle FR + ordre
|
||||||
|
* d'affichage UNIQUEMENT. Aucune longueur bancaire/fiscale (numero de compte,
|
||||||
|
* IBAN, TVA, BIC, SIREN) a ce stade — iteration ulterieure du meme ticket.
|
||||||
|
*
|
||||||
|
* Pas de FK posee sur les adresses (client_address.country / supplier_address)
|
||||||
|
* a cette etape : ces colonnes restent des chaines libres (« France »...), donc
|
||||||
|
* aucune migration de donnees ni rupture de l'existant.
|
||||||
|
*
|
||||||
|
* Namespace racine `DoctrineMigrations` (regle ABSOLUE Starseed n°11) comme les
|
||||||
|
* migrations M1/M2 du module Commercial : pas de migrations_path modulaire
|
||||||
|
* configure pour Commercial, et le tri par timestamp reste garanti.
|
||||||
|
*
|
||||||
|
* Seed idempotent `ON CONFLICT (code) DO NOTHING` : la table peut deja porter
|
||||||
|
* des donnees en prod lors d'un rejeu. Chaque colonne porte un `COMMENT ON
|
||||||
|
* COLUMN` (regle ABSOLUE n°12, garde-fou ColumnsHaveSqlCommentTest) ; la table
|
||||||
|
* est aussi mirroree dans ColumnCommentsCatalog pour survivre au
|
||||||
|
* `schema:update --force` du setup de test.
|
||||||
|
*/
|
||||||
|
final class Version20260609100000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'ERP-116 : table country (referentiel pays) + seed des 7 pays.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE country (
|
||||||
|
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||||
|
code VARCHAR(2) NOT NULL,
|
||||||
|
name VARCHAR(80) NOT NULL,
|
||||||
|
position INT DEFAULT 0 NOT NULL,
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX uq_country_code ON country (code)');
|
||||||
|
|
||||||
|
$this->comment('country', '_table', 'Referentiel des pays selectionnables dans les adresses (clients/fournisseurs). Perimetre minimal : code ISO + libelle + ordre (pas de longueurs bancaires/fiscales).');
|
||||||
|
$this->comment('country', 'id', 'Identifiant interne auto-incremente.');
|
||||||
|
$this->comment('country', 'code', 'Code pays ISO 3166-1 alpha-2 (2 lettres MAJUSCULES, ex: FR) — unique (uq_country_code), fige a la creation.');
|
||||||
|
$this->comment('country', 'name', 'Libelle FR du pays (≤ 80 caracteres) — valeur stockee telle quelle dans les adresses (country en chaine libre a ce stade).');
|
||||||
|
$this->comment('country', 'position', 'Ordre d affichage croissant dans le selecteur pays (tri position ASC puis name ASC ; France en tete).');
|
||||||
|
|
||||||
|
// Seed initial. France en tete (position 10) puis ordre alphabetique.
|
||||||
|
// Table fraichement creee, mais ON CONFLICT pour rejouabilite en prod.
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO country (code, name, position) VALUES
|
||||||
|
('FR', 'France', 10),
|
||||||
|
('DE', 'Allemagne', 20),
|
||||||
|
('BE', 'Belgique', 30),
|
||||||
|
('ES', 'Espagne', 40),
|
||||||
|
('IT', 'Italie', 50),
|
||||||
|
('GB', 'Royaume-Uni', 60),
|
||||||
|
('CH', 'Suisse', 70)
|
||||||
|
ON CONFLICT (code) DO NOTHING
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP TABLE country');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pose un `COMMENT ON TABLE` (colonne speciale `_table`) ou
|
||||||
|
* `COMMENT ON COLUMN`. Quoting defensif des identifiants + delimiteur $_$
|
||||||
|
* pour ne pas casser sur les apostrophes des descriptions.
|
||||||
|
*/
|
||||||
|
private function comment(string $table, string $column, string $description): void
|
||||||
|
{
|
||||||
|
$quotedTable = '"'.str_replace('"', '""', $table).'"';
|
||||||
|
|
||||||
|
if ('_table' === $column) {
|
||||||
|
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addSql(sprintf(
|
||||||
|
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||||
|
$quotedTable,
|
||||||
|
'"'.str_replace('"', '""', $column).'"',
|
||||||
|
$description,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Commercial\Domain\Entity;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineCountryRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pays selectionnable dans les adresses (clients / fournisseurs) : referentiel
|
||||||
|
* statique seede par la migration (France, Allemagne, Belgique, Espagne, Italie,
|
||||||
|
* Royaume-Uni). Remplace la liste de pays jusqu'ici codee en dur cote front.
|
||||||
|
*
|
||||||
|
* Perimetre minimal (ticket ERP-116, 1re iteration) : code ISO + libelle + ordre
|
||||||
|
* d'affichage uniquement. AUCUNE longueur bancaire/fiscale (numero de compte,
|
||||||
|
* IBAN, TVA, BIC, SIREN) a ce stade — ces colonnes feront l'objet d'une iteration
|
||||||
|
* ulterieure du meme ticket.
|
||||||
|
*
|
||||||
|
* Lecture seule : GetCollection + Get uniquement ; POST/PATCH/DELETE -> 405.
|
||||||
|
* Permission alignee sur Bank (referentiel d'adresse partage clients/fournisseurs).
|
||||||
|
* Pas de Timestampable/Blamable (referentiel statique whiteliste dans
|
||||||
|
* EntitiesAreTimestampableBlamableTest::EXCLUDED, comme Bank).
|
||||||
|
*/
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new GetCollection(
|
||||||
|
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
|
||||||
|
normalizationContext: ['groups' => ['country:read']],
|
||||||
|
// Tri par defaut : position ASC (France en tete) puis name ASC.
|
||||||
|
order: ['position' => 'ASC', 'name' => 'ASC'],
|
||||||
|
// Toggle ?pagination=false pour alimenter le select (cf. Bank).
|
||||||
|
paginationClientEnabled: true,
|
||||||
|
),
|
||||||
|
new Get(
|
||||||
|
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
|
||||||
|
normalizationContext: ['groups' => ['country:read']],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
|
||||||
|
)]
|
||||||
|
#[ORM\Entity(repositoryClass: DoctrineCountryRepository::class)]
|
||||||
|
#[ORM\Table(name: 'country')]
|
||||||
|
#[ORM\UniqueConstraint(name: 'uq_country_code', columns: ['code'])]
|
||||||
|
class Country
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
#[Groups(['country:read'])]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 2)]
|
||||||
|
#[Groups(['country:read'])]
|
||||||
|
private ?string $code = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 80)]
|
||||||
|
#[Groups(['country:read'])]
|
||||||
|
private ?string $name = null;
|
||||||
|
|
||||||
|
#[ORM\Column(options: ['default' => 0])]
|
||||||
|
#[Groups(['country:read'])]
|
||||||
|
private int $position = 0;
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCode(): ?string
|
||||||
|
{
|
||||||
|
return $this->code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCode(string $code): static
|
||||||
|
{
|
||||||
|
$this->code = $code;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): ?string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setName(string $name): static
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPosition(): int
|
||||||
|
{
|
||||||
|
return $this->position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPosition(int $position): static
|
||||||
|
{
|
||||||
|
$this->position = $position;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Commercial\Domain\Repository;
|
||||||
|
|
||||||
|
use App\Module\Commercial\Domain\Entity\Country;
|
||||||
|
|
||||||
|
interface CountryRepositoryInterface
|
||||||
|
{
|
||||||
|
public function findById(int $id): ?Country;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne tous les pays tries position ASC puis name ASC.
|
||||||
|
*
|
||||||
|
* @return list<Country>
|
||||||
|
*/
|
||||||
|
public function findAllOrdered(): array;
|
||||||
|
}
|
||||||
+44
-3
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Module\Commercial\Infrastructure\DataFixtures;
|
namespace App\Module\Commercial\Infrastructure\DataFixtures;
|
||||||
|
|
||||||
use App\Module\Commercial\Domain\Entity\Bank;
|
use App\Module\Commercial\Domain\Entity\Bank;
|
||||||
|
use App\Module\Commercial\Domain\Entity\Country;
|
||||||
use App\Module\Commercial\Domain\Entity\PaymentDelay;
|
use App\Module\Commercial\Domain\Entity\PaymentDelay;
|
||||||
use App\Module\Commercial\Domain\Entity\PaymentType;
|
use App\Module\Commercial\Domain\Entity\PaymentType;
|
||||||
use App\Module\Commercial\Domain\Entity\TvaMode;
|
use App\Module\Commercial\Domain\Entity\TvaMode;
|
||||||
@@ -14,10 +15,11 @@ use Doctrine\Persistence\ObjectManager;
|
|||||||
/**
|
/**
|
||||||
* Fixtures du module Commercial : re-seed des 4 referentiels comptables
|
* Fixtures du module Commercial : re-seed des 4 referentiels comptables
|
||||||
* (tva_mode, payment_delay, payment_type, bank) seedes par la migration M1
|
* (tva_mode, payment_delay, payment_type, bank) seedes par la migration M1
|
||||||
* (Version20260601000000).
|
* (Version20260601000000) + du referentiel pays (country) seede par la
|
||||||
|
* migration ERP-116 (Version20260609100000).
|
||||||
*
|
*
|
||||||
* Pourquoi cette fixture EN PLUS du seed de la migration : depuis ERP-54 ces
|
* Pourquoi cette fixture EN PLUS du seed de la migration : ces tables sont des
|
||||||
* 4 tables sont des entites managees par l'ORM, donc le purger Doctrine les
|
* entites managees par l'ORM, donc le purger Doctrine les
|
||||||
* vide avant chaque `doctrine:fixtures:load`. Sans cette fixture, les
|
* vide avant chaque `doctrine:fixtures:load`. Sans cette fixture, les
|
||||||
* referentiels seedes par la migration disparaitraient apres `make db-reset`
|
* referentiels seedes par la migration disparaitraient apres `make db-reset`
|
||||||
* (0 ligne en dev/test) — cassant les FK Client -> referentiels et les tests
|
* (0 ligne en dev/test) — cassant les FK Client -> referentiels et les tests
|
||||||
@@ -59,15 +61,54 @@ class CommercialReferentialFixtures extends Fixture
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Referentiel pays (ERP-116) : code ISO alpha-2 => [name, position].
|
||||||
|
* Doit rester aligne sur le seed de la migration Version20260609100000.
|
||||||
|
* Traite a part car Country porte `name` (et non `label`).
|
||||||
|
*
|
||||||
|
* @var array<string, array{string, int}>
|
||||||
|
*/
|
||||||
|
private const COUNTRIES = [
|
||||||
|
'FR' => ['France', 10],
|
||||||
|
'DE' => ['Allemagne', 20],
|
||||||
|
'BE' => ['Belgique', 30],
|
||||||
|
'ES' => ['Espagne', 40],
|
||||||
|
'IT' => ['Italie', 50],
|
||||||
|
'GB' => ['Royaume-Uni', 60],
|
||||||
|
'CH' => ['Suisse', 70],
|
||||||
|
];
|
||||||
|
|
||||||
public function load(ObjectManager $manager): void
|
public function load(ObjectManager $manager): void
|
||||||
{
|
{
|
||||||
foreach (self::REFERENTIALS as $entityClass => $rows) {
|
foreach (self::REFERENTIALS as $entityClass => $rows) {
|
||||||
$this->seedReferential($manager, $entityClass, $rows);
|
$this->seedReferential($manager, $entityClass, $rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->seedCountries($manager);
|
||||||
|
|
||||||
$manager->flush();
|
$manager->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert idempotent du referentiel pays (lookup par code). Distinct de
|
||||||
|
* seedReferential car Country utilise setName au lieu de setLabel.
|
||||||
|
*/
|
||||||
|
private function seedCountries(ObjectManager $manager): void
|
||||||
|
{
|
||||||
|
$existingByCode = [];
|
||||||
|
foreach ($manager->getRepository(Country::class)->findAll() as $country) {
|
||||||
|
$existingByCode[$country->getCode()] = $country;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (self::COUNTRIES as $code => [$name, $position]) {
|
||||||
|
$country = $existingByCode[$code] ?? new Country();
|
||||||
|
$country->setCode($code);
|
||||||
|
$country->setName($name);
|
||||||
|
$country->setPosition($position);
|
||||||
|
$manager->persist($country);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upsert idempotent d'un referentiel : indexe l'existant par code puis
|
* Upsert idempotent d'un referentiel : indexe l'existant par code puis
|
||||||
* cree/met a jour chaque entree. Les 4 entites partagent le meme contrat
|
* cree/met a jour chaque entree. Les 4 entites partagent le meme contrat
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Commercial\Infrastructure\Doctrine;
|
||||||
|
|
||||||
|
use App\Module\Commercial\Domain\Entity\Country;
|
||||||
|
use App\Module\Commercial\Domain\Repository\CountryRepositoryInterface;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<Country>
|
||||||
|
*/
|
||||||
|
class DoctrineCountryRepository extends ServiceEntityRepository implements CountryRepositoryInterface
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Country::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findById(int $id): ?Country
|
||||||
|
{
|
||||||
|
return $this->find($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findAllOrdered(): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('c')
|
||||||
|
->orderBy('c.position', 'ASC')
|
||||||
|
->addOrderBy('c.name', 'ASC')
|
||||||
|
->getQuery()
|
||||||
|
->getResult()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -170,6 +170,14 @@ final class ColumnCommentsCatalog
|
|||||||
'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).',
|
'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'country' => [
|
||||||
|
'_table' => 'Referentiel des pays selectionnables dans les adresses (clients/fournisseurs). Perimetre minimal : code ISO + libelle + ordre (pas de longueurs bancaires/fiscales).',
|
||||||
|
'id' => 'Identifiant interne auto-incremente.',
|
||||||
|
'code' => 'Code pays ISO 3166-1 alpha-2 (2 lettres MAJUSCULES, ex: FR) — unique (uq_country_code), fige a la creation.',
|
||||||
|
'name' => 'Libelle FR du pays (≤ 80 caracteres) — valeur stockee telle quelle dans les adresses (country en chaine libre a ce stade).',
|
||||||
|
'position' => 'Ordre d affichage croissant dans le selecteur pays (tri position ASC puis name ASC ; France en tete).',
|
||||||
|
],
|
||||||
|
|
||||||
'client' => [
|
'client' => [
|
||||||
'_table' => 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).',
|
'_table' => 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).',
|
||||||
'id' => 'Identifiant interne auto-incremente.',
|
'id' => 'Identifiant interne auto-incremente.',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\Tests\Architecture;
|
|||||||
|
|
||||||
use App\Module\Catalog\Domain\Entity\CategoryType;
|
use App\Module\Catalog\Domain\Entity\CategoryType;
|
||||||
use App\Module\Commercial\Domain\Entity\Bank;
|
use App\Module\Commercial\Domain\Entity\Bank;
|
||||||
|
use App\Module\Commercial\Domain\Entity\Country;
|
||||||
use App\Module\Commercial\Domain\Entity\PaymentDelay;
|
use App\Module\Commercial\Domain\Entity\PaymentDelay;
|
||||||
use App\Module\Commercial\Domain\Entity\PaymentType;
|
use App\Module\Commercial\Domain\Entity\PaymentType;
|
||||||
use App\Module\Commercial\Domain\Entity\TvaMode;
|
use App\Module\Commercial\Domain\Entity\TvaMode;
|
||||||
@@ -58,6 +59,8 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase
|
|||||||
* CommercialReferentialFixtures, lecture seule au M1 (HP-M2-2). Pas de
|
* CommercialReferentialFixtures, lecture seule au M1 (HP-M2-2). Pas de
|
||||||
* tracabilite user-driven, meme justification que CategoryType. Cf.
|
* tracabilite user-driven, meme justification que CategoryType. Cf.
|
||||||
* spec-back M1 § 2.6 + § 3.5.
|
* spec-back M1 § 2.6 + § 3.5.
|
||||||
|
* - Country (ERP-116) : referentiel statique des pays (id/code/name/position),
|
||||||
|
* seede par migration, lecture seule. Meme justification que Bank.
|
||||||
*
|
*
|
||||||
* Les futurs referentiels statiques s'ajoutent ici avec une justification.
|
* Les futurs referentiels statiques s'ajoutent ici avec une justification.
|
||||||
*/
|
*/
|
||||||
@@ -71,6 +74,7 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase
|
|||||||
PaymentDelay::class,
|
PaymentDelay::class,
|
||||||
PaymentType::class,
|
PaymentType::class,
|
||||||
Bank::class,
|
Bank::class,
|
||||||
|
Country::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
public function testAllBusinessEntitiesImplementBothInterfaces(): void
|
public function testAllBusinessEntitiesImplementBothInterfaces(): void
|
||||||
|
|||||||
@@ -241,6 +241,72 @@ final class ReferentialApiTest extends AbstractCommercialApiTestCase
|
|||||||
self::assertResponseStatusCodeSame(401);
|
self::assertResponseStatusCodeSame(401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Referentiel pays (ERP-116) — teste a part des 4 referentiels comptables
|
||||||
|
* car il expose `name` (et non `label`). Memes garanties : 200 + seed des 7
|
||||||
|
* pays, France en tete (position ASC), lecture seule (405), gating (403/401).
|
||||||
|
*/
|
||||||
|
public function testCountriesCollectionReturns200WithSeed(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$response = $client->request('GET', '/api/countries?pagination=false', ['headers' => ['Accept' => self::LD]]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(200);
|
||||||
|
|
||||||
|
$members = $response->toArray()['member'];
|
||||||
|
$codes = array_map(static fn (array $m): string => $m['code'], $members);
|
||||||
|
|
||||||
|
foreach (['FR', 'DE', 'BE', 'ES', 'IT', 'GB', 'CH'] as $expected) {
|
||||||
|
self::assertContains($expected, $codes, '/api/countries doit exposer le pays seede '.$expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le DTO de lecture expose id / code / name / position.
|
||||||
|
$first = $members[0];
|
||||||
|
self::assertArrayHasKey('id', $first);
|
||||||
|
self::assertArrayHasKey('name', $first);
|
||||||
|
self::assertArrayHasKey('position', $first);
|
||||||
|
// Tri par defaut position ASC : France (position 10) en tete.
|
||||||
|
self::assertSame('FR', $first['code'], 'France (FR) doit etre en tete (position 10, tri position ASC).');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCountriesGetItemReturns200(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
|
||||||
|
$first = $client->request('GET', '/api/countries?pagination=false', ['headers' => ['Accept' => self::LD]])
|
||||||
|
->toArray()['member'][0]
|
||||||
|
;
|
||||||
|
|
||||||
|
$client->request('GET', '/api/countries/'.$first['id'], ['headers' => ['Accept' => self::LD]]);
|
||||||
|
self::assertResponseStatusCodeSame(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCountriesPostReturns405(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$client->request('POST', '/api/countries', [
|
||||||
|
'headers' => ['Content-Type' => self::LD],
|
||||||
|
'json' => ['code' => 'XX', 'name' => 'Pays X', 'position' => 1],
|
||||||
|
]);
|
||||||
|
self::assertResponseStatusCodeSame(405);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCountriesForbiddenWithoutPermission(): void
|
||||||
|
{
|
||||||
|
$creds = $this->createUserWithPermission('core.users.view');
|
||||||
|
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||||
|
|
||||||
|
$client->request('GET', '/api/countries', ['headers' => ['Accept' => self::LD]]);
|
||||||
|
self::assertResponseStatusCodeSame(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCountriesUnauthorizedWhenAnonymous(): void
|
||||||
|
{
|
||||||
|
$client = self::createClient();
|
||||||
|
$client->request('GET', '/api/countries', ['headers' => ['Accept' => self::LD]]);
|
||||||
|
self::assertResponseStatusCodeSame(401);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return iterable<string, array{string, list<string>}>
|
* @return iterable<string, array{string, list<string>}>
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user