Compare commits

..

10 Commits

Author SHA1 Message Date
tristan e2ad17820b fix(commercial) : corrections ajout fournisseur — addressType en select, 422 inline (addressType/catégorie/compta complète/LCR sur paymentType), Information facultative (RG-2.03 retirée, miroir client) (ERP-94) 2026-06-09 23:36:39 +02:00
tristan 556b7026da test(front) : import explicite de vi dans SupplierContactBlock.spec (ERP-94) 2026-06-09 22:48:26 +02:00
tristan 86373f0d3c refactor(front) : volume prévisionnel en champ texte masqué + bloc adresse fournisseur aligné sur le client (radio type sans label, une colonne, required par radio) (ERP-94) 2026-06-09 22:46:40 +02:00
tristan 01a3bd6419 feat(front) : page Ajouter un fournisseur (/suppliers/new) + workflow par onglets (ERP-94) 2026-06-09 22:37:30 +02:00
tristan 639cf8482f chore(front) : i18n écrans/onglets fournisseurs + fournisseur avant client dans la sidebar (ERP-97) 2026-06-09 22:15:16 +02:00
tristan 79d389834b feat(front) : page Répertoire fournisseurs (/suppliers) + datatable + filtres + export (ERP-93)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m6s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m10s
2026-06-09 22:05:56 +02:00
gitea-actions 26b1f2c39b chore: bump version to v0.1.101
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 56s
2026-06-09 19:47:49 +00:00
tristan 8490de99da ERP-119 : revue validation front clients + évolutions écran client (types d'adresse, 2e email, saisies manuelles, redirection) (#80)
Auto Tag Develop / tag (push) Successful in 7s
## Contexte
Branche ERP-119 — revue de la validation des formulaires clients (déclencheur : écran « Ajouter un client »), accompagnée de plusieurs évolutions de l'écran client (M1).

## Contenu

### Validation front (clients)
- Boutons « Valider » toujours actifs (retrait du gating de validité) : c'est le back qui renvoie les 422, mappées en rouge par champ.
- Champs requis adossés à une colonne non-nullable : la clé est omise du payload si vide (companyName, RIB, adresse) → 422 NotBlank au lieu d'un 400 de type.
- Onglet Contact : au moins un contact requis (l'amorce vide est soumise → 422 RG-1.05).
- Onglet Adresse : affichage inline des erreurs type / sites / catégories + RG back « au moins un type d'adresse obligatoire ».

### Nouveaux types d'adresse
- Courtier / Distributeur, types autonomes exclusifs : colonnes `is_broker` / `is_distributor` (migration + CHECK miroir d'exclusivité), entité + Callback, et front (select, drapeaux, payloads).

### Saisies manuelles
- Adresse : `allow-create` sur le champ Adresse → saisie libre si la BAN ne propose rien.
- Date de création : `MalioDate :editable` → saisie clavier JJ/MM/AAAA en plus du calendrier.

### 2e email de facturation
- Colonne `billing_email_secondary` (optionnel, max 2), miroir du téléphone secondaire. Bump `@malio/layer-ui` 1.7.8 (prop `addable`).

### Fin d'ajout d'un client
- Redirection vers la liste à la validation du dernier onglet remplissable par le rôle (Adresse pour Bureau/Commerciale, Comptabilité pour Admin) + toast « Client ajouté ». Dérivé de `tabKeys`, sans règle RBAC custom.

## Vérifications
- Back : suites Module/Commercial + Architecture vertes (Client : 124/124). Migrations appliquées (dev + test).
- Front : Vitest vert (272), ESLint OK.

> Note : le hook pré-commit flake aléatoirement (JWT 401 / timeout DB) sur des tests sans rapport (Supplier) ; les commits ont été faits après vérification des suites concernées en isolation.

Reviewed-on: #80
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-09 19:47:40 +00:00
gitea-actions b3ab23ee8f chore: bump version to v0.1.100
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 36s
2026-06-09 08:44:19 +00:00
tristan 222338e5a4 fix(commercial) : validation onglet compta LCR + controle croise BIC/IBAN (ERP-118) (#78)
Auto Tag Develop / tag (push) Successful in 7s
## ERP-118 — Validation onglet Comptabilité (LCR / RIB)

### 1. Fix — 422 « Au moins un RIB est obligatoire pour le type de règlement LCR »

L'onglet Comptabilité envoyait le `PATCH /clients/{id}` des scalaires (`paymentType=LCR`) **avant** le `POST /clients/{id}/ribs`. Or le back valide RG-1.13 (LCR ⟹ ≥1 RIB persisté) sur ce PATCH, en lisant les RIB en base — vides à ce stade. Résultat : 422, et le `return` empêchait la création des RIB. Premier passage en LCR impossible (deadlock).

**Correctif :** inverser l'ordre — RIB d'abord, puis PATCH des scalaires.
- `new.vue` : `POST/PATCH RIB` → `PATCH scalaires`.
- `[id]/edit.vue` : ordre universel `CREATE/UPDATE RIB` → `PATCH scalaires` → `DELETE RIB retirés` (suppressions après le PATCH : le guard back n'autorise la suppression du dernier RIB qu'une fois quitté LCR). Corrige au passage un 409 latent sur le swap du dernier RIB en LCR.

### 2. Feat — contrôle croisé pays BIC/IBAN

`Assert\Bic(ibanPropertyPath: 'iban')` sur `ClientRib` et `SupplierRib` : le pays du BIC (positions 5-6) doit correspondre au pays de l'IBAN (positions 1-2). Un BIC et un IBAN valides isolément mais de pays différents → 422, violation portée par le champ `bic` avec message FR (`ibanMessage`), mappée inline côté front. Aucune modif front nécessaire.

### Tests

- Tests fonctionnels du mismatch (BIC DE + IBAN FR → 422 sur `propertyPath=bic`, message FR) côté client et fournisseur.
- Suite back complète au vert (garde-fou `EntityConstraintsHaveFrenchMessageTest` inclus), suite front Vitest au vert.

### Points d'attention

- **Durcissement de RG** (cross-check BIC/IBAN) hors spec initiale : des RIB existants avec BIC/IBAN de pays différents deviendraient non modifiables sans correction.
- L'orchestration de submit n'est pas couverte par un test unitaire (pas d'infra de test composant sur ces écrans) — vérification golden path recommandée.

Reviewed-on: #78
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-09 08:44:12 +00:00
57 changed files with 4824 additions and 868 deletions
+7 -7
View File
@@ -45,13 +45,6 @@ return [
'label' => 'sidebar.commercial.section',
'icon' => 'mdi:account-arrow-left-outline',
'items' => [
[
'label' => 'sidebar.commercial.clients',
'to' => '/clients',
'icon' => 'mdi:account-group-outline',
'module' => 'commercial',
'permission' => 'commercial.clients.view',
],
[
'label' => 'sidebar.commercial.suppliers',
'to' => '/suppliers',
@@ -59,6 +52,13 @@ return [
'module' => 'commercial',
'permission' => 'commercial.suppliers.view',
],
[
'label' => 'sidebar.commercial.clients',
'to' => '/clients',
'icon' => 'mdi:account-group-outline',
'module' => 'commercial',
'permission' => 'commercial.clients.view',
],
],
],
// Section "Administration" : regroupe toutes les pages de configuration
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.99'
app.version: '0.1.101'
+150
View File
@@ -49,6 +49,150 @@
"commercial": {
"title": "Commercial",
"welcome": "Module Commercial",
"suppliers": {
"title": "Répertoire fournisseurs",
"add": "Ajouter",
"export": "Exporter",
"empty": "Aucun fournisseur pour l'instant.",
"column": {
"companyName": "Nom",
"categories": "Catégories",
"sites": "Site",
"lastActivity": "Dernière activité"
},
"filters": {
"title": "Filtres",
"search": "Recherche",
"categories": "Catégories",
"sites": "Sites",
"status": "Statut",
"includeArchived": "Inclure les archivés",
"apply": "Voir les résultats",
"reset": "Réinitialiser"
},
"toast": {
"error": "Une erreur est survenue. Réessayez.",
"exportError": "L'export du répertoire fournisseurs a échoué. Réessayez.",
"createSuccess": "Fournisseur créé avec succès",
"updateSuccess": "Fournisseur mis à jour avec succès",
"addComplete": "Fournisseur ajouté",
"archiveSuccess": "Fournisseur archivé avec succès",
"restoreSuccess": "Fournisseur restauré avec succès",
"restoreConflict": "Impossible de restaurer : un fournisseur actif portant ce nom existe déjà."
},
"comingSoon": "À venir",
"tab": {
"information": "Information",
"contacts": "Contacts",
"addresses": "Adresses",
"transport": "Transport",
"accounting": "Comptabilité",
"statistics": "Statistiques",
"reports": "Rapports",
"exchanges": "Échanges"
},
"action": {
"edit": "Modifier",
"archive": "Archiver",
"restore": "Restaurer"
},
"consultation": {
"title": "Consultation fournisseur",
"back": "Retour au répertoire",
"loading": "Chargement du fournisseur…",
"notFound": "Fournisseur introuvable.",
"confirmArchive": {
"title": "Archiver le fournisseur",
"message": "Ce fournisseur n'apparaîtra plus dans le répertoire actif. Confirmer l'archivage ?"
},
"confirmRestore": {
"title": "Restaurer le fournisseur",
"message": "Ce fournisseur réapparaîtra dans le répertoire actif. Confirmer la restauration ?"
}
},
"edit": {
"title": "Modifier le fournisseur",
"back": "Retour au répertoire",
"loading": "Chargement du fournisseur…",
"notFound": "Fournisseur introuvable.",
"save": "Valider"
},
"form": {
"title": "Ajouter un fournisseur",
"back": "Précédent",
"submit": "Valider",
"duplicateCompany": "Un fournisseur portant ce nom de société existe déjà.",
"main": {
"companyName": "Nom du fournisseur (Entreprise)",
"categories": "Catégorie"
},
"information": {
"description": "Description",
"competitors": "Concurrent",
"foundedAt": "Date de création",
"employeesCount": "Nombre de salariés",
"revenueAmount": "CA",
"profitAmount": "Résultat",
"directorName": "Dirigeant",
"volumeForecast": "Volume prévisionnel"
},
"contact": {
"title": "Contact {n}",
"lastName": "Nom",
"firstName": "Prénom",
"jobTitle": "Fonction",
"email": "Email",
"phonePrimary": "Téléphone",
"phoneSecondary": "Téléphone (2)",
"addPhone": "Ajouter un numéro",
"remove": "Supprimer le contact",
"add": "Nouveau contact"
},
"address": {
"title": "Adresse {n}",
"addressType": "Type d'adresse",
"addressTypeProspect": "Prospect",
"addressTypeDepart": "Départ",
"addressTypeRendu": "Rendu",
"categories": "Catégorie",
"country": "Pays",
"postalCode": "Code postal",
"city": "Ville",
"street": "Adresse",
"streetNotFound": "Adresse introuvable ? Saisissez-la directement.",
"streetComplement": "Adresse complémentaire",
"sites": "Sites",
"contacts": "Contact(s) rattaché(s)",
"bennes": "Benne(s)",
"triageProvider": "Prestation de triage",
"remove": "Supprimer l'adresse",
"add": "Nouvelle adresse",
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
},
"accounting": {
"siren": "SIREN",
"accountNumber": "Numéro de compte",
"tvaMode": "Mode de TVA",
"nTva": "N° de TVA",
"paymentDelay": "Délai de règlement",
"paymentType": "Type de règlement",
"bank": "Banque",
"ribLabel": "Libellé",
"ribBic": "BIC",
"ribIban": "IBAN",
"addRib": "Ajouter un RIB",
"removeRib": "Supprimer le RIB"
},
"confirmDelete": {
"title": "Confirmer la suppression",
"contact": "Supprimer ce contact ?",
"address": "Supprimer cette adresse ?",
"rib": "Supprimer ce RIB ?",
"cancel": "Annuler",
"confirm": "Confirmer"
}
}
},
"clients": {
"title": "Répertoire clients",
"add": "Ajouter",
@@ -88,6 +232,7 @@
"toast": {
"createSuccess": "Client créé avec succès",
"updateSuccess": "Client mis à jour avec succès",
"addComplete": "Client ajouté",
"archiveSuccess": "Client archivé avec succès",
"restoreSuccess": "Client restauré avec succès",
"error": "Une erreur est survenue. Réessayez.",
@@ -173,15 +318,20 @@
"addressTypeDelivery": "Livraison",
"addressTypeBilling": "Facturation",
"addressTypeDeliveryBilling": "Adresse + Facturation",
"addressTypeBroker": "Adresse Courtier",
"addressTypeDistributor": "Adresse Distributeur",
"categories": "Catégorie",
"country": "Pays",
"postalCode": "Code postal",
"city": "Ville",
"street": "Adresse",
"streetNotFound": "Adresse introuvable ? Saisissez-la directement.",
"streetComplement": "Adresse complémentaire",
"sites": "Sites",
"contacts": "Contact(s) rattaché(s)",
"billingEmail": "Email de facturation",
"billingEmailSecondary": "Email de facturation secondaire",
"addBillingEmail": "Ajouter un email",
"remove": "Supprimer l'adresse",
"add": "Nouvelle adresse",
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
@@ -14,12 +14,15 @@
remplacant les 3 cases. Les options encodent les combinaisons valides
(exclusivite Prospect, RG-1.06/07/08) ; le back recoit toujours les
drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). -->
<!-- Erreur portee sur `isProspect` cote back (Callback type obligatoire +
exclusivite prospect) -> affichee sous le select Type d'adresse. -->
<MalioSelect
:model-value="addressType"
:options="addressTypeOptions"
:label="t('commercial.clients.form.address.addressType')"
:readonly="readonly"
:required="true"
:error="errors?.isProspect"
@update:model-value="onAddressTypeChange"
/>
@@ -31,6 +34,7 @@
:display-tag="true"
:readonly="readonly"
:required="true"
:error="errors?.sites"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
/>
@@ -43,9 +47,10 @@
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/>
<!-- Email de facturation : ligne 1 colonne 4, visible/obligatoire
seulement si Facturation (RG-1.11). Sinon un filler comble la
colonne pour que Categorie reparte au debut de la ligne 2. -->
<!-- Email(s) de facturation : visible/obligatoire seulement si Facturation
(RG-1.11). Le « + » revele un 2e email optionnel (max 2, pendant du
telephone secondaire) qui coule dans la grille. Sinon un filler comble
la colonne pour que Categorie reparte au debut de la ligne suivante. -->
<MalioInputEmail
v-if="isBillingEmailRequired(model)"
:model-value="model.billingEmail"
@@ -54,10 +59,23 @@
:readonly="readonly"
:lowercase="true"
:error="errors?.billingEmail"
:addable="!model.hasSecondaryBillingEmail && !readonly"
:add-button-label="t('commercial.clients.form.address.addBillingEmail')"
@update:model-value="(v: string) => update('billingEmail', v)"
@add="revealSecondaryBillingEmail"
/>
<div v-else aria-hidden="true" />
<MalioInputEmail
v-if="isBillingEmailRequired(model) && model.hasSecondaryBillingEmail"
:model-value="model.billingEmailSecondary"
:label="t('commercial.clients.form.address.billingEmailSecondary')"
:readonly="readonly"
:lowercase="true"
:error="errors?.billingEmailSecondary"
@update:model-value="(v: string) => update('billingEmailSecondary', v)"
/>
<MalioSelectCheckbox
:model-value="model.categoryIris"
:options="categoryOptions"
@@ -65,6 +83,7 @@
:display-tag="true"
:readonly="readonly"
:required="true"
:error="errors?.categories"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
/>
@@ -118,10 +137,10 @@
<div class="col-span-2">
<!-- Adresse : saisie assistee (BAN) en edition ; champ texte simple
seulement en lecture seule (MalioInputAutocomplete ne reaffiche pas
sa valeur liee, il n'afficherait rien en readonly). Une erreur BAN
ne bascule PAS en saisie libre : l'autocompletion reste montee et
chaque frappe relance la recherche (l'utilisateur peut aussi taper
une rue librement). -->
sa valeur liee, il n'afficherait rien en readonly). allow-create :
si la BAN ne propose rien (ou erreur), le texte saisi est CONSERVE au
blur/Entree (saisie manuelle) — sinon il serait efface. La ville reste
pilotee par le code postal ; choisir une suggestion remplit rue+ville+CP. -->
<MalioInputAutocomplete
v-if="!readonly"
:model-value="model.street"
@@ -132,6 +151,8 @@
:readonly="readonly"
:required="true"
:error="errors?.street"
:allow-create="true"
:no-results-text="t('commercial.clients.form.address.streetNotFound')"
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
@@ -147,7 +168,7 @@
/>
</div>
<div class="col-span-2">
<div class="col-span-1">
<MalioInputText
:model-value="model.streetComplement"
:label="t('commercial.clients.form.address.streetComplement')"
@@ -213,6 +234,8 @@ const addressTypeOptions = computed<RefOption[]>(() => [
{ value: 'delivery', label: t('commercial.clients.form.address.addressTypeDelivery') },
{ value: 'billing', label: t('commercial.clients.form.address.addressTypeBilling') },
{ value: 'delivery_billing', label: t('commercial.clients.form.address.addressTypeDeliveryBilling') },
{ value: 'broker', label: t('commercial.clients.form.address.addressTypeBroker') },
{ value: 'distributor', label: t('commercial.clients.form.address.addressTypeDistributor') },
])
/** Applique le type choisi en repercutant les 3 drapeaux back (immutabilite). */
@@ -266,6 +289,11 @@ function update<K extends keyof AddressFormDraft>(field: K, value: AddressFormDr
emit('update:modelValue', { ...props.modelValue, [field]: value })
}
/** Revele le 2e champ email de facturation (clic sur le « + »). */
function revealSecondaryBillingEmail(): void {
emit('update:modelValue', { ...props.modelValue, hasSecondaryBillingEmail: true })
}
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
function notifyUnavailable(): void {
if (!unavailableNotified) {
@@ -0,0 +1,314 @@
<template>
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- Suppression : modal de confirmation cote parent. -->
<MalioButtonIcon
v-if="removable && !readonly"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('commercial.suppliers.form.address.remove') }"
@click="$emit('remove')"
/>
<!-- Type d'adresse : Prospect / Depart / Rendu (RG-2.09). Select en attendant
l'arbitrage metier (radio vs select) ; l'erreur 422 (propertyPath
`addressType`) s'affiche via la prop native :error de MalioSelect. -->
<MalioSelect
:model-value="model.addressType"
:options="addressTypeOptions"
:label="t('commercial.suppliers.form.address.addressType')"
:readonly="readonly"
empty-option-label=""
:required="true"
:error="errors?.addressType"
@update:model-value="(v: string | number | null) => update('addressType', v === null ? null : (v as SupplierAddressType))"
/>
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-2.06). -->
<MalioSelectCheckbox
:model-value="model.siteIris"
:options="siteOptions"
:label="t('commercial.suppliers.form.address.sites')"
:display-tag="true"
:readonly="readonly"
:required="true"
:error="errors?.sites"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
/>
<!-- Contacts rattaches (M2M, facultatif). -->
<MalioSelectCheckbox
:model-value="model.contactIris"
:options="contactOptions"
:label="t('commercial.suppliers.form.address.contacts')"
:display-tag="true"
:readonly="readonly"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/>
<!-- Filler : aligne le debut de ligne suivant sur la grille (le bloc client
porte ici l'email de facturation, absent cote fournisseur). -->
<div aria-hidden="true" />
<!-- Categories de type FOURNISSEUR (>= 1 obligatoire, RG-2.10). -->
<MalioSelectCheckbox
:model-value="model.categoryIris"
:options="categoryOptions"
:label="t('commercial.suppliers.form.address.categories')"
:display-tag="true"
:readonly="readonly"
:required="true"
:error="errors?.categories"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
/>
<MalioSelect
:model-value="model.country"
:options="countryOptions"
:label="t('commercial.suppliers.form.address.country')"
:readonly="readonly"
:required="true"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/>
<MalioInputText
:model-value="model.postalCode"
:label="t('commercial.suppliers.form.address.postalCode')"
:mask="POSTAL_CODE_MASK"
:readonly="readonly"
:required="true"
:error="errors?.postalCode"
@update:model-value="onPostalCodeChange"
/>
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
<MalioSelect
v-if="!degraded"
:model-value="model.city"
:options="cityOptions"
:label="t('commercial.suppliers.form.address.city')"
:readonly="readonly"
empty-option-label=""
:required="true"
:error="errors?.city"
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
/>
<MalioInputText
v-else
:model-value="model.city"
:label="t('commercial.suppliers.form.address.city')"
:readonly="readonly"
:required="true"
:error="errors?.city"
@update:model-value="(v: string) => update('city', v)"
/>
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
<div class="col-span-2">
<MalioInputAutocomplete
v-if="!readonly"
:model-value="model.street"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:label="t('commercial.suppliers.form.address.street')"
:readonly="readonly"
:required="true"
:error="errors?.street"
:allow-create="true"
:no-results-text="t('commercial.suppliers.form.address.streetNotFound')"
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
/>
<MalioInputText
v-else
:model-value="model.street"
:label="t('commercial.suppliers.form.address.street')"
:readonly="readonly"
:required="true"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)"
/>
</div>
<div class="col-span-1">
<MalioInputText
:model-value="model.streetComplement"
:label="t('commercial.suppliers.form.address.streetComplement')"
:readonly="readonly"
:error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)"
/>
</div>
<!-- Bennes : stepper (specifique fournisseur, defaut 0). -->
<MalioInputNumber
:model-value="model.bennes"
:label="t('commercial.suppliers.form.address.bennes')"
:min="0"
:readonly="readonly"
:error="errors?.bennes"
@update:model-value="(v: string) => update('bennes', v)"
/>
<!-- Prestation de triage : booleen porte par l'adresse (specifique fournisseur). -->
<MalioCheckbox
id="address-triage-provider"
:label="t('commercial.suppliers.form.address.triageProvider')"
:model-value="model.triageProvider"
group-class="self-center"
:readonly="readonly"
@update:model-value="(v: boolean) => update('triageProvider', v)"
/>
</div>
</template>
<script setup lang="ts">
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useSupplierReferentials'
import type { SupplierAddressFormDraft, SupplierAddressType } from '~/modules/commercial/types/supplierForm'
// Masque code postal FR : 5 chiffres.
const POSTAL_CODE_MASK = '#####'
const props = defineProps<{
/** Brouillon de l'adresse (v-model). */
modelValue: SupplierAddressFormDraft
title: string
/** Categories autorisees sur une adresse (type FOURNISSEUR). */
categoryOptions: CategoryOption[]
/** Sites Starseed disponibles. */
siteOptions: RefOption[]
/** Contacts deja saisis, rattachables a l'adresse. */
contactOptions: RefOption[]
/** Pays disponibles (France par defaut). */
countryOptions: RefOption[]
removable?: boolean
readonly?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string>
}>()
const emit = defineEmits<{
'update:modelValue': [value: SupplierAddressFormDraft]
'remove': []
/** Emis une fois quand le service d'autocompletion bascule en indisponible. */
'degraded': []
}>()
const { t } = useI18n()
const autocomplete = useAddressAutocomplete()
const model = computed(() => props.modelValue)
const addressTypeOptions = computed<{ value: SupplierAddressType, label: string }[]>(() => [
{ value: 'PROSPECT', label: t('commercial.suppliers.form.address.addressTypeProspect') },
{ value: 'DEPART', label: t('commercial.suppliers.form.address.addressTypeDepart') },
{ value: 'RENDU', label: t('commercial.suppliers.form.address.addressTypeRendu') },
])
// Repli saisie libre de la VILLE quand la BAN est indisponible (recuperable).
const degraded = ref(false)
let unavailableNotified = false
const banCityOptions = ref<RefOption[]>([])
const banAddressOptions = ref<RefOption[]>([])
// Options ville effectives : on garantit que la ville courante figure toujours
// dans la liste, sinon MalioSelect afficherait un champ vide en lecture seule.
const cityOptions = computed<RefOption[]>(() => {
const current = props.modelValue.city
if (current && !banCityOptions.value.some(o => o.value === current)) {
return [{ value: current, label: current }, ...banCityOptions.value]
}
return banCityOptions.value
})
// Meme garantie pour le champ Adresse : la rue courante doit toujours figurer
// dans les options, sinon MalioInputAutocomplete laisse le champ vide.
const addressOptions = computed<RefOption[]>(() => {
const current = props.modelValue.street
if (current && !banAddressOptions.value.some(o => o.value === current)) {
return [{ value: current, label: current }, ...banAddressOptions.value]
}
return banAddressOptions.value
})
const addressLoading = ref(false)
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
let lastAddressSuggestions: AddressSuggestion[] = []
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
function update<K extends keyof SupplierAddressFormDraft>(field: K, value: SupplierAddressFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value })
}
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
function notifyUnavailable(): void {
if (!unavailableNotified) {
unavailableNotified = true
emit('degraded')
}
}
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */
async function onPostalCodeChange(value: string): Promise<void> {
update('postalCode', value)
const digits = (value ?? '').replace(/\D/g, '')
if (digits.length < 5) {
return
}
try {
const suggestions = await autocomplete.searchCity(digits)
banCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
degraded.value = false
}
catch {
degraded.value = true
notifyUnavailable()
}
}
/** Recherche d'adresse assistee (event de MalioInputAutocomplete). */
async function onAddressSearch(query: string): Promise<void> {
// La BAN exige au moins 3 caracteres : on n'envoie rien en deca (evite un 400).
if (query.trim().length < 3) {
banAddressOptions.value = []
return
}
addressLoading.value = true
try {
const postalCode = (model.value.postalCode ?? '').replace(/\D/g, '') || undefined
const suggestions = await autocomplete.searchAddress(query, postalCode)
lastAddressSuggestions = suggestions
banAddressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
}
catch {
// Erreur transitoire : on vide les suggestions, la prochaine frappe reessaie.
banAddressOptions.value = []
notifyUnavailable()
}
finally {
addressLoading.value = false
}
}
/** Selection d'une suggestion d'adresse → remplit rue + ville + CP. */
function onAddressSelect(option: { label: string, value: string | number } | null): void {
if (option === null) {
return
}
const suggestion = lastAddressSuggestions.find(s => s.street === option.value)
if (!suggestion) {
update('street', String(option.value))
return
}
emit('update:modelValue', {
...props.modelValue,
street: suggestion.street,
city: suggestion.city,
postalCode: suggestion.postalCode,
})
}
</script>
@@ -0,0 +1,104 @@
<template>
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
non supprimable (1er bloc, RG-2.13) ou en lecture seule. -->
<MalioButtonIcon
v-if="removable && !readonly"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('commercial.suppliers.form.contact.remove') }"
@click="$emit('remove')"
/>
<MalioInputText
:model-value="model.lastName"
:label="t('commercial.suppliers.form.contact.lastName')"
:readonly="readonly"
:error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)"
/>
<MalioInputText
:model-value="model.firstName"
:label="t('commercial.suppliers.form.contact.firstName')"
:readonly="readonly"
:error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)"
/>
<MalioInputText
:model-value="model.jobTitle"
:label="t('commercial.suppliers.form.contact.jobTitle')"
:readonly="readonly"
:error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)"
/>
<MalioInputEmail
:model-value="model.email"
:label="t('commercial.suppliers.form.contact.email')"
:readonly="readonly"
:lowercase="true"
:error="errors?.email"
@update:model-value="(v: string) => update('email', v)"
/>
<MalioInputPhone
:model-value="model.phonePrimary"
:label="t('commercial.suppliers.form.contact.phonePrimary')"
:mask="PHONE_MASK"
:readonly="readonly"
:error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('commercial.suppliers.form.contact.addPhone')"
@update:model-value="(v: string) => update('phonePrimary', v)"
@add="revealSecondaryPhone"
/>
<MalioInputPhone
v-if="model.hasSecondaryPhone"
:model-value="model.phoneSecondary"
:label="t('commercial.suppliers.form.contact.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="readonly"
:error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)"
/>
</div>
</template>
<script setup lang="ts">
import type { SupplierContactFormDraft } from '~/modules/commercial/types/supplierForm'
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur).
const PHONE_MASK = '## ## ## ## ##'
const props = defineProps<{
/** Brouillon du contact (v-model). */
modelValue: SupplierContactFormDraft
/** Titre du bloc (ex: « Contact 1 »). */
title: string
/** Affiche l'icone de suppression (1er bloc non supprimable, RG-2.13). */
removable?: boolean
/** Bloc en lecture seule (onglet valide). */
readonly?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string>
}>()
const emit = defineEmits<{
'update:modelValue': [value: SupplierContactFormDraft]
'remove': []
}>()
const { t } = useI18n()
// Alias local pour la lisibilite du template.
const model = computed(() => props.modelValue)
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
function update<K extends keyof SupplierContactFormDraft>(field: K, value: SupplierContactFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value })
}
/** Revele le 2e numero (max 1 secondaire, le « + » disparait). */
function revealSecondaryPhone(): void {
emit('update:modelValue', { ...props.modelValue, hasSecondaryPhone: true })
}
</script>
@@ -36,6 +36,7 @@ const MalioInputAutocompleteStub = defineComponent({
minSearchLength: { type: Number, default: 0 },
label: { type: String, default: '' },
readonly: { type: Boolean, default: false },
allowCreate: { type: Boolean, default: false },
},
emits: ['update:modelValue', 'search', 'select'],
setup(props) {
@@ -78,6 +79,14 @@ describe('ClientAddressBlock — affichage de l\'adresse persistee', () => {
expect(values).toContain('8 Boulevard du Port')
})
// ERP-119 : saisie manuelle possible quand la BAN ne trouve rien -> allow-create
// (sans cette prop, MalioInputAutocomplete efface le texte non selectionne au blur).
it('active allow-create sur le champ Adresse (saisie manuelle libre)', () => {
const wrapper = mountBlock(null)
expect(wrapper.findComponent(MalioInputAutocompleteStub).props('allowCreate')).toBe(true)
})
})
/**
@@ -134,6 +143,32 @@ describe('ClientAddressBlock — mapping erreur par champ (ERP-101)', () => {
)
expect(field?.attributes('data-error')).toBe('Code postal invalide.')
})
// ERP-119 : type d'adresse (propertyPath back `isProspect`), sites et
// categories sont obligatoires ; leurs violations 422 doivent s'afficher sous
// le champ correspondant (bindings :error de ClientAddressBlock).
it('affiche l\'erreur serveur sur type d\'adresse (propertyPath isProspect)', () => {
const wrapper = mountWithErrors({ isProspect: 'Le type d\'adresse est obligatoire.' })
const field = wrapper.findAll('malio-select-stub').find(
el => el.attributes('label') === 'commercial.clients.form.address.addressType',
)
expect(field?.attributes('error')).toBe('Le type d\'adresse est obligatoire.')
})
it('affiche les erreurs serveur sur sites et categories', () => {
const wrapper = mountWithErrors({
sites: 'Au moins un site est obligatoire.',
categories: 'Au moins une catégorie est obligatoire.',
})
const checkboxes = wrapper.findAll('malio-select-checkbox-stub')
const sitesField = checkboxes.find(el => el.attributes('label') === 'commercial.clients.form.address.sites')
const categoriesField = checkboxes.find(el => el.attributes('label') === 'commercial.clients.form.address.categories')
expect(sitesField?.attributes('error')).toBe('Au moins un site est obligatoire.')
expect(categoriesField?.attributes('error')).toBe('Au moins une catégorie est obligatoire.')
})
})
describe('ClientAddressBlock — recherche adresse robuste (erreur BAN)', () => {
@@ -0,0 +1,178 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, h, ref, computed } from 'vue'
import { emptyAddress } from '~/modules/commercial/types/supplierForm'
import SupplierAddressBlock from '../SupplierAddressBlock.vue'
// Mocks controlables du composable BAN (hoisted).
const { searchCityMock, searchAddressMock } = vi.hoisted(() => ({
searchCityMock: vi.fn(),
searchAddressMock: vi.fn(),
}))
vi.mock('~/shared/composables/useAddressAutocomplete', () => ({
useAddressAutocomplete: () => ({
searchCity: searchCityMock,
searchAddress: searchAddressMock,
}),
}))
// Auto-imports Nuxt/Vue utilises sans import explicite par le composant.
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('ref', ref)
vi.stubGlobal('computed', computed)
// Stub de MalioInputAutocomplete : expose les `value` des options + allowCreate.
const MalioInputAutocompleteStub = defineComponent({
name: 'MalioInputAutocomplete',
props: {
modelValue: { type: [String, Number, null], default: undefined },
options: { type: Array as () => { value: string | number, label: string }[], default: () => [] },
loading: { type: Boolean, default: false },
minSearchLength: { type: Number, default: 0 },
label: { type: String, default: '' },
readonly: { type: Boolean, default: false },
allowCreate: { type: Boolean, default: false },
},
emits: ['update:modelValue', 'search', 'select'],
setup(props) {
return () => h('div', {
'data-testid': 'addr-autocomplete',
'data-options': JSON.stringify(props.options.map(o => o.value)),
})
},
})
function mountBlock(overrides: Record<string, unknown> = {}, errors?: Record<string, string>) {
return mount(SupplierAddressBlock, {
props: {
modelValue: { ...emptyAddress(), ...overrides },
title: 'Adresse 1',
categoryOptions: [],
siteOptions: [],
contactOptions: [],
countryOptions: [],
...(errors ? { errors } : {}),
},
global: {
stubs: {
MalioButtonIcon: true,
MalioCheckbox: true,
MalioInputNumber: true,
MalioSelect: true,
MalioSelectCheckbox: true,
MalioInputText: true,
MalioInputAutocomplete: MalioInputAutocompleteStub,
},
},
})
}
describe('SupplierAddressBlock — specificites M2 (type, bennes, triage)', () => {
it('rend un select de type d\'adresse (en attendant l\'arbitrage metier)', () => {
const wrapper = mountBlock()
const addressTypeSelect = wrapper.findAll('malio-select-stub').find(
el => el.attributes('label') === 'commercial.suppliers.form.address.addressType',
)
expect(addressTypeSelect).toBeDefined()
})
it('rend le stepper Bennes et la case Prestation de triage (champs specifiques fournisseur)', () => {
const wrapper = mountBlock()
expect(wrapper.find('malio-input-number-stub').exists()).toBe(true)
expect(wrapper.find('malio-checkbox-stub').exists()).toBe(true)
})
it('ne rend aucun champ d\'email de facturation (difference M1)', () => {
const wrapper = mountBlock()
// Aucun MalioInputEmail dans le bloc adresse fournisseur.
expect(wrapper.find('malio-input-email-stub').exists()).toBe(false)
})
})
describe('SupplierAddressBlock — mapping erreur par champ (ERP-101)', () => {
it('affiche l\'erreur serveur du type d\'adresse (propertyPath addressType) sur le select', () => {
const wrapper = mountBlock({}, { addressType: 'Le type d\'adresse est obligatoire.' })
const addressTypeSelect = wrapper.findAll('malio-select-stub').find(
el => el.attributes('label') === 'commercial.suppliers.form.address.addressType',
)
expect(addressTypeSelect?.attributes('error')).toBe('Le type d\'adresse est obligatoire.')
})
it('affiche les erreurs serveur sur sites et categories', () => {
const wrapper = mountBlock({}, {
sites: 'Au moins un site est obligatoire.',
categories: 'Au moins une catégorie est obligatoire.',
})
const checkboxes = wrapper.findAll('malio-select-checkbox-stub')
const sitesField = checkboxes.find(el => el.attributes('label') === 'commercial.suppliers.form.address.sites')
const categoriesField = checkboxes.find(el => el.attributes('label') === 'commercial.suppliers.form.address.categories')
expect(sitesField?.attributes('error')).toBe('Au moins un site est obligatoire.')
expect(categoriesField?.attributes('error')).toBe('Au moins une catégorie est obligatoire.')
})
it('affiche l\'erreur serveur sur le code postal', () => {
const wrapper = mountBlock({}, { postalCode: 'Code postal invalide.' })
const field = wrapper.findAll('malio-input-text-stub').find(
el => el.attributes('label') === 'commercial.suppliers.form.address.postalCode',
)
expect(field?.attributes('error')).toBe('Code postal invalide.')
})
})
describe('SupplierAddressBlock — autocompletion adresse (BAN) robuste', () => {
beforeEach(() => {
searchAddressMock.mockReset()
})
it('n\'appelle pas la BAN en deca de 3 caracteres', async () => {
const wrapper = mountBlock()
wrapper.findComponent(MalioInputAutocompleteStub).vm.$emit('search', 'ab')
await flushPromises()
expect(searchAddressMock).not.toHaveBeenCalled()
})
it('relance la recherche apres une erreur (pas de bascule definitive)', async () => {
searchAddressMock
.mockRejectedValueOnce(new Error('BAN indisponible'))
.mockResolvedValueOnce([
{ label: '8 Boulevard du Port, Paris', street: '8 Boulevard du Port', postalCode: '75001', city: 'Paris' },
])
const wrapper = mountBlock()
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
auto.vm.$emit('search', 'boulevard du port')
await flushPromises()
auto.vm.$emit('search', 'boulevard du porte')
await flushPromises()
expect(searchAddressMock).toHaveBeenCalledTimes(2)
expect(wrapper.find('[data-testid="addr-autocomplete"]').exists()).toBe(true)
})
it('emet « degraded » une seule fois malgre plusieurs erreurs', async () => {
searchAddressMock.mockRejectedValue(new Error('BAN indisponible'))
const wrapper = mountBlock()
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
auto.vm.$emit('search', 'rue de la paix')
await flushPromises()
auto.vm.$emit('search', 'rue de la paixx')
await flushPromises()
expect(wrapper.emitted('degraded')).toHaveLength(1)
})
it('active allow-create sur le champ Adresse (saisie manuelle libre)', () => {
const wrapper = mountBlock()
expect(wrapper.findComponent(MalioInputAutocompleteStub).props('allowCreate')).toBe(true)
})
it('inclut la rue courante dans les options meme sans recherche BAN', () => {
const wrapper = mountBlock({ street: '8 Boulevard du Port' })
const values = JSON.parse(wrapper.find('[data-testid="addr-autocomplete"]').attributes('data-options') ?? '[]')
expect(values).toContain('8 Boulevard du Port')
})
})
@@ -0,0 +1,56 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent, h, ref, computed } from 'vue'
import { emptyContact } from '~/modules/commercial/types/supplierForm'
import SupplierContactBlock from '../SupplierContactBlock.vue'
// Auto-imports Nuxt/Vue utilises sans import explicite par le composant.
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('ref', ref)
vi.stubGlobal('computed', computed)
/** Stub d'un champ Malio qui re-expose la prop `error` recue dans un data-* attribut. */
function errorProbe(testid: string) {
return defineComponent({
name: `Probe-${testid}`,
props: {
modelValue: { type: [String, Number, null], default: undefined },
error: { type: String, default: '' },
label: { type: String, default: '' },
readonly: { type: Boolean, default: false },
},
setup(props) {
return () => h('div', { 'data-testid': testid, 'data-error': props.error })
},
})
}
function mountBlock(errors?: Record<string, string>) {
return mount(SupplierContactBlock, {
props: {
modelValue: emptyContact(),
title: 'Contact 1',
...(errors ? { errors } : {}),
},
global: {
stubs: {
MalioButtonIcon: true,
MalioInputPhone: true,
MalioInputText: errorProbe('contact-text'),
MalioInputEmail: errorProbe('contact-email'),
},
},
})
}
describe('SupplierContactBlock — mapping erreur par champ (ERP-101)', () => {
it('affiche l\'erreur serveur sur le champ email via la prop errors', () => {
const wrapper = mountBlock({ email: 'Adresse e-mail invalide.' })
expect(wrapper.find('[data-testid="contact-email"]').attributes('data-error')).toBe('Adresse e-mail invalide.')
})
it('laisse les champs sans erreur quand errors est absent', () => {
const wrapper = mountBlock()
expect(wrapper.find('[data-testid="contact-email"]').attributes('data-error')).toBe('')
})
})
@@ -0,0 +1,63 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// `useApi` est un auto-import Nuxt : on le stubbe globalement pour intercepter
// les appels de chargement des referentiels et controler les reponses Hydra.
const mockGet = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({ get: mockGet }))
const { useSupplierReferentials } = await import('../useSupplierReferentials')
describe('useSupplierReferentials', () => {
beforeEach(() => {
mockGet.mockReset()
mockGet.mockResolvedValue({ member: [] })
})
it('charge les categories filtrees sur le type FOURNISSEUR (RG-2.10)', async () => {
await useSupplierReferentials().loadCommon()
expect(mockGet).toHaveBeenCalledWith(
'/categories',
expect.objectContaining({ pagination: 'false', typeCode: 'FOURNISSEUR' }),
expect.objectContaining({ toast: false }),
)
})
it('mappe les categories en options { value: IRI, label: name, code }', async () => {
mockGet.mockImplementation((url: string) => {
if (url === '/categories') {
return Promise.resolve({ member: [{ '@id': '/api/categories/9', code: 'NEGOCIANT', name: 'Négociant' }] })
}
return Promise.resolve({ member: [] })
})
const refs = useSupplierReferentials()
await refs.loadCommon()
expect(refs.categories.value).toEqual([{ value: '/api/categories/9', label: 'Négociant', code: 'NEGOCIANT' }])
})
it('ne charge ni distributeurs ni courtiers (absents du modele fournisseur)', async () => {
await useSupplierReferentials().loadCommon()
const urls = mockGet.mock.calls.map(c => c[0])
expect(urls).not.toContain('/clients')
expect(urls).toEqual(
expect.arrayContaining(['/categories', '/sites', '/tva_modes', '/payment_delays', '/payment_types', '/banks']),
)
})
it('reste resilient : un referentiel en echec n\'empeche pas les autres', async () => {
mockGet.mockImplementation((url: string) => {
if (url === '/categories') return Promise.reject(new Error('403'))
if (url === '/banks') return Promise.resolve({ member: [{ '@id': '/api/banks/1', code: 'SG', label: 'Société Générale' }] })
return Promise.resolve({ member: [] })
})
const refs = useSupplierReferentials()
await refs.loadCommon()
expect(refs.categories.value).toEqual([])
expect(refs.banks.value).toEqual([{ value: '/api/banks/1', label: 'Société Générale' }])
})
})
@@ -0,0 +1,85 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { HydraCollection } from '~/shared/utils/api'
import type { Supplier } from '../useSuppliersRepository'
// `useApi` est un auto-import Nuxt : on le stubbe globalement pour intercepter
// les appels declenches par usePaginatedList (que useSuppliersRepository enveloppe)
// et controler les reponses. Meme pattern que useClientsRepository.spec.ts.
const mockGet = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({
get: mockGet,
post: vi.fn(),
put: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
}))
// Import APRES le stub pour que useApi soit bien resolu au top-level du module.
const { useSuppliersRepository } = await import('../useSuppliersRepository')
/** Envelope Hydra minimale (la liste reelle des membres importe peu ici). */
function makeHydra(total: number): HydraCollection<Supplier> {
return { totalItems: total, member: [] }
}
describe('useSuppliersRepository', () => {
beforeEach(() => {
mockGet.mockReset()
// 25 items → 3 pages a 10/page : permet de tester la navigation page 2.
mockGet.mockResolvedValue(makeHydra(25))
})
it('cible la ressource /suppliers en page 1 par defaut', async () => {
const repo = useSuppliersRepository()
await repo.fetch()
expect(mockGet).toHaveBeenLastCalledWith(
'/suppliers',
{ page: 1, itemsPerPage: 10 },
expect.objectContaining({ toast: false }),
)
})
it('pousse les filtres du drawer (categories multi, sites, archives inclus) et retombe en page 1', async () => {
const repo = useSuppliersRepository()
await repo.fetch()
await repo.goToPage(2)
expect(repo.currentPage.value).toBe(2)
await repo.setFilters(
{
search: 'acme',
'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'],
'siteId[]': ['86', '17'],
includeArchived: true,
},
{ replace: true },
)
expect(repo.currentPage.value).toBe(1)
expect(mockGet).toHaveBeenLastCalledWith(
'/suppliers',
{
search: 'acme',
'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'],
'siteId[]': ['86', '17'],
includeArchived: true,
page: 1,
itemsPerPage: 10,
},
expect.objectContaining({ toast: false }),
)
})
it('repasse a une query propre apres reinitialisation des filtres', async () => {
const repo = useSuppliersRepository()
await repo.setFilters({ search: 'acme', includeArchived: true }, { replace: true })
await repo.setFilters({}, { replace: true })
expect(mockGet).toHaveBeenLastCalledWith(
'/suppliers',
{ page: 1, itemsPerPage: 10 },
expect.objectContaining({ toast: false }),
)
})
})
@@ -0,0 +1,88 @@
/**
* Composable d'erreurs partage des ecrans fournisseur (creation + edition, M2
* Commercial). Miroir de `useClientFormErrors` (M1) :
* - un `useFormErrors` par groupe scalaire (Principal / Information /
* Comptabilite) : violations 422 affichees inline sous chaque champ ;
* - un tableau d'erreurs PAR LIGNE pour chaque collection (contacts /
* adresses / RIB), aligne sur l'index du `v-for`.
*
* `mapRowError` ne toaste PAS lui-meme : il retourne un booleen (true = mappe
* inline). Chaque page conserve ainsi son propre fallback dans le `catch`.
*/
import { ref, type Ref } from 'vue'
import { mapViolationsToRecord } from '~/shared/utils/api'
export function useSupplierFormErrors() {
const mainErrors = useFormErrors()
const informationErrors = useFormErrors()
const accountingErrors = useFormErrors()
const contactErrors = ref<Record<string, string>[]>([])
const addressErrors = ref<Record<string, string>[]>([])
const ribErrors = ref<Record<string, string>[]>([])
/**
* Mappe l'erreur d'une ligne de collection sur le tableau cible (par index).
* 422 avec violations exploitables → erreurs inline sous les champs de la
* ligne + retourne true. Sinon → ne touche pas la cible et retourne false.
*/
function mapRowError(
error: unknown,
target: Ref<Record<string, string>[]>,
index: number,
): boolean {
const response = (error as { response?: { status?: number, _data?: unknown } })?.response
const mapped = response?.status === 422 ? mapViolationsToRecord(response._data) : {}
if (Object.keys(mapped).length > 0) {
target.value[index] = mapped
return true
}
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.
* Retourne true si au moins un bloc a echoue.
*/
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++) {
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 {
mainErrors,
informationErrors,
accountingErrors,
contactErrors,
addressErrors,
ribErrors,
mapRowError,
submitRows,
}
}
@@ -0,0 +1,118 @@
import { ref } from 'vue'
/**
* Charge les referentiels (listes courtes) alimentant les selects de l'ecran
* « Ajouter un fournisseur » : categories (type FOURNISSEUR), sites, modes de TVA,
* delais et types de reglement, banques. Miroir de `useClientReferentials` (M1).
*
* Toutes les collections sont recuperees en entier via l'echappatoire prevue
* `?pagination=false` (referentiels de quelques dizaines d'entrees max), avec
* l'en-tete `Accept: application/ld+json` impose par API Platform 4 pour obtenir
* l'enveloppe Hydra (`member`). Les valeurs d'option sont les IRI Hydra (`@id`)
* renvoyees telles quelles dans les payloads POST/PATCH (relations M:1 / M:N).
*
* Difference M2 : pas de distributeurs/courtiers (absents du modele fournisseur).
*
* Etat 100 % local a l'instance (refs) — aucune persistance URL.
*/
/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox. */
export interface RefOption {
value: string
label: string
}
/** Option de type de reglement enrichie de son code stable (RG-2.07 / RG-2.08). */
export interface PaymentTypeOption extends RefOption {
code: string
}
/** Option de categorie enrichie de son code stable. */
export interface CategoryOption extends RefOption {
code: string
}
interface HydraMember {
'@id': string
}
interface CategoryMember extends HydraMember {
code: string
name: string
}
interface SiteMember extends HydraMember {
name: string
postalCode: string
}
interface ReferentialMember extends HydraMember {
code: string
label: string
}
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
export function useSupplierReferentials() {
const api = useApi()
const categories = ref<CategoryOption[]>([])
const sites = ref<RefOption[]>([])
const tvaModes = ref<RefOption[]>([])
const paymentDelays = ref<RefOption[]>([])
const paymentTypes = ref<PaymentTypeOption[]>([])
const banks = ref<RefOption[]>([])
/** Recupere une collection complete (pagination desactivee) en Hydra. */
async function fetchAll<T extends HydraMember>(
url: string,
query: Record<string, string | string[]> = {},
): Promise<T[]> {
const res = await api.get<{ member?: T[] }>(
url,
{ pagination: 'false', ...query },
{ headers: LD_JSON_HEADERS, toast: false },
)
return res.member ?? []
}
/**
* Charge en parallele les referentiels communs.
*
* Chargement RESILIENT (Promise.allSettled) : chaque referentiel est isole.
* Necessaire pour les roles metier qui n'ont pas toutes les permissions de
* lecture (ex. Compta a `commercial.suppliers.view` mais pas forcement
* `catalog.categories.view` ni `sites.view`). Un referentiel en echec reste
* simplement vide.
*/
async function loadCommon(): Promise<void> {
await Promise.allSettled([
// Taxonomie multi-types (ERP-84) : un fournisseur ne porte que des
// categories de type FOURNISSEUR (RG-2.10) -> on filtre cote API.
fetchAll<CategoryMember>('/categories', { typeCode: 'FOURNISSEUR' })
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
fetchAll<SiteMember>('/sites')
// Libelle = numero de departement (2 premiers chiffres du code
// postal du site), ex: 86100 -> « 86 ».
.then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }),
fetchAll<ReferentialMember>('/tva_modes')
.then((tva) => { tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label })) }),
fetchAll<ReferentialMember>('/payment_delays')
.then((delays) => { paymentDelays.value = delays.map(d => ({ value: d['@id'], label: d.label })) }),
fetchAll<ReferentialMember>('/payment_types')
.then((types) => { paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code })) }),
fetchAll<ReferentialMember>('/banks')
.then((banksList) => { banks.value = banksList.map(b => ({ value: b['@id'], label: b.label })) }),
])
}
return {
categories,
sites,
tvaModes,
paymentDelays,
paymentTypes,
banks,
loadCommon,
}
}
@@ -0,0 +1,54 @@
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
/**
* Site Starseed rattache a une adresse du fournisseur, tel qu'embarque en LISTE
* (groupe site:read) pour la colonne « Site » du Repertoire (badges colores).
* Agrege des adresses cote back via Supplier::getSites() (cf. spec-back M2).
*/
export interface SupplierSite {
id: number
name: string
color: string
}
/**
* Categorie (type FOURNISSEUR) rattachee au fournisseur, embarquee en LISTE
* (groupe category:read). La colonne « Catégories » affiche le `name` (et non le
* `code` comme au M1 clients — decision spec-front M2 § Datatable).
*/
export interface SupplierCategory {
code: string
name: string
}
/**
* Vue MINIMALE d'un fournisseur pour le Repertoire (datatable). Volontairement
* partielle : seuls les champs des colonnes + l'id (navigation) sont types ici.
* Le detail complet (onglets) est hors perimetre de cet ecran (ERP-93).
*/
export interface Supplier {
id: number
companyName: string
categories: SupplierCategory[]
sites: SupplierSite[]
/** Date ISO de derniere modification (default:read) — colonne « Dernière activité ». */
updatedAt: string | null
isArchived: boolean
}
/**
* Repertoire fournisseurs (ERP-93) — simple enveloppe de `usePaginatedList<Supplier>`
* sur la ressource `/suppliers` (RG-13 : pagination serveur obligatoire ; jamais
* de chargement integral en memoire). Miroir de `useClientsRepository` (M1).
*
* Les filtres (recherche, categories, sites, inclusion des archives) sont pilotes
* par la page via `setFilters` du composable partage — la remise en page 1 est
* garantie.
*
* Volontairement PAR INSTANCE (pas de singleton module-level) : l'etat tableau
* est propre a l'ecran Repertoire et meurt avec lui, comme tout consommateur de
* `usePaginatedList`. Aucun reset au logout a gerer.
*/
export function useSuppliersRepository() {
return usePaginatedList<Supplier>({ url: '/suppliers' })
}
@@ -0,0 +1,205 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, h, ref } from 'vue'
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
// La page ne les importe pas (auto-import) : on les expose en globals pour le
// runtime de test (happy-dom). Meme philosophie que les autres specs commercial.
const mockPush = vi.hoisted(() => vi.fn())
const mockApiGet = vi.hoisted(() => vi.fn())
const mockCan = vi.hoisted(() => vi.fn())
const mockSetFilters = vi.hoisted(() => vi.fn())
const mockFetch = vi.hoisted(() => vi.fn())
const mockToastError = vi.hoisted(() => vi.fn())
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('useHead', () => undefined)
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
vi.stubGlobal('useToast', () => ({ error: mockToastError, success: vi.fn() }))
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
// Le repository est lui aussi un auto-import : on controle items + setFilters.
vi.stubGlobal('useSuppliersRepository', () => ({
items: ref([
{
id: 7,
companyName: 'ACME',
categories: [{ code: 'NEG', name: 'Négociant' }],
sites: [{ id: 86, name: '86', color: '#123456' }],
updatedAt: '2026-01-15T10:00:00+00:00',
},
]),
totalItems: ref(1),
currentPage: ref(1),
itemsPerPage: ref(10),
itemsPerPageOptions: ref([10, 25, 50]),
fetch: mockFetch,
goToPage: vi.fn(),
setItemsPerPage: vi.fn(),
setFilters: mockSetFilters,
}))
// happy-dom n'implemente pas createObjectURL : on ajoute les methodes statiques
// sur la classe URL existante (sans la remplacer — sinon `new URL()` casse).
globalThis.URL.createObjectURL = vi.fn(() => 'blob:fake')
globalThis.URL.revokeObjectURL = vi.fn()
// Import APRES les stubs (la page resout les auto-imports au top-level du module).
const SuppliersIndex = (await import('../suppliers/index.vue')).default
// ── Stubs de composants ──────────────────────────────────────────────────────
const ButtonStub = defineComponent({
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
emits: ['click'],
setup(props, { emit }) {
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
},
})
const DataTableStub = defineComponent({
props: { items: { type: Array, default: () => [] } },
emits: ['row-click', 'update:page', 'update:per-page'],
setup(props, { emit }) {
return () => h('div', { 'data-testid': 'datatable' },
(props.items as Array<{ id: number }>).map(it =>
h('tr', { 'data-row-id': it.id, onClick: () => emit('row-click', it) }),
),
)
},
})
const DrawerStub = defineComponent({
props: { modelValue: { type: Boolean, default: false } },
setup(_, { slots }) {
return () => h('div', {}, [slots.header?.(), slots.default?.(), slots.footer?.()])
},
})
const SlotStub = defineComponent({ setup(_, { slots }) { return () => h('div', {}, slots.default?.()) } })
const PageHeaderStub = defineComponent({
setup(_, { slots }) { return () => h('div', {}, [slots.default?.(), slots.actions?.()]) },
})
const CheckboxStub = defineComponent({
props: { id: { type: String, default: '' }, modelValue: { type: Boolean, default: false } },
emits: ['update:model-value'],
setup(props, { emit }) {
return () => h('input', {
'type': 'checkbox',
'data-id': props.id,
'onChange': (e: Event) => emit('update:model-value', (e.target as HTMLInputElement).checked),
})
},
})
const InputTextStub = defineComponent({ setup() { return () => h('input') } })
function mountPage() {
return mount(SuppliersIndex, {
global: {
stubs: {
PageHeader: PageHeaderStub,
MalioButton: ButtonStub,
MalioDataTable: DataTableStub,
MalioDrawer: DrawerStub,
MalioAccordion: SlotStub,
MalioAccordionItem: SlotStub,
MalioInputText: InputTextStub,
MalioCheckbox: CheckboxStub,
},
},
})
}
describe('Répertoire fournisseurs (page /suppliers)', () => {
beforeEach(() => {
mockPush.mockReset()
mockApiGet.mockReset().mockResolvedValue({ member: [] })
mockCan.mockReset().mockReturnValue(true)
mockSetFilters.mockReset()
mockFetch.mockReset()
mockToastError.mockReset()
})
it('charge la liste au montage', async () => {
mountPage()
await flushPromises()
expect(mockFetch).toHaveBeenCalled()
})
it('affiche « + Ajouter » uniquement avec la permission manage', async () => {
mockCan.mockImplementation((perm: string) => perm === 'commercial.suppliers.manage')
const wrapper = mountPage()
await flushPromises()
expect(wrapper.find('[data-label="commercial.suppliers.add"]').exists()).toBe(true)
})
it('masque « + Ajouter » sans la permission manage (view seul)', async () => {
mockCan.mockImplementation((perm: string) => perm === 'commercial.suppliers.view')
const wrapper = mountPage()
await flushPromises()
expect(wrapper.find('[data-label="commercial.suppliers.add"]').exists()).toBe(false)
})
it('navigue vers la consultation au clic sur une ligne', async () => {
const wrapper = mountPage()
await flushPromises()
await wrapper.find('tr[data-row-id="7"]').trigger('click')
expect(mockPush).toHaveBeenCalledWith('/suppliers/7')
})
it('charge les categories de type FOURNISSEUR pour le filtre', async () => {
mountPage()
await flushPromises()
expect(mockApiGet).toHaveBeenCalledWith(
'/categories',
expect.objectContaining({ pagination: 'false', typeCode: 'FOURNISSEUR' }),
expect.objectContaining({ toast: false }),
)
})
it('appelle l\'export XLSX sur /suppliers/export.xlsx en blob', async () => {
const wrapper = mountPage()
await flushPromises()
await wrapper.find('[data-label="commercial.suppliers.export"]').trigger('click')
await flushPromises()
expect(mockApiGet).toHaveBeenCalledWith(
'/suppliers/export.xlsx',
expect.any(Object),
expect.objectContaining({ responseType: 'blob', toast: false }),
)
})
it('repercute le filtre « Inclure les archivés » dans setFilters sans toucher l\'URL', async () => {
const wrapper = mountPage()
await flushPromises()
// Coche « Inclure les archivés » puis applique les filtres.
await wrapper.find('input[data-id="filter-include-archived"]').setValue(true)
await wrapper.find('[data-label="commercial.suppliers.filters.apply"]').trigger('click')
expect(mockSetFilters).toHaveBeenLastCalledWith(
{ includeArchived: true },
{ replace: true },
)
// Etat 100 % local (regle n°6) : aucune navigation/query string declenchee.
expect(mockPush).not.toHaveBeenCalled()
})
it('badge filtres actifs + Réinitialiser vide l\'etat applique', async () => {
const wrapper = mountPage()
await flushPromises()
await wrapper.find('input[data-id="filter-include-archived"]').setValue(true)
await wrapper.find('[data-label="commercial.suppliers.filters.apply"]').trigger('click')
// Le libelle du bouton Filtrer porte le compteur (1 filtre actif).
expect(wrapper.find('[data-label="commercial.suppliers.filters.title (1)"]').exists()).toBe(true)
// Réinitialiser → query propre (setFilters avec objet vide).
await wrapper.find('[data-label="commercial.suppliers.filters.reset"]').trigger('click')
expect(mockSetFilters).toHaveBeenLastCalledWith({}, { replace: true })
})
})
@@ -82,7 +82,7 @@
<MalioButton
variant="primary"
:label="t('commercial.clients.edit.save')"
:disabled="!isMainValid || mainSubmitting"
:disabled="mainSubmitting"
@click="submitMain"
/>
</div>
@@ -114,6 +114,7 @@
v-model="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')"
:readonly="businessReadonly"
:editable="true"
:error="informationErrors.errors.foundedAt"
/>
<MalioInputText
@@ -178,7 +179,7 @@
<MalioButton
variant="primary"
:label="t('commercial.clients.edit.save')"
:disabled="!canValidateContacts || tabSubmitting"
:disabled="tabSubmitting"
@click="submitContacts"
/>
</div>
@@ -216,7 +217,7 @@
<MalioButton
variant="primary"
:label="t('commercial.clients.edit.save')"
:disabled="!canValidateAddresses || tabSubmitting"
:disabled="tabSubmitting"
@click="submitAddresses"
/>
</div>
@@ -347,7 +348,7 @@
<MalioButton
variant="primary"
:label="t('commercial.clients.edit.save')"
:disabled="!canValidateAccounting || tabSubmitting"
:disabled="tabSubmitting"
@click="submitAccounting"
/>
</div>
@@ -419,8 +420,6 @@ import {
} from '~/modules/commercial/utils/clientEdit'
import {
buildClientFormTabKeys,
hasAllRequiredAccountingFields,
hasAtLeastOneValidContact,
isAddressValid,
isBankRequiredForPaymentType,
isBillingEmailRequired,
@@ -673,17 +672,6 @@ const {
} = useClientFormErrors()
// ── Bloc principal ───────────────────────────────────────────────────────────
const isMainValid = computed(() => {
const filled = (v: string | null | undefined) => v !== null && v !== undefined && v.trim() !== ''
const relationValid
= main.relationType === null
|| (main.relationType === 'distributeur' && filled(main.distributorIri))
|| (main.relationType === 'courtier' && filled(main.brokerIri))
return filled(main.companyName)
&& main.categoryIris.length >= 1
&& relationValid
})
async function onRelationChange(value: string | number | null): Promise<void> {
const relation = (value === null || value === '') ? null : (String(value) as 'distributeur' | 'courtier')
main.relationType = relation
@@ -697,7 +685,7 @@ async function onRelationChange(value: string | number | null): Promise<void> {
/** PATCH /clients/{id} — groupe client:write:main UNIQUEMENT (mode strict). */
async function submitMain(): Promise<void> {
if (businessReadonly.value || !isMainValid.value || mainSubmitting.value) return
if (businessReadonly.value || mainSubmitting.value) return
mainSubmitting.value = true
mainErrors.clearErrors()
try {
@@ -750,9 +738,6 @@ const canAddContact = computed(() => {
const last = contacts.value[contacts.value.length - 1]
return last === undefined || isContactNamed(last)
})
// RG-1.14 : au moins un contact nomme pour finaliser l'onglet.
const canValidateContacts = computed(() => hasAtLeastOneValidContact(contacts.value))
function addContact(): void {
if (canAddContact.value) contacts.value.push(emptyContact())
}
@@ -774,7 +759,7 @@ function askRemoveContact(index: number): void {
* collection contacts (endpoints client_contact dedies).
*/
async function submitContacts(): Promise<void> {
if (businessReadonly.value || !canValidateContacts.value || tabSubmitting.value) return
if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
contactErrors.value = []
try {
@@ -783,6 +768,11 @@ async function submitContacts(): Promise<void> {
}
removedContactIds.value = []
// RG-1.14 : au moins un contact requis. Si l'onglet ne contient QUE des
// amorces neuves vides (ex. tous les contacts existants supprimes), on ne
// les skippe pas -> le back renvoie la 422 RG-1.05 « prénom ou nom
// obligatoire » inline (la RG-1.14 n'a pas d'equivalent back au POST).
const hasSubmittableContact = contacts.value.some(c => c.id !== null || !isContactBlank(c))
// On tente TOUS les blocs (collecte des erreurs par index, ERP-110). Seuls
// les blocs TOTALEMENT vides sont ignores : un bloc partiellement rempli
// sans nom (email seul) est soumis -> 422 RG-1.05 inline sous le bloc.
@@ -805,10 +795,10 @@ async function submitContacts(): Promise<void> {
}
},
error => showError(error),
// On ne saute QUE les amorces neuves (id null) totalement vides. Un
// bloc existant vide est soumis -> 422 RG-1.05 inline (sinon la modif
// serait perdue en silence avec un faux toast de succes).
contact => contact.id === null && isContactBlank(contact),
// On ne saute une amorce neuve (id null) totalement vide QUE si un autre
// bloc sera soumis : sinon on la soumet pour declencher la 422 RG-1.05
// (un onglet Contact vide ne doit pas passer en faux succes).
contact => hasSubmittableContact && contact.id === null && isContactBlank(contact),
)
// Tant qu'un bloc reste en erreur : pas de toast succes.
if (hasError) return
@@ -823,10 +813,6 @@ async function submitContacts(): Promise<void> {
}
// ── Onglet Adresse ───────────────────────────────────────────────────────────
const canValidateAddresses = computed(() =>
addresses.value.length > 0 && addresses.value.every(isAddressValid),
)
// « + Adresse » desactive tant que la derniere adresse n'est pas valide.
const canAddAddress = computed(() => {
const last = addresses.value[addresses.value.length - 1]
@@ -859,7 +845,7 @@ function onAddressDegraded(): void {
/** Valide l'onglet Adresse : DELETE des adresses retirees puis POST/PATCH. */
async function submitAddresses(): Promise<void> {
if (businessReadonly.value || !canValidateAddresses.value || tabSubmitting.value) return
if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
addressErrors.value = []
try {
@@ -927,13 +913,6 @@ function onPaymentTypeChange(value: string | number | null): void {
}
}
const canValidateAccounting = computed(() => {
if (!hasAllRequiredAccountingFields(accounting)) return false
if (isBankRequired.value && accounting.bankIri === null) return false
if (isRibRequired.value && !ribs.value.some(isRibComplete)) return false
return true
})
// « + RIB » desactive tant que le dernier bloc RIB n'est pas complet.
const canAddRib = computed(() => {
const last = ribs.value[ribs.value.length - 1]
@@ -956,35 +935,21 @@ function askRemoveRib(index: number): void {
}
/**
* Valide l'onglet Comptabilite : PATCH des scalaires (groupe client:write:accounting,
* exige accounting.manage cote back) PUIS DELETE/POST/PATCH des RIB sur la
* sous-ressource. Aucun champ main/information dans le payload (mode strict
* RG-1.28 : sinon 403 sur tout le payload).
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
* 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
* (LCR => au moins un RIB persiste) sur le PATCH scalaires ; les suppressions en
* 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
* 403 sur tout le payload).
*/
async function submitAccounting(): Promise<void> {
if (accountingReadonly.value || !canValidateAccounting.value || tabSubmitting.value) return
if (accountingReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
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 = []
try {
// 1) PATCH des scalaires comptables (erreurs inline sur leurs champs).
try {
await api.patch(`/clients/${clientId}`, buildAccountingPayload(accounting, isBankRequired.value), { toast: false })
}
catch (error) {
accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
return
}
for (const id of removedRibIds.value) {
await api.delete(`/client_ribs/${id}`, {}, { toast: false })
}
removedRibIds.value = []
// 2) POST/PATCH des RIB (erreurs inline par ligne, tous les blocs tentes).
// 1) POST/PATCH des RIB d'abord (erreurs inline par ligne, tous les blocs
// tentes). Le back exige >=1 RIB persiste pour valider une LCR a l'etape 2.
// Seuls les blocs RIB TOTALEMENT vides sont ignores : un RIB partiel (ex.
// IBAN seul) est soumis -> 422 NotBlank (label / bic / iban) inline.
const ribHasError = await submitRows(
@@ -1011,6 +976,23 @@ async function submitAccounting(): Promise<void> {
rib => rib.id === null && isRibBlank(rib),
)
if (ribHasError) return
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
try {
await api.patch(`/clients/${clientId}`, buildAccountingPayload(accounting, isBankRequired.value), { toast: false })
}
catch (error) {
accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
return
}
// 3) DELETE des RIB retires : APRES le PATCH scalaires (si on quitte LCR, le
// guard back n'autorise la suppression du dernier RIB qu'une fois le type change).
for (const id of removedRibIds.value) {
await api.delete(`/client_ribs/${id}`, {}, { toast: false })
}
removedRibIds.value = []
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch (e) {
+83 -115
View File
@@ -76,7 +76,7 @@
<MalioButton
variant="primary"
:label="t('commercial.clients.form.submit')"
:disabled="!isMainValid || mainSubmitting"
:disabled="mainSubmitting"
@click="submitMain"
/>
</div>
@@ -109,6 +109,7 @@
v-model="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')"
:readonly="isValidated('information')"
:editable="true"
:error="informationErrors.errors.foundedAt"
/>
<MalioInputText
@@ -140,13 +141,12 @@
<div v-if="!isValidated('information')" class="mt-12 flex justify-center">
<!-- Desactive tant que le client n'est pas cree (evite un PATCH
avant le POST si clic trop tot, Information etant l'onglet
actif par defaut) OU si aucun champ n'est rempli : onglet
facultatif, mais pas de validation a vide (on passe alors
directement a Contact). -->
actif par defaut). Onglet facultatif : un enregistrement a
vide reste possible, c'est le back qui valide. -->
<MalioButton
variant="primary"
:label="t('commercial.clients.form.submit')"
:disabled="tabSubmitting || clientId === null || !canValidateInformation"
:disabled="tabSubmitting || clientId === null"
@click="submitInformation"
/>
</div>
@@ -178,7 +178,7 @@
<MalioButton
variant="primary"
:label="t('commercial.clients.form.submit')"
:disabled="!canValidateContacts || tabSubmitting"
:disabled="tabSubmitting"
@click="submitContacts"
/>
</div>
@@ -216,7 +216,7 @@
<MalioButton
variant="primary"
:label="t('commercial.clients.form.submit')"
:disabled="!canValidateAddresses || tabSubmitting"
:disabled="tabSubmitting"
@click="submitAddresses"
/>
</div>
@@ -347,7 +347,7 @@
<MalioButton
variant="primary"
:label="t('commercial.clients.form.submit')"
:disabled="!canValidateAccounting || tabSubmitting"
:disabled="tabSubmitting"
@click="submitAccounting"
/>
</div>
@@ -391,9 +391,6 @@ import { useClientFormErrors } from '~/modules/commercial/composables/useClientF
import {
buildClientFormTabKeys,
CLIENT_FORM_PLACEHOLDER_TABS,
hasAllRequiredAccountingFields,
hasAtLeastOneInformationField,
hasAtLeastOneValidContact,
isAddressValid,
isBankRequiredForPaymentType,
isBillingEmailRequired,
@@ -402,8 +399,14 @@ import {
isRibBlank,
isRibComplete,
isRibRequiredForPaymentType,
lastFillableTabKey,
showsRelationAndTriageFields,
} from '~/modules/commercial/utils/clientFormRules'
import {
buildAddressPayload,
buildMainPayload,
buildRibPayload,
} from '~/modules/commercial/utils/clientEdit'
import {
emptyAddress,
emptyContact,
@@ -517,25 +520,6 @@ watch(showRelationAndTriage, (visible) => {
}
})
// Validation du formulaire principal (gate le bouton « Valider ») :
// - companyName / >= 1 categorie obligatoires ;
// - relation Distributeur/Courtier optionnelle, mais le nom correspondant
// devient requis si l'un des deux est choisi (spec fonctionnelle).
// Les coordonnees de contact ne sont plus saisies ici : elles vivent dans
// l'onglet Contacts (RG-1.05/1.14 garantissent >= 1 contact valide).
const isMainValid = computed(() => {
const filled = (v: string | null | undefined) => v !== null && v !== undefined && v.trim() !== ''
// Relation Distributeur/Courtier OPTIONNELLE ; mais si « Depend du
// distributeur/courtier » est choisi, le nom correspondant devient requis.
const relationValid
= main.relationType === null
|| (main.relationType === 'distributeur' && filled(main.distributorIri))
|| (main.relationType === 'courtier' && filled(main.brokerIri))
return filled(main.companyName)
&& main.categoryIris.length >= 1
&& relationValid
})
async function onRelationChange(value: string | number | null): Promise<void> {
const relation = (value === null || value === '')
? null
@@ -551,18 +535,13 @@ async function onRelationChange(value: string | number | null): Promise<void> {
/** POST /clients (groupe client:write:main). Au succes : verrouille + bascule Information. */
async function submitMain(): Promise<void> {
if (!isMainValid.value || mainSubmitting.value) return
if (mainSubmitting.value) return
mainSubmitting.value = true
mainErrors.clearErrors()
try {
const payload: Record<string, unknown> = {
companyName: main.companyName,
categories: main.categoryIris,
distributor: main.relationType === 'distributeur' ? main.distributorIri : null,
broker: main.relationType === 'courtier' ? main.brokerIri : null,
triageService: main.triageService,
}
const created = await api.post<ClientResponse>('/clients', payload, {
// Payload partage avec l'edition (buildMainPayload) : meme logique
// d'omission des requis vides et meme envoi de relationType (ERP-119).
const created = await api.post<ClientResponse>('/clients', buildMainPayload(main), {
headers: { Accept: 'application/ld+json' },
toast: false,
})
@@ -606,6 +585,12 @@ const validated = reactive<Record<string, boolean>>({})
const tabKeys = computed(() => buildClientFormTabKeys(canAccountingView.value))
// Dernier onglet REMPLISSABLE par le role (cf. lastFillableTabKey) : deja role-aware
// via tabKeys (accounting present ssi accounting.view, et a la creation « present » =
// « editable » : aucun role createur n'a la Compta en lecture seule). Sa validation
// cloture l'ajout -> redirection vers la liste.
const lastFillableTab = computed(() => lastFillableTabKey(tabKeys.value))
// Icone (Iconify) affichee dans l'onglet, par cle. A ajuster librement.
const TAB_ICONS: Record<string, string> = {
information: 'mdi:account-outline',
@@ -633,12 +618,23 @@ function tabIndex(key: string): number {
return tabKeys.value.indexOf(key)
}
/** Marque l'onglet valide, deverrouille et avance automatiquement au suivant. */
function completeTab(key: string): void {
/**
* Marque l'onglet valide. Si c'est le dernier onglet remplissable, l'ajout est
* termine : toast final + redirection vers la liste, et on retourne true pour que
* l'appelant n'affiche pas son toast « mis a jour ». Sinon, deverrouille et avance
* a l'onglet suivant, et retourne false.
*/
function completeTab(key: string): boolean {
validated[key] = true
if (key === lastFillableTab.value) {
toast.success({ title: t('commercial.clients.toast.addComplete') })
router.push('/clients')
return true
}
const next = tabKeys.value[tabIndex(key) + 1]
unlockedIndex.value = Math.max(unlockedIndex.value, tabIndex(key) + 1)
if (next) activeTab.value = next
return false
}
// Passage automatique sur les onglets coquille (Transport, Stats, Rapports, Echanges).
@@ -661,12 +657,9 @@ const information = reactive({
directorName: null as string | null,
})
// Onglet facultatif, mais pas de validation « a vide » : au moins un champ rempli.
const canValidateInformation = computed(() => hasAtLeastOneInformationField(information))
/** PATCH /clients/{id} — mode strict : uniquement les champs du groupe information. */
async function submitInformation(): Promise<void> {
if (clientId.value === null || tabSubmitting.value || !canValidateInformation.value) return
if (clientId.value === null || tabSubmitting.value) return
tabSubmitting.value = true
informationErrors.clearErrors()
try {
@@ -679,7 +672,7 @@ async function submitInformation(): Promise<void> {
profitAmount: information.profitAmount || null,
directorName: information.directorName || null,
}, { toast: false })
completeTab('information')
if (completeTab('information')) return
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch (error) {
@@ -701,9 +694,6 @@ const canAddContact = computed(() => {
return last !== undefined && isContactNamed(last)
})
// RG-1.14 : au moins un contact nomme pour finaliser l'onglet.
const canValidateContacts = computed(() => hasAtLeastOneValidContact(contacts.value))
function addContact(): void {
if (canAddContact.value) contacts.value.push(emptyContact())
}
@@ -717,9 +707,14 @@ function askRemoveContact(index: number): void {
/** POST/PATCH des contacts sur la sous-ressource /clients/{id}/contacts. */
async function submitContacts(): Promise<void> {
if (clientId.value === null || !canValidateContacts.value || tabSubmitting.value) return
if (clientId.value === null || tabSubmitting.value) return
tabSubmitting.value = true
try {
// RG-1.14 : au moins un contact requis. Si l'onglet ne contient QUE des
// amorces neuves vides, on ne les skippe pas -> le bloc vide est POSTe et
// le back renvoie la 422 RG-1.05 « prénom ou nom obligatoire » inline (la
// RG-1.14 n'a pas d'equivalent back au POST, on la materialise via RG-1.05).
const hasSubmittableContact = contacts.value.some(c => c.id !== null || !isContactBlank(c))
// On tente TOUS les blocs (collecte des erreurs par index, ERP-110). Seuls
// les blocs TOTALEMENT vides sont ignores : un bloc partiellement rempli
// sans nom (email seul) est soumis -> 422 RG-1.05 inline sous le bloc.
@@ -749,14 +744,14 @@ async function submitContacts(): Promise<void> {
}
},
error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
// On ne saute QUE les amorces neuves (id null) totalement vides. Un
// bloc existant vide est soumis -> 422 RG-1.05 inline (sinon la modif
// serait perdue en silence avec un faux toast de succes).
contact => contact.id === null && isContactBlank(contact),
// On ne saute une amorce neuve (id null) totalement vide QUE si un autre
// bloc sera soumis : sinon on la soumet pour declencher la 422 RG-1.05
// (un onglet Contact vide ne doit pas passer en faux succes).
contact => hasSubmittableContact && contact.id === null && isContactBlank(contact),
)
// Tant qu'un bloc reste en erreur : pas de validation d'onglet ni de toast succes.
if (hasError) return
completeTab('contact')
if (completeTab('contact')) return
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
finally {
@@ -789,12 +784,6 @@ const countryOptions: RefOption[] = [
{ value: 'Espagne', label: 'Espagne' },
]
// Type d'adresse (Select) obligatoire + RG-1.10 (>= 1 site) + RG-1.11 (email
// facturation si Facturation) sur chaque adresse.
const canValidateAddresses = computed(() =>
addresses.value.length > 0 && addresses.value.every(isAddressValid),
)
// « + Adresse » desactive tant que la derniere adresse n'est pas valide.
const canAddAddress = computed(() => {
const last = addresses.value[addresses.value.length - 1]
@@ -824,7 +813,7 @@ function onAddressDegraded(): void {
/** POST des adresses sur la sous-ressource /clients/{id}/addresses. */
async function submitAddresses(): Promise<void> {
if (clientId.value === null || !canValidateAddresses.value || tabSubmitting.value) return
if (clientId.value === null || tabSubmitting.value) return
tabSubmitting.value = true
try {
// On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110).
@@ -832,20 +821,8 @@ async function submitAddresses(): Promise<void> {
addresses.value,
addressErrors,
async (address) => {
const body = {
isProspect: address.isProspect,
isDelivery: address.isDelivery,
isBilling: address.isBilling,
country: address.country,
postalCode: address.postalCode || null,
city: address.city || null,
street: address.street || null,
streetComplement: address.streetComplement || null,
categories: address.categoryIris,
sites: address.siteIris,
contacts: address.contactIris,
billingEmail: isBillingEmailRequired(address) ? (address.billingEmail || null) : null,
}
// Payload partage avec l'edition (buildAddressPayload, ERP-119).
const body = buildAddressPayload(address, isBillingEmailRequired(address))
if (address.id === null) {
const created = await api.post<{ id: number }>(
`/clients/${clientId.value}/addresses`,
@@ -861,7 +838,7 @@ async function submitAddresses(): Promise<void> {
error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
)
if (hasError) return
completeTab('address')
if (completeTab('address')) return
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
finally {
@@ -909,16 +886,6 @@ function onPaymentTypeChange(value: string | number | null): void {
}
}
// RG-1.30 : les 6 champs scalaires obligatoires (comme les onglets Contact /
// Adresse, le bouton reste desactive tant que l'onglet n'est pas complet).
// RG-1.12 : banque requise si VIREMENT. RG-1.13 : >= 1 RIB complet si LCR.
const canValidateAccounting = computed(() => {
if (!hasAllRequiredAccountingFields(accounting)) return false
if (isBankRequired.value && (accounting.bankIri === null)) return false
if (isRibRequired.value && !ribs.value.some(isRibComplete)) return false
return true
})
// « + RIB » desactive tant que le dernier bloc RIB n'est pas complet.
const canAddRib = computed(() => {
const last = ribs.value[ribs.value.length - 1]
@@ -939,44 +906,28 @@ function askRemoveRib(index: number): void {
}
/**
* Valide l'onglet Comptabilite : PATCH des scalaires (groupe client:write:accounting)
* PUIS POST des RIB sur /clients/{id}/ribs. Deux appels distincts (mode strict
* RG-1.28 : il n'existe pas d'endpoint /accounting, cf. recon back).
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur /clients/{id}/ribs PUIS
* PATCH des scalaires (groupe client:write:accounting). Les RIB d'abord : le back
* valide RG-1.13 (LCR => au moins un RIB persiste) sur le PATCH scalaires, les RIB
* doivent donc exister en base AVANT (sinon 422 « Au moins un RIB est obligatoire
* pour le type de reglement LCR »). Deux appels distincts (mode strict RG-1.28 :
* il n'existe pas d'endpoint /accounting, cf. recon back).
*/
async function submitAccounting(): Promise<void> {
if (clientId.value === null || !canValidateAccounting.value || tabSubmitting.value) return
if (clientId.value === null || tabSubmitting.value) return
tabSubmitting.value = true
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 = []
try {
// 1) PATCH des scalaires comptables (erreurs inline sur leurs champs).
try {
await api.patch(`/clients/${clientId.value}`, {
siren: accounting.siren || null,
accountNumber: accounting.accountNumber || null,
tvaMode: accounting.tvaModeIri,
nTva: accounting.nTva || null,
paymentDelay: accounting.paymentDelayIri,
paymentType: accounting.paymentTypeIri,
bank: isBankRequired.value ? accounting.bankIri : null,
}, { toast: false })
}
catch (error) {
accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
return
}
// 2) POST/PATCH des RIB (erreurs inline par ligne, tous les blocs tentes).
// 1) POST/PATCH des RIB d'abord (erreurs inline par ligne, tous les blocs
// tentes). Le back exige >=1 RIB persiste pour valider une LCR a l'etape 2.
// Seuls les blocs RIB TOTALEMENT vides sont ignores : un RIB partiel (ex.
// IBAN seul) est soumis -> 422 NotBlank (label / bic / iban) inline.
const ribHasError = await submitRows(
ribs.value,
ribErrors,
async (rib) => {
const body = { label: rib.label, bic: rib.bic, iban: rib.iban }
// Payload partage avec l'edition (buildRibPayload, ERP-119).
const body = buildRibPayload(rib)
if (rib.id === null) {
const created = await api.post<{ id: number }>(
`/clients/${clientId.value}/ribs`,
@@ -997,7 +948,24 @@ async function submitAccounting(): Promise<void> {
)
if (ribHasError) return
completeTab('accounting')
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
try {
await api.patch(`/clients/${clientId.value}`, {
siren: accounting.siren || null,
accountNumber: accounting.accountNumber || null,
tvaMode: accounting.tvaModeIri,
nTva: accounting.nTva || null,
paymentDelay: accounting.paymentDelayIri,
paymentType: accounting.paymentTypeIri,
bank: isBankRequired.value ? accounting.bankIri : null,
}, { toast: false })
}
catch (error) {
accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
return
}
if (completeTab('accounting')) return
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
finally {
@@ -0,0 +1,434 @@
<template>
<div>
<PageHeader>
{{ t('commercial.suppliers.title') }}
<template #actions>
<!-- gap-8 = 32px d'espacement entre Filtres et Ajouter. -->
<div class="flex items-center gap-8">
<!-- Bouton Filtres a GAUCHE d'Ajouter. Le compteur reflete les filtres actifs. -->
<MalioButton
v-if="canView"
variant="tertiary"
:label="filterButtonLabel"
icon-name="mdi:tune"
icon-position="left"
icon-size="24"
@click="openFilters"
/>
<MalioButton
v-if="canManage"
variant="secondary"
:label="t('commercial.suppliers.add')"
icon-name="mdi:add-bold"
icon-position="left"
@click="goToCreate"
/>
</div>
</template>
</PageHeader>
<!-- Datatable branchee sur usePaginatedList via useSuppliersRepository :
pagination serveur, tri companyName ASC par defaut (cote back). -->
<MalioDataTable
:columns="columns"
:items="rows"
:total-items="totalItems"
:page="currentPage"
:per-page="itemsPerPage"
:per-page-options="itemsPerPageOptions"
row-clickable
table-class="table-fixed suppliers-table"
:empty-message="t('commercial.suppliers.empty')"
@row-click="onRowClick"
@update:page="goToPage"
@update:per-page="setItemsPerPage"
>
<!-- Categories : libelles (name) separes par une virgule (spec M2). -->
<template #cell-categories="{ item }">
{{ formatCategories(item) }}
</template>
<!-- Sites : badges colores (name + color), agreges des adresses. -->
<template #cell-sites="{ item }">
<span class="flex flex-wrap gap-1">
<span
v-for="site in (item.sites as SupplierSite[])"
:key="site.id"
class="inline-flex items-center rounded-full px-2 py-0.5 font-medium text-white"
:style="{ backgroundColor: site.color }"
>
{{ site.name }}
</span>
</span>
</template>
<!-- Derniere activite : date de derniere modification (updatedAt). -->
<template #cell-lastActivity="{ item }">
{{ formatLastActivity(item) }}
</template>
</MalioDataTable>
<div class="flex justify-center mt-4">
<MalioButton
v-if="canView"
variant="primary"
:label="t('commercial.suppliers.export')"
:disabled="exporting"
@click="exportXlsx"
/>
</div>
<!-- Drawer de filtres : etat BROUILLON, applique uniquement au clic sur
« Appliquer ». Meme pattern que le repertoire clients. Etat 100 % local,
jamais dans l'URL (regle ABSOLUE n°6). -->
<MalioDrawer
v-model="filterDrawerOpen"
drawer-class="max-w-[450px]"
body-class="p-0"
footer-class="justify-between border-t border-black p-6"
>
<template #header>
<h2 class="text-[24px] font-bold uppercase">{{ t('commercial.suppliers.filters.title') }}</h2>
</template>
<MalioAccordion>
<!-- Recherche : nom societe + contact + email (param `search`, decision D1). -->
<MalioAccordionItem :title="t('commercial.suppliers.filters.search')" value="search">
<MalioInputText
v-model="draftSearch"
icon-name="mdi:magnify"
/>
</MalioAccordionItem>
<!-- Categories : cases a cocher (multi). Valeur = code stable. -->
<MalioAccordionItem :title="t('commercial.suppliers.filters.categories')" value="categories">
<div class="flex flex-col">
<MalioCheckbox
v-for="opt in categoryOptions"
:id="`filter-category-${opt.value}`"
:key="opt.value"
:label="opt.label"
:model-value="draftCategoryCodes.includes(opt.value)"
@update:model-value="(val: boolean) => toggleCategory(opt.value, val)"
/>
</div>
</MalioAccordionItem>
<!-- Sites : cases a cocher (multi). Valeur = id du site. -->
<MalioAccordionItem :title="t('commercial.suppliers.filters.sites')" value="sites">
<div class="flex flex-col">
<MalioCheckbox
v-for="opt in siteOptions"
:id="`filter-site-${opt.value}`"
:key="opt.value"
:label="opt.label"
:model-value="draftSiteIds.includes(opt.value)"
@update:model-value="(val: boolean) => toggleSite(opt.value, val)"
/>
</div>
</MalioAccordionItem>
<!-- Statut : bool unique. Coche = inclut aussi les archives (sinon actifs seuls). -->
<MalioAccordionItem :title="t('commercial.suppliers.filters.status')" value="status">
<MalioCheckbox
id="filter-include-archived"
:label="t('commercial.suppliers.filters.includeArchived')"
:model-value="draftIncludeArchived"
@update:model-value="(val: boolean) => draftIncludeArchived = val"
/>
</MalioAccordionItem>
</MalioAccordion>
<template #footer>
<MalioButton
variant="tertiary"
:label="t('commercial.suppliers.filters.reset')"
button-class="w-m-btn-action"
@click="resetFilters"
/>
<MalioButton
variant="primary"
:label="t('commercial.suppliers.filters.apply')"
button-class="w-[170px]"
@click="applyFilters"
/>
</template>
</MalioDrawer>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { Supplier, SupplierSite } from '~/modules/commercial/composables/useSuppliersRepository'
interface FilterOption {
value: string
label: string
}
const { t } = useI18n()
const api = useApi()
const router = useRouter()
const toast = useToast()
const { can } = usePermissions()
useHead({ title: t('commercial.suppliers.title') })
// Bouton « Ajouter » reserve a `manage` (POST /suppliers garde manage seul →
// Compta / Usine ne creent pas). « Exporter » et « Filtres » suivent `view`.
const canManage = computed(() => can('commercial.suppliers.manage'))
const canView = computed(() => can('commercial.suppliers.view'))
const {
items: suppliers,
totalItems,
currentPage,
itemsPerPage,
itemsPerPageOptions,
fetch: loadSuppliers,
goToPage,
setItemsPerPage,
setFilters,
} = useSuppliersRepository()
// Mappe les fournisseurs en objets « plats » pour MalioDataTable (items typees
// Record<string, unknown>[]) : un objet litteral porte une signature d'index
// implicite, contrairement a l'interface Supplier. Meme pattern que clients.
const rows = computed(() => suppliers.value.map(supplier => ({
id: supplier.id,
companyName: supplier.companyName,
categories: supplier.categories,
sites: supplier.sites,
updatedAt: supplier.updatedAt,
})))
const columns = [
{ key: 'companyName', label: t('commercial.suppliers.column.companyName') },
{ key: 'categories', label: t('commercial.suppliers.column.categories') },
{ key: 'sites', label: t('commercial.suppliers.column.sites') },
{ key: 'lastActivity', label: t('commercial.suppliers.column.lastActivity') },
]
/** Libelles des categories du fournisseur, separes par une virgule (spec M2 : name). */
function formatCategories(item: Record<string, unknown>): string {
const categories = (item.categories as Supplier['categories']) ?? []
return categories.map(c => c.name).join(', ')
}
/**
* Derniere activite : faute de suivi d'activite metier au M2, on affiche la
* date de derniere modification de la fiche (updatedAt, expose en liste via
* default:read). Format court francais jj/mm/aaaa.
*/
function formatLastActivity(item: Record<string, unknown>): string {
const value = item.updatedAt as string | null | undefined
if (!value) {
return ''
}
// Garde-fou date invalide : un updatedAt mal forme donnerait « Invalid Date ».
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
return date.toLocaleDateString('fr-FR')
}
/** Clic sur une ligne → ecran Consultation (route a plat /suppliers/{id}). */
function onRowClick(item: Record<string, unknown>): void {
router.push(`/suppliers/${item.id}`)
}
function goToCreate(): void {
router.push('/suppliers/new')
}
// ── Filtres (drawer) ────────────────────────────────────────────────────────
// Deux niveaux d'etat (pattern repertoire clients) :
// - APPLIED : pilote la liste/l'export + le compteur du bouton. Modifie
// uniquement au clic « Appliquer » / « Réinitialiser ».
// - DRAFT : edite librement dans le drawer ; recopie vers applied a la validation.
const filterDrawerOpen = ref(false)
const draftSearch = ref('')
const draftCategoryCodes = ref<string[]>([])
const draftSiteIds = ref<string[]>([])
const draftIncludeArchived = ref(false)
const appliedSearch = ref('')
const appliedCategoryCodes = ref<string[]>([])
const appliedSiteIds = ref<string[]>([])
const appliedIncludeArchived = ref(false)
// Options des selects multi, chargees une fois (referentiels courts).
const categoryOptions = ref<FilterOption[]>([])
const siteOptions = ref<FilterOption[]>([])
const activeFilterCount = computed(() => {
let count = 0
if (appliedSearch.value.trim() !== '') count++
if (appliedCategoryCodes.value.length > 0) count++
if (appliedSiteIds.value.length > 0) count++
if (appliedIncludeArchived.value) count++
return count
})
const filterButtonLabel = computed(() => {
const base = t('commercial.suppliers.filters.title')
return activeFilterCount.value > 0 ? `${base} (${activeFilterCount.value})` : base
})
// Recopie l'etat applique vers le brouillon puis ouvre le drawer : la
// reouverture reflete les filtres actifs.
function openFilters(): void {
draftSearch.value = appliedSearch.value
draftCategoryCodes.value = [...appliedCategoryCodes.value]
draftSiteIds.value = [...appliedSiteIds.value]
draftIncludeArchived.value = appliedIncludeArchived.value
filterDrawerOpen.value = true
}
function toggleCategory(code: string, selected: boolean): void {
draftCategoryCodes.value = selected
? [...draftCategoryCodes.value, code]
: draftCategoryCodes.value.filter(c => c !== code)
}
function toggleSite(id: string, selected: boolean): void {
draftSiteIds.value = selected
? [...draftSiteIds.value, id]
: draftSiteIds.value.filter(s => s !== id)
}
/**
* Construit le payload de filtres serveur a partir de l'etat applique. Cles
* `categoryCode[]` / `siteId[]` pour que PHP les parse en tableaux (OR cote back).
* Les filtres vides sont omis pour une query propre.
*/
function buildFilterPayload(): Record<string, string | string[] | boolean> {
const payload: Record<string, string | string[] | boolean> = {}
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
if (appliedCategoryCodes.value.length > 0) payload['categoryCode[]'] = [...appliedCategoryCodes.value]
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = [...appliedSiteIds.value]
if (appliedIncludeArchived.value) payload.includeArchived = true
return payload
}
// « Appliquer » : recopie brouillon → applied, pousse les filtres (retombe en
// page 1 via usePaginatedList) et ferme le drawer.
function applyFilters(): void {
appliedSearch.value = draftSearch.value.trim()
appliedCategoryCodes.value = [...draftCategoryCodes.value]
appliedSiteIds.value = [...draftSiteIds.value]
appliedIncludeArchived.value = draftIncludeArchived.value
setFilters(buildFilterPayload(), { replace: true })
filterDrawerOpen.value = false
}
// « Réinitialiser » : vide brouillon ET applied, recharge la liste complete.
// Le drawer reste ouvert pour montrer le formulaire vide.
function resetFilters(): void {
draftSearch.value = ''
draftCategoryCodes.value = []
draftSiteIds.value = []
draftIncludeArchived.value = false
appliedSearch.value = ''
appliedCategoryCodes.value = []
appliedSiteIds.value = []
appliedIncludeArchived.value = false
setFilters({}, { replace: true })
}
/** Charge les referentiels du drawer (categories FOURNISSEUR + sites) via ?pagination=false. */
async function loadFilterOptions(): Promise<void> {
const [cats, sites] = await Promise.all([
api.get<{ member?: Array<{ code: string, name: string }> }>(
'/categories',
// Taxonomie multi-types (ERP-84) : le filtre du repertoire fournisseurs
// ne propose que les categories de type FOURNISSEUR (pas les CLIENT).
{ pagination: 'false', typeCode: 'FOURNISSEUR' },
{ headers: { Accept: 'application/ld+json' }, toast: false },
),
api.get<{ member?: Array<{ id: number, name: string }> }>(
'/sites',
{ pagination: 'false' },
{ headers: { Accept: 'application/ld+json' }, toast: false },
),
])
categoryOptions.value = (cats.member ?? []).map(c => ({ value: c.code, label: c.name }))
siteOptions.value = (sites.member ?? []).map(s => ({ value: String(s.id), label: s.name }))
}
// ── Export XLSX ─────────────────────────────────────────────────────────────
// Memes filtres que la vue. La colonne SIREN n'est dans le fichier que si
// l'utilisateur a accounting.view (gere cote back).
const exporting = ref(false)
async function exportXlsx(): Promise<void> {
if (exporting.value) {
return
}
exporting.value = true
try {
// useApi type ses options en JSON ; l'export renvoie un binaire, donc on
// force responseType:'blob' (transmis tel quel a ofetch au runtime). Cast
// contenu faute d'overload blob sur le client partage — a generaliser via
// un ticket dedie si d'autres exports binaires arrivent.
const blob = await api.get<Blob>('/suppliers/export.xlsx', buildFilterPayload(), {
responseType: 'blob',
toast: false,
} as unknown as Parameters<typeof api.get>[2])
triggerDownload(blob, 'repertoire-fournisseurs.xlsx')
}
catch {
toast.error({
title: t('commercial.suppliers.toast.error'),
message: t('commercial.suppliers.toast.exportError'),
})
}
finally {
exporting.value = false
}
}
/** Declenche le telechargement d'un blob via un lien temporaire. */
function triggerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}
onMounted(() => {
loadSuppliers()
// Echec du chargement des referentiels non bloquant : la liste s'affiche,
// l'utilisateur perd juste les options de filtre.
loadFilterOptions().catch(() => {
categoryOptions.value = []
siteOptions.value = []
})
})
</script>
<style scoped>
/*
* Colonne Sites uniquement (3e colonne : companyName, categories, SITES,
* lastActivity) : ses badges rendent la cellule trop haute. On reduit le padding
* vertical de SON td (16px Malio -> 8px) sans toucher les autres colonnes ni les
* couleurs/tailles (qui restent sur les defauts Malio).
*/
:deep(.suppliers-table tbody td:nth-child(3)) {
padding-top: 8px;
padding-bottom: 8px;
}
</style>
@@ -0,0 +1,864 @@
<template>
<div>
<!-- En-tete : retour vers le repertoire + titre. -->
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
v-bind="{ ariaLabel: t('commercial.suppliers.form.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('commercial.suppliers.form.title') }}</h1>
</div>
<!-- Formulaire principal (pre-onglets)
Sans validation de ce bloc, les onglets restent inaccessibles. Au
succes du POST, les champs passent en lecture seule et on bascule
automatiquement sur l'onglet Information. Pas de contact inline (ERP-106). -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="main.companyName"
:label="t('commercial.suppliers.form.main.companyName')"
:required="true"
:readonly="mainLocked"
:error="mainErrors.errors.companyName"
/>
<MalioSelectCheckbox
:model-value="main.categoryIris"
:options="referentials.categories.value"
:label="t('commercial.suppliers.form.main.categories')"
:display-tag="true"
:readonly="mainLocked"
:required="true"
:error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
/>
</div>
<div v-if="!mainLocked" class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('commercial.suppliers.form.submit')"
:disabled="mainSubmitting"
@click="submitMain"
/>
</div>
<!-- ── Onglets a validation incrementale ─────────────────────────────-->
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<!-- Onglet Information -->
<template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<MalioInputTextArea
v-model="information.description"
:label="t('commercial.suppliers.form.information.description')"
resize="none"
group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg"
:readonly="isValidated('information')"
:error="informationErrors.errors.description"
/>
<MalioInputText
v-model="information.competitors"
:label="t('commercial.suppliers.form.information.competitors')"
:readonly="isValidated('information')"
:error="informationErrors.errors.competitors"
/>
<MalioDate
v-model="information.foundedAt"
:label="t('commercial.suppliers.form.information.foundedAt')"
:readonly="isValidated('information')"
:editable="true"
:error="informationErrors.errors.foundedAt"
/>
<MalioInputText
v-model="information.employeesCount"
:label="t('commercial.suppliers.form.information.employeesCount')"
:mask="EMPLOYEES_MASK"
:readonly="isValidated('information')"
:error="informationErrors.errors.employeesCount"
/>
<MalioInputAmount
v-model="information.revenueAmount"
:label="t('commercial.suppliers.form.information.revenueAmount')"
:readonly="isValidated('information')"
:error="informationErrors.errors.revenueAmount"
/>
<MalioInputText
v-model="information.directorName"
:label="t('commercial.suppliers.form.information.directorName')"
:readonly="isValidated('information')"
:error="informationErrors.errors.directorName"
/>
<MalioInputAmount
v-model="information.profitAmount"
:label="t('commercial.suppliers.form.information.profitAmount')"
:readonly="isValidated('information')"
:error="informationErrors.errors.profitAmount"
/>
<!-- Volume previsionnel : specifique fournisseur. Champ texte
masque (chiffres uniquement) ; l'entier est resolu au PATCH. -->
<MalioInputText
v-model="information.volumeForecast"
:label="t('commercial.suppliers.form.information.volumeForecast')"
:mask="VOLUME_FORECAST_MASK"
:readonly="isValidated('information')"
:error="informationErrors.errors.volumeForecast"
/>
</div>
<div v-if="!isValidated('information')" class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('commercial.suppliers.form.submit')"
:disabled="tabSubmitting || supplierId === null"
@click="submitInformation"
/>
</div>
</template>
<!-- Onglet Contacts -->
<template #contacts>
<div class="mt-12 flex flex-col gap-6">
<SupplierContactBlock
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
:removable="index > 0"
:readonly="isValidated('contacts')"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
/>
<div v-if="!isValidated('contacts')" class="flex justify-center gap-6">
<MalioButton
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('commercial.suppliers.form.contact.add')"
:disabled="!canAddContact"
@click="addContact"
/>
<MalioButton
variant="primary"
:label="t('commercial.suppliers.form.submit')"
:disabled="tabSubmitting"
@click="submitContacts"
/>
</div>
</div>
</template>
<!-- Onglet Adresses -->
<template #addresses>
<div class="mt-12 flex flex-col gap-6">
<SupplierAddressBlock
v-for="(address, index) in addresses"
:key="index"
:model-value="address"
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
:category-options="referentials.categories.value"
:site-options="referentials.sites.value"
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="index > 0"
:readonly="isValidated('addresses')"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)"
@degraded="onAddressDegraded"
/>
<div v-if="!isValidated('addresses')" class="flex justify-center gap-6">
<MalioButton
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('commercial.suppliers.form.address.add')"
:disabled="!canAddAddress"
@click="addAddress"
/>
<MalioButton
variant="primary"
:label="t('commercial.suppliers.form.submit')"
:disabled="tabSubmitting"
@click="submitAddresses"
/>
</div>
</div>
</template>
<!-- Onglet Comptabilite (present uniquement si accounting.view) -->
<template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="accounting.siren"
:label="t('commercial.suppliers.form.accounting.siren')"
:mask="SIREN_MASK"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.siren"
/>
<MalioInputText
v-model="accounting.accountNumber"
:label="t('commercial.suppliers.form.accounting.accountNumber')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.accountNumber"
/>
<MalioSelect
:model-value="accounting.tvaModeIri"
:options="referentials.tvaModes.value"
:label="t('commercial.suppliers.form.accounting.tvaMode')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.tvaMode"
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
/>
<MalioInputText
v-model="accounting.nTva"
:label="t('commercial.suppliers.form.accounting.nTva')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.nTva"
/>
<MalioSelect
:model-value="accounting.paymentDelayIri"
:options="referentials.paymentDelays.value"
:label="t('commercial.suppliers.form.accounting.paymentDelay')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentDelay"
@update:model-value="(v: string | number | null) => accounting.paymentDelayIri = v === null ? null : String(v)"
/>
<MalioSelect
:model-value="accounting.paymentTypeIri"
:options="referentials.paymentTypes.value"
:label="t('commercial.suppliers.form.accounting.paymentType')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentType"
@update:model-value="onPaymentTypeChange"
/>
<MalioSelect
v-if="isBankRequired"
:model-value="accounting.bankIri"
:options="referentials.banks.value"
:label="t('commercial.suppliers.form.accounting.bank')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.bank"
@update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)"
/>
</div>
</div>
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-2.08). -->
<div
v-for="(rib, index) in visibleRibs"
:key="index"
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<MalioButtonIcon
v-if="!accountingReadonly"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('commercial.suppliers.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="rib.label"
:label="t('commercial.suppliers.form.accounting.ribLabel')"
:readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.label"
/>
<MalioInputText
v-model="rib.bic"
:label="t('commercial.suppliers.form.accounting.ribBic')"
:readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.bic"
/>
<MalioInputText
v-model="rib.iban"
:label="t('commercial.suppliers.form.accounting.ribIban')"
:readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.iban"
/>
</div>
</div>
<div v-if="!accountingReadonly" class="flex justify-center gap-6">
<MalioButton
v-if="isRibRequired"
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('commercial.suppliers.form.accounting.addRib')"
:disabled="!canAddRib"
@click="addRib"
/>
<MalioButton
variant="primary"
:label="t('commercial.suppliers.form.submit')"
:disabled="tabSubmitting"
@click="submitAccounting"
/>
</div>
</div>
</template>
<!-- Onglet placeholder : frame vide, passage automatique. -->
<template #transport><ComingSoonPlaceholder /></template>
</MalioTabList>
<!-- Modal de confirmation generique (suppression contact/adresse/RIB). -->
<MalioModal v-model="confirmModal.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('commercial.suppliers.form.confirmDelete.title') }}</h2>
</template>
<p>{{ confirmModal.message }}</p>
<template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('commercial.suppliers.form.confirmDelete.cancel')"
@click="confirmModal.open = false"
/>
<MalioButton
variant="danger"
button-class="flex-1"
:label="t('commercial.suppliers.form.confirmDelete.confirm')"
@click="runConfirm"
/>
</template>
</MalioModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useSupplierReferentials, type RefOption } from '~/modules/commercial/composables/useSupplierReferentials'
import { useSupplierFormErrors } from '~/modules/commercial/composables/useSupplierFormErrors'
import {
buildSupplierFormTabKeys,
SUPPLIER_FORM_PLACEHOLDER_TABS,
isAddressValid,
isBankRequiredForPaymentType,
isContactBlank,
isContactNamed,
isRibBlank,
isRibComplete,
isRibRequiredForPaymentType,
lastFillableTabKey,
} from '~/modules/commercial/utils/supplierFormRules'
import {
buildAccountingPayload,
buildAddressPayload,
buildContactPayload,
buildInformationPayload,
buildMainPayload,
buildRibPayload,
} from '~/modules/commercial/utils/supplierEdit'
import {
emptyAddress,
emptyContact,
emptyRib,
type SupplierAddressFormDraft,
type SupplierContactFormDraft,
type SupplierRibFormDraft,
} from '~/modules/commercial/types/supplierForm'
import { extractApiErrorMessage } from '~/shared/utils/api'
// Masques de saisie (la normalisation finale reste serveur).
const SIREN_MASK = '#########'
const EMPLOYEES_MASK = '#######'
// Volume previsionnel : champ texte borne aux chiffres (entier >= 0 cote back).
const VOLUME_FORECAST_MASK = '##########'
const { t } = useI18n()
const api = useApi()
const toast = useToast()
const router = useRouter()
const { can } = usePermissions()
/** Retour vers le repertoire fournisseurs (fleche d'en-tete). */
function goBack(): void {
router.push('/suppliers')
}
/**
* Message d'erreur a afficher dans un toast a partir d'une erreur d'API. Retourne
* TOUJOURS une chaine (le composant de toast plante sur `undefined`).
*/
function apiErrorMessage(error: unknown): string {
const data = (error as { data?: unknown })?.data
return extractApiErrorMessage(data) || t('commercial.suppliers.toast.error')
}
// ── Erreurs de validation par champ (ERP-101) ───────────────────────────────
const {
mainErrors,
informationErrors,
accountingErrors,
contactErrors,
addressErrors,
ribErrors,
submitRows,
} = useSupplierFormErrors()
useHead({ title: t('commercial.suppliers.form.title') })
// Gating de la route : la creation est reservee a `manage`. Compta (accounting
// seul) et Usine sont rediriges vers le repertoire.
if (!can('commercial.suppliers.manage')) {
await navigateTo('/suppliers')
}
const canAccountingView = computed(() => can('commercial.suppliers.accounting.view'))
const canAccountingManage = computed(() => can('commercial.suppliers.accounting.manage'))
const referentials = useSupplierReferentials()
// ── Etat du fournisseur cree ────────────────────────────────────────────────
const supplierId = ref<number | null>(null)
const mainLocked = ref(false)
const mainSubmitting = ref(false)
const tabSubmitting = ref(false)
// ── Formulaire principal ────────────────────────────────────────────────────
const main = reactive({
companyName: null as string | null,
categoryIris: [] as string[],
})
/** POST /suppliers (groupe supplier:write:main). Au succes : verrouille + bascule Information. */
async function submitMain(): Promise<void> {
if (mainSubmitting.value) return
mainSubmitting.value = true
mainErrors.clearErrors()
try {
const created = await api.post<SupplierResponse>('/suppliers', buildMainPayload(main), {
headers: { Accept: 'application/ld+json' },
toast: false,
})
supplierId.value = created.id
// Reaffiche la valeur normalisee renvoyee par le serveur (UPPERCASE, RG-2.12).
main.companyName = created.companyName ?? main.companyName
mainLocked.value = true
// Information est facultatif : on deverrouille jusqu'a Contacts (index 1).
unlockedIndex.value = tabIndex('contacts')
activeTab.value = 'information'
toast.success({ title: t('commercial.suppliers.toast.createSuccess') })
}
catch (error) {
// 409 = doublon nom de societe (RG d'unicite) → erreur inline + toast ;
// 422 → mapping inline par champ ; autre → toast de fallback (ERP-101).
const status = (error as { response?: { status?: number } })?.response?.status
if (status === 409) {
const message = t('commercial.suppliers.form.duplicateCompany')
mainErrors.setError('companyName', message)
toast.error({ title: t('commercial.suppliers.toast.error'), message })
}
else {
mainErrors.handleApiError(error, { fallbackMessage: t('commercial.suppliers.toast.error') })
}
}
finally {
mainSubmitting.value = false
}
}
// ── Onglets : ordre + gating progressif ─────────────────────────────────────
const activeTab = ref('information')
// Index du dernier onglet deverrouille (-1 tant que le fournisseur n'est pas cree).
const unlockedIndex = ref(-1)
// Onglets valides (passent en lecture seule).
const validated = reactive<Record<string, boolean>>({})
const tabKeys = computed(() => buildSupplierFormTabKeys(canAccountingView.value))
// Dernier onglet REMPLISSABLE par le role : sa validation cloture l'ajout.
const lastFillableTab = computed(() => lastFillableTabKey(tabKeys.value))
// Icone (Iconify) affichee dans l'onglet, par cle.
const TAB_ICONS: Record<string, string> = {
information: 'mdi:account-outline',
contacts: 'mdi:account-box-plus-outline',
addresses: 'mdi:map-marker-outline',
transport: 'mdi:truck-delivery-outline',
accounting: 'mdi:bank-circle-outline',
}
const tabs = computed(() => tabKeys.value.map((key, index) => ({
key,
label: t(`commercial.suppliers.tab.${key}`),
icon: TAB_ICONS[key],
disabled: index > unlockedIndex.value,
})))
function isValidated(key: string): boolean {
return validated[key] === true
}
function tabIndex(key: string): number {
return tabKeys.value.indexOf(key)
}
/**
* Marque l'onglet valide. Si c'est le dernier onglet remplissable, l'ajout est
* termine : toast final + redirection vers la liste, et on retourne true. Sinon,
* deverrouille et avance a l'onglet suivant, et retourne false.
*/
function completeTab(key: string): boolean {
validated[key] = true
if (key === lastFillableTab.value) {
toast.success({ title: t('commercial.suppliers.toast.addComplete') })
router.push('/suppliers')
return true
}
const next = tabKeys.value[tabIndex(key) + 1]
unlockedIndex.value = Math.max(unlockedIndex.value, tabIndex(key) + 1)
if (next) activeTab.value = next
return false
}
// Passage automatique sur les onglets coquille (Transport).
watch(activeTab, (key) => {
if ((SUPPLIER_FORM_PLACEHOLDER_TABS as readonly string[]).includes(key)) {
const next = tabKeys.value[tabIndex(key) + 1]
unlockedIndex.value = Math.max(unlockedIndex.value, tabIndex(key) + 1)
if (next) activeTab.value = next
}
})
// ── Onglet Information ──────────────────────────────────────────────────────
const information = reactive({
description: null as string | null,
competitors: null as string | null,
foundedAt: null as string | null,
employeesCount: null as string | null,
revenueAmount: null as string | null,
profitAmount: null as string | null,
directorName: null as string | null,
volumeForecast: null as string | null,
})
/** PATCH /suppliers/{id} — mode strict : uniquement les champs du groupe information. */
async function submitInformation(): Promise<void> {
if (supplierId.value === null || tabSubmitting.value) return
tabSubmitting.value = true
informationErrors.clearErrors()
try {
await api.patch(`/suppliers/${supplierId.value}`, buildInformationPayload(information), { toast: false })
if (completeTab('information')) return
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
}
catch (error) {
informationErrors.handleApiError(error, { fallbackMessage: t('commercial.suppliers.toast.error') })
}
finally {
tabSubmitting.value = false
}
}
// ── Onglet Contacts ─────────────────────────────────────────────────────────
const contacts = ref<SupplierContactFormDraft[]>([emptyContact()])
// « + Nouveau contact » desactive tant que le dernier bloc n'a ni nom ni prenom.
const canAddContact = computed(() => {
const last = contacts.value[contacts.value.length - 1]
return last !== undefined && isContactNamed(last)
})
function addContact(): void {
if (canAddContact.value) contacts.value.push(emptyContact())
}
function askRemoveContact(index: number): void {
askConfirm(t('commercial.suppliers.form.confirmDelete.contact'), () => {
contacts.value.splice(index, 1)
contactErrors.value.splice(index, 1)
})
}
/** POST/PATCH des contacts sur la sous-ressource /suppliers/{id}/contacts. */
async function submitContacts(): Promise<void> {
if (supplierId.value === null || tabSubmitting.value) return
tabSubmitting.value = true
try {
// RG-2.13 : au moins un contact requis. Si l'onglet ne contient QUE des
// amorces vides, on les soumet pour declencher la 422 RG-2.04 inline.
const hasSubmittableContact = contacts.value.some(c => c.id !== null || !isContactBlank(c))
const hasError = await submitRows(
contacts.value,
contactErrors,
async (contact) => {
const body = buildContactPayload(contact)
if (contact.id === null) {
const created = await api.post<ContactResponse>(
`/suppliers/${supplierId.value}/contacts`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
contact.id = created.id
contact.iri = created['@id'] ?? null
}
else {
await api.patch(`/supplier_contacts/${contact.id}`, body, { toast: false })
}
},
error => toast.error({ title: t('commercial.suppliers.toast.error'), message: apiErrorMessage(error) }),
contact => hasSubmittableContact && contact.id === null && isContactBlank(contact),
)
if (hasError) return
if (completeTab('contacts')) return
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
}
finally {
tabSubmitting.value = false
}
}
// ── Onglet Adresses ─────────────────────────────────────────────────────────
const addresses = ref<SupplierAddressFormDraft[]>([emptyAddress()])
const addressDegradedNotified = ref(false)
// Contacts deja crees, rattachables a une adresse (M2M, via leur IRI).
const contactOptions = computed<RefOption[]>(() =>
contacts.value
.filter(c => c.iri !== null)
.map(c => ({
value: c.iri as string,
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? ''),
})),
)
// Pays disponibles (France preselectionnee par defaut sur chaque adresse).
const countryOptions: RefOption[] = [
{ value: 'France', label: 'France' },
{ value: 'Espagne', label: 'Espagne' },
]
// « + Adresse » desactive tant que la derniere adresse n'est pas valide.
const canAddAddress = computed(() => {
const last = addresses.value[addresses.value.length - 1]
return last !== undefined && isAddressValid(last)
})
function addAddress(): void {
if (canAddAddress.value) addresses.value.push(emptyAddress())
}
function askRemoveAddress(index: number): void {
askConfirm(t('commercial.suppliers.form.confirmDelete.address'), () => {
addresses.value.splice(index, 1)
addressErrors.value.splice(index, 1)
})
}
/** Avertit une seule fois quand l'autocompletion d'adresse bascule en degrade. */
function onAddressDegraded(): void {
if (addressDegradedNotified.value) return
addressDegradedNotified.value = true
toast.warning({
title: t('commercial.suppliers.toast.error'),
message: t('commercial.suppliers.form.address.degraded'),
})
}
/** POST/PATCH des adresses sur la sous-ressource /suppliers/{id}/addresses. */
async function submitAddresses(): Promise<void> {
if (supplierId.value === null || tabSubmitting.value) return
tabSubmitting.value = true
try {
const hasError = await submitRows(
addresses.value,
addressErrors,
async (address) => {
const body = buildAddressPayload(address)
if (address.id === null) {
const created = await api.post<{ id: number }>(
`/suppliers/${supplierId.value}/addresses`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
address.id = created.id
}
else {
await api.patch(`/supplier_addresses/${address.id}`, body, { toast: false })
}
},
error => toast.error({ title: t('commercial.suppliers.toast.error'), message: apiErrorMessage(error) }),
)
if (hasError) return
if (completeTab('addresses')) return
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
}
finally {
tabSubmitting.value = false
}
}
// ── Onglet Comptabilite ─────────────────────────────────────────────────────
const accounting = reactive({
siren: null as string | null,
accountNumber: null as string | null,
tvaModeIri: null as string | null,
nTva: null as string | null,
paymentDelayIri: null as string | null,
paymentTypeIri: null as string | null,
bankIri: null as string | null,
})
const ribs = ref<SupplierRibFormDraft[]>([])
// L'onglet est editable seulement avec accounting.manage (sinon lecture seule).
const accountingReadonly = computed(() => isValidated('accounting') || !canAccountingManage.value)
// Code du type de reglement selectionne (pour RG-2.07 / RG-2.08).
const selectedPaymentTypeCode = computed(() =>
referentials.paymentTypes.value.find(p => p.value === accounting.paymentTypeIri)?.code ?? null,
)
const isBankRequired = computed(() => isBankRequiredForPaymentType(selectedPaymentTypeCode.value))
const isRibRequired = computed(() => isRibRequiredForPaymentType(selectedPaymentTypeCode.value))
// Les blocs RIB ne sont affiches que pour une LCR (RG-2.08).
const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
function onPaymentTypeChange(value: string | number | null): void {
accounting.paymentTypeIri = value === null ? null : String(value)
// La banque n'a de sens que pour un virement : on la vide sinon (RG-2.07).
if (!isBankRequired.value) accounting.bankIri = null
// Les RIB n'ont de sens que pour une LCR (RG-2.08) : amorce un bloc vide quand
// LCR est choisi, vide la liste sinon (pas de RIB fantome soumis).
if (isRibRequired.value) {
if (ribs.value.length === 0) ribs.value.push(emptyRib())
}
else {
ribs.value = []
ribErrors.value = []
}
}
// « + RIB » desactive tant que le dernier bloc RIB n'est pas complet.
const canAddRib = computed(() => {
const last = ribs.value[ribs.value.length - 1]
return last !== undefined && isRibComplete(last)
})
function addRib(): void {
if (canAddRib.value) ribs.value.push(emptyRib())
}
function askRemoveRib(index: number): void {
askConfirm(t('commercial.suppliers.form.confirmDelete.rib'), () => {
ribs.value.splice(index, 1)
ribErrors.value.splice(index, 1)
// Garde au moins un bloc RIB visible.
if (ribs.value.length === 0) ribs.value.push(emptyRib())
})
}
/**
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur /suppliers/{id}/ribs PUIS
* PATCH des scalaires (groupe supplier:write:accounting). Les RIB d'abord : le back
* valide RG-2.08 (LCR => au moins un RIB persiste) sur le PATCH scalaires, les RIB
* doivent donc exister en base AVANT. Deux appels distincts (mode strict).
*/
async function submitAccounting(): Promise<void> {
if (supplierId.value === null || tabSubmitting.value) return
tabSubmitting.value = true
accountingErrors.clearErrors()
try {
// 1) POST/PATCH des RIB d'abord (erreurs inline par ligne). Seuls les blocs
// RIB TOTALEMENT vides (amorce neuve) sont ignores.
const ribHasError = await submitRows(
ribs.value,
ribErrors,
async (rib) => {
const body = buildRibPayload(rib)
if (rib.id === null) {
const created = await api.post<{ id: number }>(
`/suppliers/${supplierId.value}/ribs`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
rib.id = created.id
}
else {
await api.patch(`/supplier_ribs/${rib.id}`, body, { toast: false })
}
},
error => toast.error({ title: t('commercial.suppliers.toast.error'), message: apiErrorMessage(error) }),
rib => rib.id === null && isRibBlank(rib),
)
if (ribHasError) return
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
try {
await api.patch(
`/suppliers/${supplierId.value}`,
buildAccountingPayload(accounting, isBankRequired.value),
{ toast: false },
)
}
catch (error) {
accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.suppliers.toast.error') })
return
}
if (completeTab('accounting')) return
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
}
finally {
tabSubmitting.value = false
}
}
// ── Modal de confirmation generique ─────────────────────────────────────────
const confirmModal = reactive({
open: false,
message: '',
action: null as null | (() => void),
})
function askConfirm(message: string, action: () => void): void {
confirmModal.message = message
confirmModal.action = action
confirmModal.open = true
}
function runConfirm(): void {
confirmModal.action?.()
confirmModal.action = null
confirmModal.open = false
}
// ── Types de reponse API ────────────────────────────────────────────────────
interface SupplierResponse {
id: number
companyName: string | null
}
interface ContactResponse {
'@id'?: string
id: number
}
onMounted(() => {
// Echec du chargement des referentiels non bloquant : les selects restent vides.
referentials.loadCommon().catch(() => {})
})
</script>
@@ -30,6 +30,10 @@ export interface AddressFormDraft {
isProspect: boolean
isDelivery: boolean
isBilling: boolean
/** Adresse Courtier — type autonome exclusif. */
isBroker: boolean
/** Adresse Distributeur — type autonome exclusif. */
isDistributor: boolean
country: string
postalCode: string | null
city: string | null
@@ -43,6 +47,10 @@ export interface AddressFormDraft {
contactIris: string[]
/** Email de facturation (obligatoire si isBilling — RG-1.11). */
billingEmail: string | null
/** 2e email de facturation, optionnel (max 2 — pendant du telephone secondaire). */
billingEmailSecondary: string | null
/** Drapeau UI : 2e champ email revele (comme hasSecondaryPhone). */
hasSecondaryBillingEmail: boolean
}
/** Un RIB du client (onglet Comptabilite). */
@@ -75,6 +83,8 @@ export function emptyAddress(): AddressFormDraft {
isProspect: false,
isDelivery: false,
isBilling: false,
isBroker: false,
isDistributor: false,
country: 'France',
postalCode: null,
city: null,
@@ -84,6 +94,8 @@ export function emptyAddress(): AddressFormDraft {
siteIris: [],
contactIris: [],
billingEmail: null,
billingEmailSecondary: null,
hasSecondaryBillingEmail: false,
}
}
@@ -0,0 +1,109 @@
/**
* Types « brouillon » de l'ecran « Ajouter un fournisseur » (M2 Commercial).
*
* Miroir de `types/clientForm.ts` (M1). Ces interfaces decrivent l'etat LOCAL du
* formulaire (refs Vue), distinct des DTO de l'API : elles portent en plus des
* champs purement UI (`hasSecondaryPhone`) et l'`iri` Hydra des entites creees
* (necessaire pour rattacher une adresse a des contacts deja persistes, M2M).
* Partage par la page de creation et les blocs `SupplierContactBlock` /
* `SupplierAddressBlock` (reutilises par la consultation/modification 95/96).
*
* Differences M2 vs M1 (cf. spec-front § « Differences notables ») :
* - Adresse : type via enum exclusif `addressType` (PROSPECT/DEPART/RENDU,
* RG-2.09) — pas de drapeaux isProspect/isDelivery/isBilling.
* - Adresse : champs specifiques fournisseur `bennes` (nombre) et
* `triageProvider` (prestation de triage). Pas d'email de facturation.
* - Pas de relation Distributeur/Courtier ni de triage sur le bloc principal.
*/
/** Type d'adresse fournisseur (enum exclusif RG-2.09). */
export type SupplierAddressType = 'PROSPECT' | 'DEPART' | 'RENDU'
/** Un contact du fournisseur (onglet Contacts). */
export interface SupplierContactFormDraft {
/** Id serveur une fois le contact cree (null tant que non persiste). */
id: number | null
/** IRI Hydra du contact cree — utilise pour le rattachement M2M cote adresse. */
iri: string | null
firstName: string | null
lastName: string | null
jobTitle: string | null
phonePrimary: string | null
phoneSecondary: string | null
email: string | null
/** UI : le 2e numero a ete revele via le bouton « + ». */
hasSecondaryPhone: boolean
}
/** Une adresse du fournisseur (onglet Adresses). */
export interface SupplierAddressFormDraft {
id: number | null
/** Type exclusif Prospect / Depart / Rendu (RG-2.09). null tant que non choisi. */
addressType: SupplierAddressType | null
country: string
postalCode: string | null
city: string | null
street: string | null
streetComplement: string | null
/** IRI des categories rattachees (type FOURNISSEUR, RG-2.10). */
categoryIris: string[]
/** IRI des sites Starseed rattaches (>= 1 obligatoire — RG-2.06). */
siteIris: string[]
/** IRI des contacts rattaches (= blocs Contact deja crees). */
contactIris: string[]
/** Nombre de bennes (stepper, defaut 0). Chaine pour MalioInputNumber, convertie au payload. */
bennes: string | null
/** Prestation de triage (specifique fournisseur, portee par l'adresse — RG). */
triageProvider: boolean
}
/** Un RIB du fournisseur (onglet Comptabilite). */
export interface SupplierRibFormDraft {
id: number | null
label: string | null
bic: string | null
iban: string | null
}
/** Fabrique un contact vierge. */
export function emptyContact(): SupplierContactFormDraft {
return {
id: null,
iri: null,
firstName: null,
lastName: null,
jobTitle: null,
phonePrimary: null,
phoneSecondary: null,
email: null,
hasSecondaryPhone: false,
}
}
/** Fabrique une adresse vierge (pays prerempli « France », 0 benne). */
export function emptyAddress(): SupplierAddressFormDraft {
return {
id: null,
addressType: null,
country: 'France',
postalCode: null,
city: null,
street: null,
streetComplement: null,
categoryIris: [],
siteIris: [],
contactIris: [],
bennes: '0',
triageProvider: false,
}
}
/** Fabrique un RIB vierge. */
export function emptyRib(): SupplierRibFormDraft {
return {
id: null,
label: null,
bic: null,
iban: null,
}
}
@@ -61,7 +61,9 @@ function accountingDraft(overrides: Partial<AccountingFormDraft> = {}): Accounti
// Le contact inline (nom/prenom/telephones/email) ne fait plus partie du groupe
// main : les coordonnees vivent desormais sur la sous-ressource ClientContact.
const MAIN_KEYS = [
'companyName', 'categories', 'distributor', 'broker', 'triageService',
// relationType : champ transitoire envoye au back pour la validation croisee
// « relation choisie => FK obligatoire » (RG-1.03 bis, ERP-119).
'companyName', 'categories', 'relationType', 'distributor', 'broker', 'triageService',
]
const INFORMATION_KEYS = [
'description', 'competitors', 'foundedAt', 'employeesCount',
@@ -99,6 +101,27 @@ describe('buildMainPayload — scoping strict groupe client:write:main', () => {
expect(payload.distributor).toBeNull()
expect(payload.broker).toBeNull()
})
it('transmet relationType au back pour la validation croisee (RG-1.03 bis)', () => {
expect(buildMainPayload(mainDraft({ relationType: 'distributeur' })).relationType).toBe('distributeur')
expect(buildMainPayload(mainDraft({ relationType: 'courtier' })).relationType).toBe('courtier')
expect(buildMainPayload(mainDraft({ relationType: null })).relationType).toBeNull()
})
// ERP-119 : companyName est requis ET adosse a une colonne NON-nullable. Si le
// champ est vide, on OMET la cle (au lieu d'envoyer null) pour que le back
// renvoie une 422 NotBlank (propertyPath companyName) et non un 400 de type.
it('omet companyName quand il est vide (null) -> 422 NotBlank cote back', () => {
expect('companyName' in buildMainPayload(mainDraft({ companyName: null }))).toBe(false)
})
it('omet companyName quand il est une chaine vide', () => {
expect('companyName' in buildMainPayload(mainDraft({ companyName: '' }))).toBe(false)
})
it('conserve companyName quand il est renseigne', () => {
expect(buildMainPayload(mainDraft({ companyName: 'ACME' })).companyName).toBe('ACME')
})
})
describe('buildInformationPayload — scoping strict groupe client:write:information', () => {
@@ -142,19 +165,50 @@ describe('buildContactPayload / buildAddressPayload / buildRibPayload', () => {
it('adresse : email facturation conserve uniquement si requis (RG-1.11)', () => {
const address: AddressFormDraft = {
id: 3, isProspect: false, isDelivery: false, isBilling: true, country: 'France',
id: 3, isProspect: false, isDelivery: false, isBilling: true, isBroker: false, isDistributor: false, country: 'France',
postalCode: '86100', city: 'Châtellerault', street: '1 rue X', streetComplement: null,
categoryIris: ['/api/categories/2'], siteIris: ['/api/sites/1'], contactIris: [],
billingEmail: 'facturation@acme.fr',
billingEmail: 'facturation@acme.fr', billingEmailSecondary: 'compta@acme.fr', hasSecondaryBillingEmail: true,
}
expect(buildAddressPayload(address, true).billingEmail).toBe('facturation@acme.fr')
expect(buildAddressPayload(address, false).billingEmail).toBeNull()
// 2e email : transmis si facturation + revele, sinon null (ERP-119).
expect(buildAddressPayload(address, true).billingEmailSecondary).toBe('compta@acme.fr')
expect(buildAddressPayload(address, false).billingEmailSecondary).toBeNull()
expect(buildAddressPayload({ ...address, hasSecondaryBillingEmail: false }, true).billingEmailSecondary).toBeNull()
})
it('rib : label / bic / iban transmis tels quels', () => {
const rib: RibFormDraft = { id: 1, label: 'Compte principal', bic: 'BNPAFRPP', iban: 'FR76...' }
expect(buildRibPayload(rib)).toEqual({ label: 'Compte principal', bic: 'BNPAFRPP', iban: 'FR76...' })
})
// ERP-119 : un RIB partiel (IBAN seul) doit omettre label/bic vides pour
// declencher la 422 NotBlank par champ, pas un 400 de type a la deserialisation.
it('rib partiel : omet label / bic vides, conserve iban', () => {
const rib: RibFormDraft = { id: null, label: null, bic: null, iban: 'FR7612345' }
const payload = buildRibPayload(rib)
expect('label' in payload).toBe(false)
expect('bic' in payload).toBe(false)
expect(payload.iban).toBe('FR7612345')
})
// ERP-119 : une adresse partielle omet postalCode/city/street vides (NotBlank).
it('adresse partielle : omet postalCode / city / street vides', () => {
const address: AddressFormDraft = {
id: null, isProspect: false, isDelivery: true, isBilling: false, isBroker: false, isDistributor: false, country: 'France',
postalCode: null, city: '', street: null, streetComplement: null,
categoryIris: ['/api/categories/2'], siteIris: ['/api/sites/1'], contactIris: [],
billingEmail: null, billingEmailSecondary: null, hasSecondaryBillingEmail: false,
}
const payload = buildAddressPayload(address, false)
expect('postalCode' in payload).toBe(false)
expect('city' in payload).toBe(false)
expect('street' in payload).toBe(false)
// Les champs non requis / booleens restent presents.
expect(payload.isDelivery).toBe(true)
expect(payload.sites).toEqual(['/api/sites/1'])
})
})
describe('mapMainDraft — pre-remplissage bloc principal', () => {
@@ -18,7 +18,10 @@ import {
isRibBlank,
isRibComplete,
isRibRequiredForPaymentType,
lastFillableTabKey,
omitEmptyRequired,
showsRelationAndTriageFields,
type AddressFlagsDraft,
type AddressValidityDraft,
type ContactDraft,
type ContactFillableDraft,
@@ -68,6 +71,24 @@ describe('buildClientFormTabKeys (gating onglet Comptabilite + onglets edit-only
})
})
describe('lastFillableTabKey (redirection fin d\'ajout, role-aware)', () => {
it('Adresse pour un role sans Comptabilite (Bureau / Commerciale)', () => {
expect(lastFillableTabKey(buildClientFormTabKeys(false))).toBe('address')
})
it('Comptabilite pour un role avec accounting.view (Admin)', () => {
expect(lastFillableTabKey(buildClientFormTabKeys(true))).toBe('accounting')
})
it('ignore les onglets placeholder (Transport en dernier ne compte pas)', () => {
expect(lastFillableTabKey(['information', 'contact', 'address', 'transport'])).toBe('address')
})
it('undefined si aucun onglet remplissable (que des placeholders)', () => {
expect(lastFillableTabKey(['transport', 'statistics'])).toBeUndefined()
})
})
describe('isContactNamed (RG-1.05)', () => {
it('vrai si le prenom est renseigne', () => {
expect(isContactNamed({ firstName: 'Alice', lastName: null })).toBe(true)
@@ -148,83 +169,79 @@ describe('hasAtLeastOneValidContact (RG-1.14)', () => {
})
})
/** Drapeaux d'adresse complets (5 types) avec surcharge partielle. */
function flags(overrides: Partial<AddressFlagsDraft> = {}): AddressFlagsDraft {
return {
isProspect: false, isDelivery: false, isBilling: false, isBroker: false, isDistributor: false,
...overrides,
}
}
describe('exclusivite Prospect / Livraison / Facturation (RG-1.06/07/08)', () => {
it('Prospect est selectionnable tant que ni Livraison ni Facturation', () => {
expect(canSelectProspect({ isProspect: false, isDelivery: false, isBilling: false })).toBe(true)
expect(canSelectProspect({ isProspect: false, isDelivery: true, isBilling: false })).toBe(false)
expect(canSelectProspect({ isProspect: false, isDelivery: false, isBilling: true })).toBe(false)
expect(canSelectProspect(flags())).toBe(true)
expect(canSelectProspect(flags({ isDelivery: true }))).toBe(false)
expect(canSelectProspect(flags({ isBilling: true }))).toBe(false)
})
it('Livraison / Facturation selectionnables tant que pas Prospect', () => {
expect(canSelectDeliveryOrBilling({ isProspect: false, isDelivery: false, isBilling: false })).toBe(true)
expect(canSelectDeliveryOrBilling({ isProspect: true, isDelivery: false, isBilling: false })).toBe(false)
expect(canSelectDeliveryOrBilling(flags())).toBe(true)
expect(canSelectDeliveryOrBilling(flags({ isProspect: true }))).toBe(false)
})
it('cocher Prospect efface Livraison et Facturation', () => {
const next = applyProspectExclusivity(
{ isProspect: false, isDelivery: true, isBilling: true },
'isProspect',
true,
)
expect(next).toEqual({ isProspect: true, isDelivery: false, isBilling: false })
const next = applyProspectExclusivity(flags({ isDelivery: true, isBilling: true }), 'isProspect', true)
expect(next).toEqual(flags({ isProspect: true }))
})
it('cocher Livraison efface Prospect', () => {
const next = applyProspectExclusivity(
{ isProspect: true, isDelivery: false, isBilling: false },
'isDelivery',
true,
)
expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: false })
const next = applyProspectExclusivity(flags({ isProspect: true }), 'isDelivery', true)
expect(next).toEqual(flags({ isDelivery: true }))
})
it('cocher Facturation efface Prospect mais conserve Livraison', () => {
const next = applyProspectExclusivity(
{ isProspect: true, isDelivery: true, isBilling: false },
'isBilling',
true,
)
expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: true })
const next = applyProspectExclusivity(flags({ isProspect: true, isDelivery: true }), 'isBilling', true)
expect(next).toEqual(flags({ isDelivery: true, isBilling: true }))
})
it('decocher un drapeau ne reactive rien d autre', () => {
const next = applyProspectExclusivity(
{ isProspect: false, isDelivery: true, isBilling: true },
'isBilling',
false,
)
expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: false })
const next = applyProspectExclusivity(flags({ isDelivery: true, isBilling: true }), 'isBilling', false)
expect(next).toEqual(flags({ isDelivery: true }))
})
})
describe('isBillingEmailRequired (RG-1.11)', () => {
it('obligatoire uniquement si Facturation est coche', () => {
expect(isBillingEmailRequired({ isProspect: false, isDelivery: false, isBilling: true })).toBe(true)
expect(isBillingEmailRequired({ isProspect: false, isDelivery: true, isBilling: false })).toBe(false)
expect(isBillingEmailRequired(flags({ isBilling: true }))).toBe(true)
expect(isBillingEmailRequired(flags({ isDelivery: true }))).toBe(false)
})
})
describe('type d\'adresse (Select front) <-> drapeaux back', () => {
it('addressFlagsFromType mappe chaque type vers les bons drapeaux', () => {
expect(addressFlagsFromType('prospect')).toEqual({ isProspect: true, isDelivery: false, isBilling: false })
expect(addressFlagsFromType('delivery')).toEqual({ isProspect: false, isDelivery: true, isBilling: false })
expect(addressFlagsFromType('billing')).toEqual({ isProspect: false, isDelivery: false, isBilling: true })
expect(addressFlagsFromType('delivery_billing')).toEqual({ isProspect: false, isDelivery: true, isBilling: true })
expect(addressFlagsFromType('prospect')).toEqual(flags({ isProspect: true }))
expect(addressFlagsFromType('delivery')).toEqual(flags({ isDelivery: true }))
expect(addressFlagsFromType('billing')).toEqual(flags({ isBilling: true }))
expect(addressFlagsFromType('delivery_billing')).toEqual(flags({ isDelivery: true, isBilling: true }))
expect(addressFlagsFromType('broker')).toEqual(flags({ isBroker: true }))
expect(addressFlagsFromType('distributor')).toEqual(flags({ isDistributor: true }))
})
it('addressTypeFromFlags reconstruit le type (Prospect prioritaire, livraison+facturation groupes)', () => {
expect(addressTypeFromFlags({ isProspect: true, isDelivery: false, isBilling: false })).toBe('prospect')
expect(addressTypeFromFlags({ isProspect: false, isDelivery: true, isBilling: false })).toBe('delivery')
expect(addressTypeFromFlags({ isProspect: false, isDelivery: false, isBilling: true })).toBe('billing')
expect(addressTypeFromFlags({ isProspect: false, isDelivery: true, isBilling: true })).toBe('delivery_billing')
it('addressTypeFromFlags reconstruit le type (Prospect/Courtier/Distributeur autonomes, livraison+facturation groupes)', () => {
expect(addressTypeFromFlags(flags({ isProspect: true }))).toBe('prospect')
expect(addressTypeFromFlags(flags({ isDelivery: true }))).toBe('delivery')
expect(addressTypeFromFlags(flags({ isBilling: true }))).toBe('billing')
expect(addressTypeFromFlags(flags({ isDelivery: true, isBilling: true }))).toBe('delivery_billing')
expect(addressTypeFromFlags(flags({ isBroker: true }))).toBe('broker')
expect(addressTypeFromFlags(flags({ isDistributor: true }))).toBe('distributor')
})
it('addressTypeFromFlags retourne null quand aucun drapeau (amorce vierge -> bouton bloque)', () => {
expect(addressTypeFromFlags({ isProspect: false, isDelivery: false, isBilling: false })).toBeNull()
expect(addressTypeFromFlags(flags())).toBeNull()
})
it('aller-retour type -> drapeaux -> type stable pour les 4 types', () => {
for (const type of ['prospect', 'delivery', 'billing', 'delivery_billing'] as const) {
it('aller-retour type -> drapeaux -> type stable pour les 6 types', () => {
for (const type of ['prospect', 'delivery', 'billing', 'delivery_billing', 'broker', 'distributor'] as const) {
expect(addressTypeFromFlags(addressFlagsFromType(type))).toBe(type)
}
})
@@ -324,6 +341,8 @@ describe('isAddressValid (gating « + Adresse » + validation onglet)', () => {
isProspect: false,
isDelivery: true,
isBilling: false,
isBroker: false,
isDistributor: false,
categoryIris: ['/api/client_categories/1'],
siteIris: ['/api/sites/1'],
billingEmail: null,
@@ -369,3 +388,33 @@ describe('isRibComplete (gating « + RIB » + RG-1.13)', () => {
expect(isRibComplete({ label: null, bic: null, iban: null })).toBe(false)
})
})
describe('omitEmptyRequired (ERP-119 : 422 NotBlank au lieu de 400 de type)', () => {
it('retire les cles requises vides (null / vide / undefined)', () => {
const payload = omitEmptyRequired(
{ companyName: null, label: '', iban: undefined, categories: ['/api/categories/1'] },
['companyName', 'label', 'iban'],
)
expect('companyName' in payload).toBe(false)
expect('label' in payload).toBe(false)
expect('iban' in payload).toBe(false)
// Les cles hors liste ne sont jamais touchees.
expect(payload.categories).toEqual(['/api/categories/1'])
})
it('conserve les cles requises renseignees', () => {
const payload = omitEmptyRequired({ companyName: 'ACME', bic: 'BNPAFRPP' }, ['companyName', 'bic'])
expect(payload).toEqual({ companyName: 'ACME', bic: 'BNPAFRPP' })
})
it('ne retire jamais une cle hors de la liste requise, meme vide', () => {
const payload = omitEmptyRequired({ streetComplement: null }, ['street'])
expect('streetComplement' in payload).toBe(true)
expect(payload.streetComplement).toBeNull()
})
it('false / 0 ne sont pas consideres vides (booleens / nombres preserves)', () => {
const payload = omitEmptyRequired({ isDelivery: false, position: 0 }, ['isDelivery', 'position'])
expect(payload).toEqual({ isDelivery: false, position: 0 })
})
})
@@ -0,0 +1,115 @@
import { describe, it, expect } from 'vitest'
import {
buildAccountingPayload,
buildAddressPayload,
buildContactPayload,
buildInformationPayload,
buildMainPayload,
buildRibPayload,
} from '../supplierEdit'
import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/supplierForm'
describe('buildMainPayload (groupe supplier:write:main)', () => {
it('envoie companyName + categories quand renseignes', () => {
expect(buildMainPayload({ companyName: 'ACME', categoryIris: ['/api/categories/1'] })).toEqual({
companyName: 'ACME',
categories: ['/api/categories/1'],
})
})
it('omet companyName vide (-> 422 NotBlank, ERP-119)', () => {
const payload = buildMainPayload({ companyName: null, categoryIris: [] })
expect('companyName' in payload).toBe(false)
expect(payload.categories).toEqual([])
})
})
describe('buildInformationPayload (groupe supplier:write:information)', () => {
const base = {
description: null, competitors: null, foundedAt: null, employeesCount: null,
revenueAmount: null, profitAmount: null, directorName: null, volumeForecast: null,
}
it('convertit employeesCount et volumeForecast en nombre, null si vide', () => {
expect(buildInformationPayload({ ...base, employeesCount: '42', volumeForecast: '1000' })).toMatchObject({
employeesCount: 42,
volumeForecast: 1000,
})
expect(buildInformationPayload(base)).toMatchObject({ employeesCount: null, volumeForecast: null })
})
})
describe('buildContactPayload (sous-ressource supplier_contact)', () => {
it('n\'envoie le 2e telephone que si revele (hasSecondaryPhone)', () => {
const contact = { ...emptyContact(), phonePrimary: '0102030405', phoneSecondary: '0607080910' }
expect(buildContactPayload({ ...contact, hasSecondaryPhone: false }).phoneSecondary).toBeNull()
expect(buildContactPayload({ ...contact, hasSecondaryPhone: true }).phoneSecondary).toBe('0607080910')
})
})
describe('buildAddressPayload (sous-ressource supplier_address — specificites M2)', () => {
it('envoie addressType (enum), bennes (nombre) et triageProvider', () => {
const address = {
...emptyAddress(),
addressType: 'RENDU' as const,
postalCode: '86100', city: 'Châtellerault', street: '1 rue de la Paix',
siteIris: ['/api/sites/1'], categoryIris: ['/api/categories/2'],
bennes: '3', triageProvider: true,
}
expect(buildAddressPayload(address)).toMatchObject({
addressType: 'RENDU',
bennes: 3,
triageProvider: true,
sites: ['/api/sites/1'],
categories: ['/api/categories/2'],
})
})
it('bennes null quand le champ est vide', () => {
expect(buildAddressPayload({ ...emptyAddress(), bennes: '' }).bennes).toBeNull()
})
it('omet postalCode / city / street vides (-> 422 NotBlank, ERP-119)', () => {
const payload = buildAddressPayload({ ...emptyAddress(), addressType: 'PROSPECT' })
expect('postalCode' in payload).toBe(false)
expect('city' in payload).toBe(false)
expect('street' in payload).toBe(false)
// Les champs non requis restent presents.
expect('streetComplement' in payload).toBe(true)
expect(payload.addressType).toBe('PROSPECT')
})
it('omet addressType quand aucun radio n\'est choisi (-> 422 NotBlank au lieu d\'un 400 de type)', () => {
// emptyAddress() laisse addressType a null : la cle doit etre absente du
// payload pour que le back renvoie une 422 propertyPath addressType.
const payload = buildAddressPayload(emptyAddress())
expect('addressType' in payload).toBe(false)
})
it('n\'expose jamais d\'email de facturation (difference M1)', () => {
const payload = buildAddressPayload({ ...emptyAddress(), addressType: 'DEPART' })
expect('billingEmail' in payload).toBe(false)
})
})
describe('buildAccountingPayload (groupe supplier:write:accounting)', () => {
const base = {
siren: '123456789', accountNumber: '00012345678', nTva: 'FR123',
tvaModeIri: '/api/tva_modes/1', paymentDelayIri: '/api/payment_delays/1',
paymentTypeIri: '/api/payment_types/1', bankIri: '/api/banks/1',
}
it('envoie la banque seulement si requise (VIREMENT, RG-2.07)', () => {
expect(buildAccountingPayload(base, true).bank).toBe('/api/banks/1')
expect(buildAccountingPayload(base, false).bank).toBeNull()
})
})
describe('buildRibPayload (sous-ressource supplier_rib)', () => {
it('omet les champs requis vides (-> 422 NotBlank, ERP-119)', () => {
const payload = buildRibPayload({ ...emptyRib(), iban: 'FR1420041010050500013M02606' })
expect('label' in payload).toBe(false)
expect('bic' in payload).toBe(false)
expect(payload.iban).toBe('FR1420041010050500013M02606')
})
})
@@ -0,0 +1,190 @@
import { describe, it, expect } from 'vitest'
import {
buildSupplierFormTabKeys,
hasAtLeastOneValidContact,
isAddressValid,
isBankRequiredForPaymentType,
isBlankRow,
isContactBlank,
isContactNamed,
isRibBlank,
isRibComplete,
isRibRequiredForPaymentType,
lastFillableTabKey,
omitEmptyRequired,
type AddressValidityDraft,
type ContactDraft,
type ContactFillableDraft,
} from '../supplierFormRules'
/** Bloc contact totalement vide (amorce par defaut). */
function blankContact(): ContactFillableDraft {
return {
firstName: null,
lastName: null,
jobTitle: null,
phonePrimary: null,
phoneSecondary: null,
email: null,
}
}
describe('buildSupplierFormTabKeys (gating onglet Comptabilite + onglets edit-only)', () => {
it('inclut l onglet accounting si l utilisateur a accounting.view', () => {
expect(buildSupplierFormTabKeys(true)).toContain('accounting')
})
it('exclut l onglet accounting sinon (Bureau / Commerciale)', () => {
expect(buildSupplierFormTabKeys(false)).not.toContain('accounting')
})
it('a la creation, ordre = information / contacts / addresses / transport (+ accounting si vu)', () => {
expect(buildSupplierFormTabKeys(true)).toEqual(['information', 'contacts', 'addresses', 'transport', 'accounting'])
expect(buildSupplierFormTabKeys(false)).toEqual(['information', 'contacts', 'addresses', 'transport'])
})
it('a la creation, exclut Statistiques / Rapports / Echanges', () => {
const keys = buildSupplierFormTabKeys(true)
expect(keys).not.toContain('statistics')
expect(keys).not.toContain('reports')
expect(keys).not.toContain('exchanges')
})
it('en modification (includeEditOnlyTabs), ajoute les onglets edit-only en fin', () => {
expect(buildSupplierFormTabKeys(true, { includeEditOnlyTabs: true })).toEqual([
'information', 'contacts', 'addresses', 'transport', 'accounting', 'statistics', 'reports', 'exchanges',
])
})
})
describe('lastFillableTabKey (redirection fin d\'ajout, role-aware)', () => {
it('addresses pour un role sans Comptabilite (Bureau / Commerciale)', () => {
expect(lastFillableTabKey(buildSupplierFormTabKeys(false))).toBe('addresses')
})
it('accounting pour un role avec accounting.view (Admin)', () => {
expect(lastFillableTabKey(buildSupplierFormTabKeys(true))).toBe('accounting')
})
it('ignore les onglets placeholder (Transport en dernier ne compte pas)', () => {
expect(lastFillableTabKey(['information', 'contacts', 'addresses', 'transport'])).toBe('addresses')
})
it('undefined si aucun onglet remplissable (que des placeholders)', () => {
expect(lastFillableTabKey(['transport', 'statistics'])).toBeUndefined()
})
})
describe('isContactNamed (RG-2.04)', () => {
it('vrai si le prenom ou le nom est renseigne', () => {
expect(isContactNamed({ firstName: 'Alice', lastName: null })).toBe(true)
expect(isContactNamed({ firstName: null, lastName: 'Martin' })).toBe(true)
})
it('faux si les deux sont vides ou espaces uniquement', () => {
expect(isContactNamed({ firstName: null, lastName: null })).toBe(false)
expect(isContactNamed({ firstName: ' ', lastName: '' })).toBe(false)
})
})
describe('hasAtLeastOneValidContact (RG-2.13)', () => {
it('faux sur une liste vide ou sans contact nomme', () => {
expect(hasAtLeastOneValidContact([])).toBe(false)
const contacts: ContactDraft[] = [{ firstName: null, lastName: null }, { firstName: '', lastName: ' ' }]
expect(hasAtLeastOneValidContact(contacts)).toBe(false)
})
it('vrai des qu un contact a un nom ou un prenom', () => {
expect(hasAtLeastOneValidContact([{ firstName: null, lastName: null }, { firstName: 'Bob', lastName: null }])).toBe(true)
})
})
describe('isBlankRow / isContactBlank / isRibBlank (blocs vides vs partiels)', () => {
it('isBlankRow vrai si toutes les valeurs sont vides', () => {
expect(isBlankRow([null, undefined, '', ' '])).toBe(true)
expect(isBlankRow([null, 'x', ''])).toBe(false)
})
it('isContactBlank faux si un email seul est saisi (bloc a soumettre -> 422 RG-2.04 inline)', () => {
expect(isContactBlank(blankContact())).toBe(true)
expect(isContactBlank({ ...blankContact(), email: 'jean@acme.fr' })).toBe(false)
})
it('isRibBlank faux si un IBAN seul est saisi (bloc a soumettre -> 422 NotBlank inline)', () => {
expect(isRibBlank({ label: null, bic: null, iban: null })).toBe(true)
expect(isRibBlank({ label: null, bic: null, iban: 'FR1420041010050500013M02606' })).toBe(false)
})
})
describe('isRibComplete (gating « + RIB » + RG-2.08)', () => {
it('vrai quand label + BIC + IBAN sont remplis', () => {
expect(isRibComplete({ label: 'Compte courant', bic: 'BNPAFRPP', iban: 'FR1420041010050500013M02606' })).toBe(true)
})
it('faux si un champ manque', () => {
expect(isRibComplete({ label: null, bic: 'BNPAFRPP', iban: 'FR14...' })).toBe(false)
expect(isRibComplete({ label: 'Compte', bic: ' ', iban: 'FR14...' })).toBe(false)
})
})
describe('regles type de reglement (RG-2.07 / RG-2.08)', () => {
it('banque obligatoire si VIREMENT', () => {
expect(isBankRequiredForPaymentType('VIREMENT')).toBe(true)
expect(isBankRequiredForPaymentType('LCR')).toBe(false)
expect(isBankRequiredForPaymentType(null)).toBe(false)
})
it('RIB obligatoire si LCR', () => {
expect(isRibRequiredForPaymentType('LCR')).toBe(true)
expect(isRibRequiredForPaymentType('VIREMENT')).toBe(false)
expect(isRibRequiredForPaymentType(null)).toBe(false)
})
})
describe('isAddressValid (enum addressType, RG-2.06/2.09/2.10 ; pas d\'email facturation)', () => {
function validAddress(): AddressValidityDraft {
return {
addressType: 'DEPART',
categoryIris: ['/api/categories/1'],
siteIris: ['/api/sites/1'],
}
}
it('vrai quand type + >= 1 site + >= 1 categorie', () => {
expect(isAddressValid(validAddress())).toBe(true)
})
it('faux si le type d\'adresse n\'est pas renseigne (amorce vierge)', () => {
expect(isAddressValid({ ...validAddress(), addressType: null })).toBe(false)
})
it('faux si aucun site (RG-2.06)', () => {
expect(isAddressValid({ ...validAddress(), siteIris: [] })).toBe(false)
})
it('faux si aucune categorie (RG-2.10)', () => {
expect(isAddressValid({ ...validAddress(), categoryIris: [] })).toBe(false)
})
it('accepte les trois valeurs d\'enum PROSPECT / DEPART / RENDU', () => {
for (const type of ['PROSPECT', 'DEPART', 'RENDU'] as const) {
expect(isAddressValid({ ...validAddress(), addressType: type })).toBe(true)
}
})
})
describe('omitEmptyRequired (ERP-119 : 422 NotBlank au lieu de 400 de type)', () => {
it('retire les cles requises vides et conserve le reste', () => {
const payload = omitEmptyRequired(
{ companyName: null, sites: ['/api/sites/1'] },
['companyName'],
)
expect('companyName' in payload).toBe(false)
expect(payload.sites).toEqual(['/api/sites/1'])
})
it('false / 0 ne sont pas consideres vides (booleens / nombres preserves)', () => {
const payload = omitEmptyRequired({ triageProvider: false, bennes: 0 }, ['triageProvider', 'bennes'])
expect(payload).toEqual({ triageProvider: false, bennes: 0 })
})
})
@@ -63,9 +63,12 @@ export interface AddressRead extends HydraRef {
street?: string | null
streetComplement?: string | null
billingEmail?: string | null
billingEmailSecondary?: string | null
isProspect?: boolean
isDelivery?: boolean
isBilling?: boolean
isBroker?: boolean
isDistributor?: boolean
sites?: SiteRead[]
categories?: CategoryRead[]
// L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu.
@@ -209,6 +212,8 @@ export function mapAddressToDraft(address: AddressRead): AddressFormDraft {
isProspect: address.isProspect ?? false,
isDelivery: address.isDelivery ?? false,
isBilling: address.isBilling ?? false,
isBroker: address.isBroker ?? false,
isDistributor: address.isDistributor ?? false,
country: address.country ?? 'France',
postalCode: address.postalCode ?? null,
city: address.city ?? null,
@@ -218,6 +223,8 @@ export function mapAddressToDraft(address: AddressRead): AddressFormDraft {
siteIris: (address.sites ?? []).map(s => s['@id']),
contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])),
billingEmail: address.billingEmail ?? null,
billingEmailSecondary: address.billingEmailSecondary ?? null,
hasSecondaryBillingEmail: (address.billingEmailSecondary ?? null) !== null && address.billingEmailSecondary !== '',
}
}
@@ -21,6 +21,12 @@ import {
relationOf,
type ClientDetail,
} from '~/modules/commercial/utils/clientConsultation'
import {
ADDRESS_REQUIRED_NON_NULLABLE_KEYS,
MAIN_REQUIRED_NON_NULLABLE_KEYS,
omitEmptyRequired,
RIB_REQUIRED_NON_NULLABLE_KEYS,
} from '~/modules/commercial/utils/clientFormRules'
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
/**
@@ -139,13 +145,21 @@ export function mapAccountingFormDraft(client: ClientDetail): AccountingFormDraf
* que la FK correspondant au type choisi, l'autre est forcee a null.
*/
export function buildMainPayload(main: MainFormDraft): Record<string, unknown> {
return {
// companyName omis si vide -> 422 NotBlank au lieu d'un 400 de type (ERP-119).
// relationType : champ transitoire (non persiste cote back) qui porte
// l'intention UI « ce client depend d'un distributeur / courtier ». Il sert
// a la validation croisee serveur (RG-1.03 bis) : si une relation est choisie,
// la FK correspondante devient obligatoire -> 422 sur distributor / broker.
// Sans equivalent derivable cote back (FK nullable), c'est la seule facon de
// rester sur « on soumet, le back tranche » plutot qu'une garde front-only.
return omitEmptyRequired({
companyName: main.companyName,
categories: main.categoryIris,
relationType: main.relationType,
distributor: main.relationType === 'distributeur' ? main.distributorIri : null,
broker: main.relationType === 'courtier' ? main.brokerIri : null,
triageService: main.triageService,
}
}, MAIN_REQUIRED_NON_NULLABLE_KEYS)
}
/** Payload de l'onglet Information — groupe client:write:information UNIQUEMENT. */
@@ -198,10 +212,13 @@ export function buildAddressPayload(
address: AddressFormDraft,
isBillingEmailRequired: boolean,
): Record<string, unknown> {
return {
// postalCode / city / street omis si vides -> 422 NotBlank (ERP-119).
return omitEmptyRequired({
isProspect: address.isProspect,
isDelivery: address.isDelivery,
isBilling: address.isBilling,
isBroker: address.isBroker,
isDistributor: address.isDistributor,
country: address.country,
postalCode: address.postalCode || null,
city: address.city || null,
@@ -211,16 +228,19 @@ export function buildAddressPayload(
sites: address.siteIris,
contacts: address.contactIris,
billingEmail: isBillingEmailRequired ? (address.billingEmail || null) : null,
}
billingEmailSecondary: isBillingEmailRequired && address.hasSecondaryBillingEmail ? (address.billingEmailSecondary || null) : null,
}, ADDRESS_REQUIRED_NON_NULLABLE_KEYS)
}
/** Payload d'un RIB (sous-ressource client_rib). */
export function buildRibPayload(rib: RibFormDraft): Record<string, unknown> {
return {
// label / bic / iban omis si vides -> 422 NotBlank au lieu d'un 400 de type
// sur un RIB partiel (ex. IBAN seul). ERP-119.
return omitEmptyRequired({
label: rib.label,
bic: rib.bic,
iban: rib.iban,
}
}, RIB_REQUIRED_NON_NULLABLE_KEYS)
}
// ── Gating par permission ────────────────────────────────────────────────────
@@ -50,6 +50,18 @@ export function buildClientFormTabKeys(
return keys
}
/**
* Dernier onglet REMPLISSABLE d'un jeu d'onglets : le dernier qui n'est pas un
* placeholder (coquille). Role-aware sans regle ad hoc — il suffit de lui passer
* les `tabKeys` deja filtres par permission (l'onglet Comptabilite n'y figure que
* si accounting.view). Sa validation marque la fin de l'ajout (redirection liste).
*/
export function lastFillableTabKey(tabKeys: string[]): string | undefined {
return [...tabKeys].reverse().find(
key => !(CLIENT_FORM_PLACEHOLDER_TABS as readonly string[]).includes(key),
)
}
/**
* Codes de categorie « intermediaire » : un client dont la categorie est
* Distributeur ou Courtier n'a ni relation amont (il EST le distributeur /
@@ -81,6 +93,10 @@ export interface AddressFlagsDraft {
isProspect: boolean
isDelivery: boolean
isBilling: boolean
/** Adresse Courtier — type autonome exclusif (comme isProspect). */
isBroker: boolean
/** Adresse Distributeur — type autonome exclusif (comme isProspect). */
isDistributor: boolean
}
/** Vrai si une chaine porte au moins un caractere non-espace. */
@@ -220,22 +236,30 @@ export function isBillingEmailRequired(flags: AddressFlagsDraft): boolean {
* drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). Les seules
* combinaisons proposees respectent l'exclusivite Prospect (RG-1.06/07/08).
*/
export type AddressType = 'prospect' | 'delivery' | 'billing' | 'delivery_billing'
export type AddressType = 'prospect' | 'delivery' | 'billing' | 'delivery_billing' | 'broker' | 'distributor'
/**
* Mappe le type d'adresse choisi vers les trois drapeaux back.
* Mappe le type d'adresse choisi vers les cinq drapeaux back.
* « Adresse + Facturation » = livraison ET facturation sur la meme adresse.
* Courtier / Distributeur sont autonomes (un seul drapeau, exclusif du reste).
*/
export function addressFlagsFromType(type: AddressType): AddressFlagsDraft {
const none: AddressFlagsDraft = {
isProspect: false, isDelivery: false, isBilling: false, isBroker: false, isDistributor: false,
}
switch (type) {
case 'prospect':
return { isProspect: true, isDelivery: false, isBilling: false }
return { ...none, isProspect: true }
case 'delivery':
return { isProspect: false, isDelivery: true, isBilling: false }
return { ...none, isDelivery: true }
case 'billing':
return { isProspect: false, isDelivery: false, isBilling: true }
return { ...none, isBilling: true }
case 'delivery_billing':
return { isProspect: false, isDelivery: true, isBilling: true }
return { ...none, isDelivery: true, isBilling: true }
case 'broker':
return { ...none, isBroker: true }
case 'distributor':
return { ...none, isDistributor: true }
}
}
@@ -246,6 +270,8 @@ export function addressFlagsFromType(type: AddressType): AddressFlagsDraft {
*/
export function addressTypeFromFlags(flags: AddressFlagsDraft): AddressType | null {
if (flags.isProspect) return 'prospect'
if (flags.isBroker) return 'broker'
if (flags.isDistributor) return 'distributor'
if (flags.isDelivery && flags.isBilling) return 'delivery_billing'
if (flags.isDelivery) return 'delivery'
if (flags.isBilling) return 'billing'
@@ -358,3 +384,38 @@ export function hasAllRequiredAccountingFields(accounting: AccountingRequiredDra
&& filled(accounting.paymentDelayIri)
&& filled(accounting.paymentTypeIri)
}
// ── Champs requis adosses a une colonne NON-nullable (ERP-119) ───────────────
// Ces champs requis (NotBlank back) sont portes par une colonne Doctrine NON
// nullable. Si le front envoie `null` (champ vide, desormais possible : le bouton
// « Valider » n'est plus desactive), API Platform rejette la valeur en 400 de TYPE
// a la deserialisation (« The type of the X attribute must be string, NULL given »)
// AVANT le Validator -> pas de violation, donc pas d'erreur rouge cote champ.
// La parade : OMETTRE la cle du payload quand elle est vide. Sans la cle, la
// propriete garde son defaut null cote entite et #[Assert\NotBlank] se declenche
// normalement -> 422 avec propertyPath, mappee en rouge sous le champ.
// (Les champs requis a colonne NULLABLE — contacts, scalaires compta — acceptent
// deja `null` et renvoient une 422 : inutile de les omettre.)
export const MAIN_REQUIRED_NON_NULLABLE_KEYS = ['companyName'] as const
export const ADDRESS_REQUIRED_NON_NULLABLE_KEYS = ['postalCode', 'city', 'street'] as const
export const RIB_REQUIRED_NON_NULLABLE_KEYS = ['label', 'bic', 'iban'] as const
/**
* Retire d'un payload d'ecriture les cles requises laissees vides (null / ''
* / undefined), pour laisser le back produire une 422 NotBlank par champ plutot
* qu'un 400 de type sur une colonne non-nullable. Mute et retourne le payload.
* A n'appliquer QU'aux cles ci-dessus (champs requis a colonne non-nullable).
*/
export function omitEmptyRequired<T extends Record<string, unknown>>(
payload: T,
requiredKeys: readonly string[],
): T {
for (const key of requiredKeys) {
const value = payload[key]
if (value === null || value === undefined || value === '') {
delete payload[key]
}
}
return payload
}
@@ -0,0 +1,142 @@
/**
* Helpers purs de payload de l'ecran « Ajouter un fournisseur » (M2 Commercial),
* partages avec la future modification (96) — miroir de `clientEdit.ts` (M1).
*
* Scoping STRICT des payloads (mode strict, aligne ERP-74/RG) : chaque onglet
* n'envoie QUE les champs de SON groupe de serialisation, jamais un payload mixte
* (un champ hors-permission = 403 sur l'integralite cote back). Ces helpers ne
* touchent ni a l'API ni a l'etat reactif.
*/
import {
ADDRESS_REQUIRED_NON_NULLABLE_KEYS,
MAIN_REQUIRED_NON_NULLABLE_KEYS,
omitEmptyRequired,
RIB_REQUIRED_NON_NULLABLE_KEYS,
} from '~/modules/commercial/utils/supplierFormRules'
import type {
SupplierAddressFormDraft,
SupplierContactFormDraft,
SupplierRibFormDraft,
} from '~/modules/commercial/types/supplierForm'
/** Etat « plat » du bloc principal (groupe supplier:write:main). */
export interface MainFormDraft {
companyName: string | null
/** IRI des categories rattachees (M2M, type FOURNISSEUR). */
categoryIris: string[]
}
/** Etat « plat » de l'onglet Information (groupe supplier:write:information). */
export interface InformationFormDraft {
description: string | null
competitors: string | null
/** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */
foundedAt: string | null
/** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */
employeesCount: string | null
revenueAmount: string | null
profitAmount: string | null
directorName: string | null
/** Volume previsionnel (entier >= 0, specifique fournisseur) en chaine. */
volumeForecast: string | null
}
/** Etat « plat » de l'onglet Comptabilite (groupe supplier:write:accounting). */
export interface AccountingFormDraft {
siren: string | null
accountNumber: string | null
nTva: string | null
tvaModeIri: string | null
paymentDelayIri: string | null
paymentTypeIri: string | null
bankIri: string | null
}
/**
* Payload du bloc principal — groupe supplier:write:main UNIQUEMENT.
* companyName omis si vide -> 422 NotBlank au lieu d'un 400 de type (ERP-119).
*/
export function buildMainPayload(main: MainFormDraft): Record<string, unknown> {
return omitEmptyRequired({
companyName: main.companyName,
categories: main.categoryIris,
}, MAIN_REQUIRED_NON_NULLABLE_KEYS)
}
/** Payload de l'onglet Information — groupe supplier:write:information UNIQUEMENT. */
export function buildInformationPayload(information: InformationFormDraft): Record<string, unknown> {
return {
description: information.description || null,
competitors: information.competitors || null,
foundedAt: information.foundedAt || null,
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
revenueAmount: information.revenueAmount || null,
profitAmount: information.profitAmount || null,
directorName: information.directorName || null,
volumeForecast: information.volumeForecast ? Number(information.volumeForecast) : null,
}
}
/**
* Payload des scalaires de l'onglet Comptabilite — groupe supplier:write:accounting
* UNIQUEMENT (les RIB passent par la sous-ressource /suppliers/{id}/ribs). La
* banque n'a de sens que pour un Virement (RG-2.07) : forcee a null sinon.
*/
export function buildAccountingPayload(
accounting: AccountingFormDraft,
isBankRequired: boolean,
): Record<string, unknown> {
return {
siren: accounting.siren || null,
accountNumber: accounting.accountNumber || null,
tvaMode: accounting.tvaModeIri,
nTva: accounting.nTva || null,
paymentDelay: accounting.paymentDelayIri,
paymentType: accounting.paymentTypeIri,
bank: isBankRequired ? accounting.bankIri : null,
}
}
/** Payload d'un contact (sous-ressource supplier_contact). */
export function buildContactPayload(contact: SupplierContactFormDraft): Record<string, unknown> {
return {
firstName: contact.firstName || null,
lastName: contact.lastName || null,
jobTitle: contact.jobTitle || null,
phonePrimary: contact.phonePrimary || null,
phoneSecondary: contact.hasSecondaryPhone ? (contact.phoneSecondary || null) : null,
email: contact.email || null,
}
}
/**
* Payload d'une adresse (sous-ressource supplier_address). postalCode / city /
* street omis si vides -> 422 NotBlank (ERP-119). Specifique fournisseur :
* `bennes` (entier, 0 par defaut) + `triageProvider` (booleen). Pas d'email de
* facturation (difference M1).
*/
export function buildAddressPayload(address: SupplierAddressFormDraft): Record<string, unknown> {
return omitEmptyRequired({
addressType: address.addressType,
country: address.country,
postalCode: address.postalCode || null,
city: address.city || null,
street: address.street || null,
streetComplement: address.streetComplement || null,
categories: address.categoryIris,
sites: address.siteIris,
contacts: address.contactIris,
bennes: address.bennes !== null && address.bennes !== '' ? Number(address.bennes) : null,
triageProvider: address.triageProvider,
}, ADDRESS_REQUIRED_NON_NULLABLE_KEYS)
}
/** Payload d'un RIB (sous-ressource supplier_rib). */
export function buildRibPayload(rib: SupplierRibFormDraft): Record<string, unknown> {
return omitEmptyRequired({
label: rib.label,
bic: rib.bic,
iban: rib.iban,
}, RIB_REQUIRED_NON_NULLABLE_KEYS)
}
@@ -0,0 +1,219 @@
/**
* Regles metier pures de l'ecran « Ajouter un fournisseur » (M2 Commercial).
*
* Miroir de `clientFormRules.ts` (M1), centralisees ici (hors composant) pour
* rester testables unitairement et partagees entre la creation et les ecrans
* d'edition/consultation (95/96). Ces helpers ne touchent ni a l'API ni a l'etat
* reactif : ils prennent des brouillons « plats » et retournent des booleens.
*
* Le back reste la source de verite (les RG sont re-validees serveur, mode
* strict) ; ces regles ne servent qu'au feedback UI immediat (gating de boutons).
*
* Differences M2 vs M1 :
* - Adresse via enum `addressType` (PROSPECT/DEPART/RENDU, RG-2.09) — pas de
* drapeaux ni d'exclusivite a gerer cote front (le radio est exclusif par nature).
* - Pas d'email de facturation, pas de relation Distributeur/Courtier.
*/
import type { SupplierAddressType } from '~/modules/commercial/types/supplierForm'
/**
* Onglets « coquille » (non encore implementes) : frame vide, passage
* automatique a l'onglet suivant (aligne M1).
*/
export const SUPPLIER_FORM_PLACEHOLDER_TABS = ['transport', 'statistics', 'reports', 'exchanges'] as const
/**
* Onglets affiches uniquement en MODIFICATION/CONSULTATION (jamais a la
* creation) : Statistiques / Rapports / Echanges. A rebrancher dans les ecrans
* 95/96 via l'option `includeEditOnlyTabs`.
*/
export const SUPPLIER_FORM_EDIT_ONLY_TABS = ['statistics', 'reports', 'exchanges'] as const
/**
* Construit l'ordre des onglets du formulaire fournisseur.
* - L'onglet Comptabilite n'est present que si l'utilisateur a `accounting.view`
* (Bureau / Commerciale ne le voient pas).
* - Les onglets edit-only sont exclus par defaut (creation) ; passer
* `includeEditOnlyTabs: true` pour les afficher en modification/consultation.
* Ordre aligne sur la spec M2 § Ecran « Ajouter un fournisseur » (barre 5 onglets).
*/
export function buildSupplierFormTabKeys(
canAccountingView: boolean,
options: { includeEditOnlyTabs?: boolean } = {},
): string[] {
const keys = ['information', 'contacts', 'addresses', 'transport']
if (canAccountingView) {
keys.push('accounting')
}
if (options.includeEditOnlyTabs) {
keys.push(...SUPPLIER_FORM_EDIT_ONLY_TABS)
}
return keys
}
/**
* Dernier onglet REMPLISSABLE d'un jeu d'onglets : le dernier qui n'est pas un
* placeholder. Role-aware sans regle ad hoc — il suffit de lui passer les
* `tabKeys` deja filtres par permission. Sa validation marque la fin de l'ajout.
*/
export function lastFillableTabKey(tabKeys: string[]): string | undefined {
return [...tabKeys].reverse().find(
key => !(SUPPLIER_FORM_PLACEHOLDER_TABS as readonly string[]).includes(key),
)
}
/** Sous-ensemble d'un contact necessaire aux regles de nommage (RG-2.04/2.13). */
export interface ContactDraft {
firstName: string | null
lastName: string | null
}
/** Vrai si une chaine porte au moins un caractere non-espace. */
function isFilled(value: string | null | undefined): boolean {
return value !== null && value !== undefined && value.trim() !== ''
}
/** RG-2.04 : un contact est valide des qu'il porte un nom OU un prenom. */
export function isContactNamed(contact: ContactDraft): boolean {
return isFilled(contact.firstName) || isFilled(contact.lastName)
}
/**
* RG-2.13 : l'onglet Contacts ne peut etre finalise que s'il reste au moins un
* contact nomme (nom ou prenom).
*/
export function hasAtLeastOneValidContact(contacts: ContactDraft[]): boolean {
return contacts.some(isContactNamed)
}
/**
* Primitive reutilisable : vrai si TOUTES les valeurs fournies sont vides. 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-2.04 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 inline plutot que d'etre saute silencieusement.
*/
export function isRibBlank(rib: RibFillableDraft): boolean {
return isBlankRow([rib.label, rib.bic, rib.iban])
}
/**
* RG-2.08 : un RIB est complet quand ses trois champs sont remplis (label, BIC,
* IBAN). Predicat partage entre le gating du bouton « + RIB » et la validation de
* l'onglet (au moins un RIB complet si reglement LCR).
*/
export function isRibComplete(rib: RibFillableDraft): boolean {
return isFilled(rib.label) && isFilled(rib.bic) && isFilled(rib.iban)
}
/**
* Sous-ensemble d'une adresse necessaire a sa validite par-bloc : type (enum),
* sites et categories rattaches.
*/
export interface AddressValidityDraft {
addressType: SupplierAddressType | null
categoryIris: string[]
siteIris: string[]
}
/**
* Validite par-bloc d'une adresse : type renseigne (RG-2.09), >= 1 site (RG-2.06)
* et >= 1 categorie (RG-2.10). Predicat partage entre le gating du bouton
* « + Adresse » (le dernier bloc doit etre valide avant d'en ajouter un autre) et
* la validation de l'onglet (toutes les adresses valides). Pas d'email de
* facturation cote fournisseur (difference M1).
*/
export function isAddressValid(address: AddressValidityDraft): boolean {
return address.addressType !== null
&& address.siteIris.length >= 1
&& address.categoryIris.length >= 1
}
/** Code stable du type de reglement « virement » (RG-2.07). */
const PAYMENT_TYPE_TRANSFER = 'VIREMENT'
/** Code stable du type de reglement « lettre de change » (RG-2.08). */
const PAYMENT_TYPE_LCR = 'LCR'
/** RG-2.07 : la banque est obligatoire lorsque le type de reglement est un virement. */
export function isBankRequiredForPaymentType(code: string | null | undefined): boolean {
return code === PAYMENT_TYPE_TRANSFER
}
/** RG-2.08 : au moins un RIB complet est obligatoire lorsque le type de reglement est une LCR. */
export function isRibRequiredForPaymentType(code: string | null | undefined): boolean {
return code === PAYMENT_TYPE_LCR
}
// ── Champs requis adosses a une colonne NON-nullable (ERP-119) ───────────────
// Memes contraintes qu'au M1 : un champ requis (NotBlank) porte par une colonne
// Doctrine NON nullable rejette `null` en 400 de TYPE avant le Validator. Parade :
// OMETTRE la cle du payload quand elle est vide -> le back produit une 422 NotBlank
// avec propertyPath, mappee en rouge sous le champ.
export const MAIN_REQUIRED_NON_NULLABLE_KEYS = ['companyName'] as const
// addressType : colonne non-nullable + NotBlank cote back. Envoyer `null` (radio
// non choisi) provoque un 400 de TYPE a la deserialisation AVANT le Validator
// (« must be string, NULL given ») -> pas de violation, pas d'erreur inline. On
// omet donc la cle quand elle est vide pour obtenir une 422 NotBlank propertyPath.
export const ADDRESS_REQUIRED_NON_NULLABLE_KEYS = ['addressType', 'postalCode', 'city', 'street'] as const
export const RIB_REQUIRED_NON_NULLABLE_KEYS = ['label', 'bic', 'iban'] as const
/**
* Retire d'un payload d'ecriture les cles requises laissees vides (null / '' /
* undefined), pour laisser le back produire une 422 NotBlank par champ plutot
* qu'un 400 de type sur une colonne non-nullable. Mute et retourne le payload.
*/
export function omitEmptyRequired<T extends Record<string, unknown>>(
payload: T,
requiredKeys: readonly string[],
): T {
for (const key of requiredKeys) {
const value = payload[key]
if (value === null || value === undefined || value === '') {
delete payload[key]
}
}
return payload
}
+4 -4
View File
@@ -7,7 +7,7 @@
"name": "starseed-frontend",
"hasInstallScript": true,
"dependencies": {
"@malio/layer-ui": "^1.7.7",
"@malio/layer-ui": "^1.7.8",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -1866,9 +1866,9 @@
"license": "MIT"
},
"node_modules/@malio/layer-ui": {
"version": "1.7.7",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.7/layer-ui-1.7.7.tgz",
"integrity": "sha512-MLHDtOzUxcCwIBGWj4FcUMLQTExtGD29uLvpU+IA6qr7gCj9kZ9fGZDu76LXxuJJdfBwzZmenuZioE7Z1qQUUw==",
"version": "1.7.8",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.8/layer-ui-1.7.8.tgz",
"integrity": "sha512-gUMAZzBsPCfQUF3OQSjN/OFzjONvQZYfwqH0u5VUbxaqwBdX1hUGtjD4ym6RvZkyNsKulrxkncFZYTWCS+IdGA==",
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0",
+1 -1
View File
@@ -17,7 +17,7 @@
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@malio/layer-ui": "^1.7.7",
"@malio/layer-ui": "^1.7.8",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
+81
View File
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Commercial — deux nouveaux types d'adresse client : Courtier et Distributeur.
*
* Ajoute les drapeaux `is_broker` / `is_distributor` sur `client_address`, au
* meme titre que `is_prospect` / `is_delivery` / `is_billing`. Ce sont des types
* AUTONOMES (comme la Prospection) : exclusifs de tout autre usage. Deux CHECK
* Postgres miroitent l'exclusivite applicative (validateExclusiveAddressTypes),
* en filet de securite (comme chk_client_address_prospect_exclusive).
*
* NB Postgres : `ADD COLUMN` ajoute en derniere position physique (pas de clause
* AFTER) — l'ordre physique est cosmetique, on adresse par nom. Les colonnes sont
* declarees juste apres isBilling dans l'entite (ERP-119).
*
* Migration au namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) : le
* tri par version garantit son passage apres l'init des tables.
*/
final class Version20260609120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Commercial : types d\'adresse Courtier / Distributeur (is_broker / is_distributor) sur client_address, exclusifs (CHECK).';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE client_address ADD COLUMN is_broker BOOLEAN DEFAULT FALSE NOT NULL');
$this->addSql('ALTER TABLE client_address ADD COLUMN is_distributor BOOLEAN DEFAULT FALSE NOT NULL');
// Exclusivite miroir (filet de securite DBAL) : un type autonome interdit
// tout autre drapeau. Livraison + Facturation restent cumulables entre eux.
$this->addSql(<<<'SQL'
ALTER TABLE client_address
ADD CONSTRAINT chk_client_address_broker_exclusive
CHECK (NOT (is_broker = TRUE AND (is_prospect = TRUE OR is_delivery = TRUE OR is_billing = TRUE OR is_distributor = TRUE)))
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE client_address
ADD CONSTRAINT chk_client_address_distributor_exclusive
CHECK (NOT (is_distributor = TRUE AND (is_prospect = TRUE OR is_delivery = TRUE OR is_billing = TRUE OR is_broker = TRUE)))
SQL);
$this->comment('client_address', 'is_broker', 'Adresse Courtier — type autonome exclusif de tout autre usage (chk_client_address_broker_exclusive). Faux par defaut.');
$this->comment('client_address', 'is_distributor', 'Adresse Distributeur — type autonome exclusif de tout autre usage (chk_client_address_distributor_exclusive). Faux par defaut.');
// Le commentaire de table mentionnait seulement prospect/livraison/facturation :
// on y ajoute les types autonomes Courtier / Distributeur (cf. ColumnCommentsCatalog).
$this->addSql('COMMENT ON TABLE client_address IS $_$Adresses d un client (1:n) — types prospect / livraison / facturation (exclusivites RG-1.06/07/08) + Courtier / Distributeur autonomes (exclusifs de tout autre usage), >= 1 site rattache (RG-1.10).$_$');
}
public function down(Schema $schema): void
{
$this->addSql('COMMENT ON TABLE client_address IS $_$Adresses d un client (1:n) — prospect exclusif de livraison/facturation (RG-1.06/07/08), >= 1 site rattache (RG-1.10).$_$');
$this->addSql('ALTER TABLE client_address DROP CONSTRAINT IF EXISTS chk_client_address_broker_exclusive');
$this->addSql('ALTER TABLE client_address DROP CONSTRAINT IF EXISTS chk_client_address_distributor_exclusive');
$this->addSql('ALTER TABLE client_address DROP COLUMN is_distributor');
$this->addSql('ALTER TABLE client_address DROP COLUMN is_broker');
}
/**
* Emet un `COMMENT ON COLUMN` en dollar-quoting Postgres ($_$...$_$) pour
* eviter tout echappement.
*/
private function comment(string $table, string $column, string $description): void
{
$this->addSql(sprintf(
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
'"'.str_replace('"', '""', $table).'"',
'"'.str_replace('"', '""', $column).'"',
$description,
));
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Commercial — second email de facturation (optionnel) sur une adresse client.
*
* Ajoute `billing_email_secondary` sur `client_address`, pendant du telephone
* secondaire du contact (max 2 emails). Optionnel ; comme l'email principal, il
* n'a de sens que sur une adresse de facturation (validateBillingEmailPresence).
*
* Migration au namespace racine `DoctrineMigrations` (regle ABSOLUE n°11).
*/
final class Version20260609140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Commercial : 2e email de facturation optionnel (billing_email_secondary) sur client_address.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE client_address ADD COLUMN billing_email_secondary VARCHAR(180) DEFAULT NULL');
$this->comment('client_address', 'billing_email_secondary', '2e email de facturation, optionnel (max 2). Interdit hors facturation (validateBillingEmailPresence), normalise en minuscules (RG-1.21).');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE client_address DROP COLUMN billing_email_secondary');
}
/**
* Emet un `COMMENT ON COLUMN` en dollar-quoting Postgres ($_$...$_$) pour
* eviter tout echappement.
*/
private function comment(string $table, string $column, string $description): void
{
$this->addSql(sprintf(
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
'"'.str_replace('"', '""', $table).'"',
'"'.str_replace('"', '""', $column).'"',
$description,
));
}
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Application\Validator;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Commercial\Domain\Entity\Supplier;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Validator metier (spec-front M2 § Onglet Comptabilite) : a la soumission
* complete de l'onglet Comptabilite, les six champs scalaires obligatoires
* doivent etre renseignes (SIREN, Numero de compte, Mode de TVA, N de TVA, Delai
* de reglement, Type de reglement). La banque reste conditionnelle (RG-2.07) et
* les RIB aussi (RG-2.08) : ils ne sont pas couverts ici (Assert\Callback sur
* l'entite Supplier — validatePaymentTypeConsistency).
*
* Parti pris (miroir ClientAccountingCompletenessValidator M1) : colonnes nullable
* en base + validateur contextuel, plutot qu'un Assert\NotBlank sur l'entite (qui
* casserait le POST de l'onglet principal, lequel n'envoie aucun champ comptable).
*
* Invoque par le SupplierProcessor uniquement quand le payload porte les six
* champs (= une validation d'onglet), jamais sur un PATCH ciblant un seul champ.
*
* Leve une ValidationException (HTTP 422) listant chaque champ manquant, par
* coherence avec les violations Symfony rendues par API Platform (mapping inline
* front via useFormErrors, ERP-101).
*/
final class SupplierAccountingCompletenessValidator
{
public function validate(Supplier $supplier): void
{
// Map champ -> valeur courante des champs obligatoires de l'onglet.
$fields = [
'siren' => $supplier->getSiren(),
'accountNumber' => $supplier->getAccountNumber(),
'tvaMode' => $supplier->getTvaMode(),
'nTva' => $supplier->getNTva(),
'paymentDelay' => $supplier->getPaymentDelay(),
'paymentType' => $supplier->getPaymentType(),
];
$violations = new ConstraintViolationList();
foreach ($fields as $property => $value) {
if ($this->isMissing($value)) {
$violations->add(new ConstraintViolation(
'Ce champ est obligatoire.',
null,
[],
$supplier,
$property,
$value,
));
}
}
if (count($violations) > 0) {
throw new ValidationException($violations);
}
}
/**
* Une valeur est manquante si null ou, pour une chaine, vide apres trim. Les
* references (TvaMode / PaymentDelay / PaymentType) ne sont manquantes que
* lorsqu'elles valent null.
*/
private function isMissing(mixed $value): bool
{
if (null === $value) {
return true;
}
return is_string($value) && '' === trim($value);
}
}
@@ -1,82 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Application\Validator;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Commercial\Domain\Entity\Supplier;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Validator metier RG-2.03 (completude Information cote fournisseur) :
* pour un utilisateur portant le role metier Commerciale, TOUS les champs de
* l'onglet Information sont obligatoires sur POST comme sur tout PATCH,
* independamment des champs reellement envoyes.
*
* Invoque par le SupplierProcessor des que l'utilisateur courant porte le role
* Commerciale (detection du role cote back). Pour les autres roles, ces champs
* restent optionnels — le validator n'est pas appele.
*
* NEW vs Client : ajoute le champ `volumeForecast` (volume previsionnel),
* specifique fournisseur.
*
* Leve une ValidationException (HTTP 422) listant chaque champ manquant, chaque
* violation portant son propertyPath (consommable par extractApiViolations,
* ERP-101), par coherence avec les violations Symfony rendues par API Platform.
*/
final class SupplierInformationCompletenessValidator
{
public function validate(Supplier $supplier): void
{
// Map champ -> valeur courante de l'onglet Information.
$fields = [
'description' => $supplier->getDescription(),
'competitors' => $supplier->getCompetitors(),
'foundedAt' => $supplier->getFoundedAt(),
'employeesCount' => $supplier->getEmployeesCount(),
'revenueAmount' => $supplier->getRevenueAmount(),
'directorName' => $supplier->getDirectorName(),
'profitAmount' => $supplier->getProfitAmount(),
'volumeForecast' => $supplier->getVolumeForecast(),
];
$violations = new ConstraintViolationList();
foreach ($fields as $property => $value) {
if ($this->isMissing($value)) {
$violations->add(new ConstraintViolation(
// Pas de nom de champ technique dans le message : la violation est
// deja rattachee au bon champ via son propertyPath (mappe inline
// cote front par useFormErrors).
'Ce champ est obligatoire pour le rôle Commerciale.',
null,
[],
$supplier,
$property,
$value,
));
}
}
if (count($violations) > 0) {
throw new ValidationException($violations);
}
}
/**
* Une valeur est manquante si null ou, pour une chaine, vide apres trim. Les
* zeros numeriques (employeesCount = 0, profitAmount = "0.00",
* volumeForecast = 0) sont des valeurs valides : on ne les considere pas
* manquants.
*/
private function isMissing(mixed $value): bool
{
if (null === $value) {
return true;
}
return is_string($value) && '' === trim($value);
}
}
@@ -25,6 +25,7 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* Client (M1 Commercial) — entite racine du repertoire clients. Porte le
@@ -171,6 +172,17 @@ class Client implements TimestampableInterface, BlamableInterface
#[Groups(['client:read', 'client:write:main'])]
private bool $triageService = false;
// Champ transitoire (NON persiste : aucune colonne ORM) portant l'intention UI
// « ce client depend d'un distributeur / courtier ». Write-only (groupe
// d'ecriture main uniquement, pas de groupe de lecture -> jamais serialise en
// sortie). Sert exclusivement a la validation croisee validateRelationName :
// si une relation est choisie, la FK correspondante (distributor / broker)
// devient obligatoire. Non mappe ORM -> non audite, et toujours null une fois
// l'entite rechargee depuis la base (ne sert qu'au cycle d'une ecriture).
#[Assert\Choice(choices: ['distributeur', 'courtier'], message: 'Le type de relation est invalide.')]
#[Groups(['client:write:main'])]
private ?string $relationType = null;
// RG : au moins une categorie (Count min 1). M2M vers Category via le contrat
// CategoryInterface (resolve_target_entities -> Category).
/** @var Collection<int, CategoryInterface> */
@@ -333,6 +345,45 @@ class Client implements TimestampableInterface, BlamableInterface
return $this;
}
public function getRelationType(): ?string
{
return $this->relationType;
}
public function setRelationType(?string $relationType): static
{
$this->relationType = $relationType;
return $this;
}
/**
* RG-1.03 bis : si l'utilisateur declare une relation (« depend d'un
* distributeur / courtier » via le champ transitoire relationType), la FK
* correspondante est obligatoire. Le back ne peut pas deviner cette intention
* a partir des seules FK nullable (distributor=null ne distingue pas « pas de
* relation » de « relation choisie sans nom »), d'ou relationType qui la porte.
* Violation portee sur distributor / broker (champ fautif cote formulaire), de
* sorte que useFormErrors la mappe inline sous le bon select (ERP-101).
*/
#[Assert\Callback]
public function validateRelationName(ExecutionContextInterface $context): void
{
if ('distributeur' === $this->relationType && null === $this->distributor) {
$context->buildViolation('Le nom du distributeur est obligatoire.')
->atPath('distributor')
->addViolation()
;
}
if ('courtier' === $this->relationType && null === $this->broker) {
$context->buildViolation('Le nom du courtier est obligatoire.')
->atPath('broker')
->addViolation()
;
}
}
public function isTriageService(): bool
{
return $this->triageService;
@@ -129,6 +129,18 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
#[Groups(['client_address:write'])]
private bool $isBilling = false;
// Adresse Courtier / Distributeur : types autonomes (comme Prospection),
// exclusifs de tout autre usage (validateExclusiveAddressTypes + CHECK BDD
// chk_client_address_broker_exclusive / chk_client_address_distributor_exclusive).
// Lecture portee par le getter + SerializedName (meme pattern que isProspect).
#[ORM\Column(name: 'is_broker', options: ['default' => false])]
#[Groups(['client_address:write'])]
private bool $isBroker = false;
#[ORM\Column(name: 'is_distributor', options: ['default' => false])]
#[Groups(['client_address:write'])]
private bool $isDistributor = false;
#[ORM\Column(length: 80, options: ['default' => 'France'])]
#[Assert\Length(max: 80, maxMessage: 'Le pays ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client_address:read', 'client_address:write'])]
@@ -166,6 +178,15 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
#[Groups(['client_address:read', 'client_address:write'])]
private ?string $billingEmail = null;
// 2e email de facturation, optionnel (max 2 — pendant du telephone secondaire).
// Comme le principal : interdit hors facturation (validateBillingEmailPresence),
// mais jamais obligatoire. Normalise en lowercase par le ClientAddressProcessor.
#[ORM\Column(length: 180, nullable: true)]
#[Assert\Email(message: 'L\'email de facturation secondaire n\'est pas valide.')]
#[Assert\Length(max: 180, maxMessage: 'L\'email de facturation secondaire ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client_address:read', 'client_address:write'])]
private ?string $billingEmailSecondary = null;
#[ORM\Column(options: ['default' => 0])]
#[Groups(['client_address:read', 'client_address:write'])]
private int $position = 0;
@@ -223,6 +244,48 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
}
}
/**
* Au moins un type d'adresse est obligatoire (Prospection, Livraison ou
* Facturation) : une adresse sans aucun drapeau pose n'a pas de sens metier.
* La violation est portee sur `isProspect` (meme champ que l'exclusivite) pour
* un mapping inline sous le select « Type d'adresse » cote front (ERP-119).
*/
#[Assert\Callback]
public function validateAddressTypeRequired(ExecutionContextInterface $context): void
{
if (!$this->isProspect && !$this->isDelivery && !$this->isBilling && !$this->isBroker && !$this->isDistributor) {
$context->buildViolation('Le type d\'adresse est obligatoire.')
->atPath('isProspect')
->addViolation()
;
}
}
/**
* Courtier et Distributeur sont des types d'adresse AUTONOMES (comme la
* Prospection) : exclusifs de tout autre usage (Livraison / Facturation /
* Prospection / l'autre type autonome). Mirror applicatif (422) des CHECK
* chk_client_address_broker_exclusive / chk_client_address_distributor_exclusive.
* Violation portee sur `isProspect` (mappee sous le select « Type d'adresse »).
*/
#[Assert\Callback]
public function validateExclusiveAddressTypes(ExecutionContextInterface $context): void
{
if ($this->isBroker && ($this->isProspect || $this->isDelivery || $this->isBilling || $this->isDistributor)) {
$context->buildViolation('Une adresse Courtier ne peut pas avoir d\'autre type.')
->atPath('isProspect')
->addViolation()
;
}
if ($this->isDistributor && ($this->isProspect || $this->isDelivery || $this->isBilling || $this->isBroker)) {
$context->buildViolation('Une adresse Distributeur ne peut pas avoir d\'autre type.')
->atPath('isProspect')
->addViolation()
;
}
}
/**
* RG-1.11 : l'email de facturation est obligatoire si l'adresse est de
* facturation, et interdit sinon. Mirror applicatif (422) du CHECK
@@ -254,6 +317,16 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
->addViolation()
;
}
// Le 2e email est OPTIONNEL (jamais requis), mais comme le principal il
// n'a de sens que sur une adresse de facturation.
$hasSecondaryEmail = null !== $this->billingEmailSecondary && '' !== trim($this->billingEmailSecondary);
if (!$this->isBilling && $hasSecondaryEmail) {
$context->buildViolation('L\'email de facturation n\'est autorisé que sur une adresse de facturation.')
->atPath('billingEmailSecondary')
->addViolation()
;
}
}
/**
@@ -343,6 +416,34 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
return $this;
}
#[Groups(['client_address:read'])]
#[SerializedName('isBroker')]
public function isBroker(): bool
{
return $this->isBroker;
}
public function setIsBroker(bool $isBroker): static
{
$this->isBroker = $isBroker;
return $this;
}
#[Groups(['client_address:read'])]
#[SerializedName('isDistributor')]
public function isDistributor(): bool
{
return $this->isDistributor;
}
public function setIsDistributor(bool $isDistributor): static
{
$this->isDistributor = $isDistributor;
return $this;
}
public function getCountry(): string
{
return $this->country;
@@ -415,6 +516,18 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
return $this;
}
public function getBillingEmailSecondary(): ?string
{
return $this->billingEmailSecondary;
}
public function setBillingEmailSecondary(?string $billingEmailSecondary): static
{
$this->billingEmailSecondary = $billingEmailSecondary;
return $this;
}
public function getPosition(): int
{
return $this->position;
@@ -31,8 +31,8 @@ use Symfony\Component\Validator\Constraints as Assert;
* comptable et la conformite, cf. spec § 2.5 / § 6.1).
*
* Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony au M1
* (HP-M2-14 : pas de controle externe banque reelle). Timestampable/Blamable
* standard.
* (HP-M2-14 : pas de controle externe banque reelle), avec controle croise pays
* BIC/IBAN (ibanPropertyPath). Timestampable/Blamable standard.
*
* Sous-ressource API (ERP-57, spec § 4.5) — gating comptable renforce :
* - POST /api/clients/{clientId}/ribs : creation rattachee au client parent
@@ -109,9 +109,15 @@ class ClientRib implements TimestampableInterface, BlamableInterface
// Bic/Iban bornent deja le format (et donc la longueur) : pas de Length
// redondant calee sur la colonne (whitelist du garde-fou ERP-107).
// ibanPropertyPath : controle croise — le pays du BIC (positions 5-6) doit
// correspondre au pays de l'IBAN (positions 1-2). Violation portee sur `bic`.
#[ORM\Column(length: 20)]
#[Assert\NotBlank(message: 'Le BIC est obligatoire.', normalizer: 'trim')]
#[Assert\Bic(message: 'Le BIC n\'est pas valide.')]
#[Assert\Bic(
message: 'Le BIC n\'est pas valide.',
ibanPropertyPath: 'iban',
ibanMessage: 'Le BIC ne correspond pas au pays de l\'IBAN.',
)]
#[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])]
private ?string $bic = null;
@@ -328,8 +328,11 @@ class Supplier implements TimestampableInterface, BlamableInterface
* chaque 422 porte un propertyPath exploitable par extractApiViolations
* (mapping inline sous le champ, pas un toast — convention ERP-101).
* - RG-2.07 : paymentType = VIREMENT impose une banque -> violation sur `bank`.
* - RG-2.08 : paymentType = LCR impose au moins un RIB -> violation sur `ribs`
* (le 409 sur DELETE du dernier RIB en LCR est porte par ERP-88).
* - RG-2.08 : paymentType = LCR impose au moins un RIB -> violation sur
* `paymentType` (miroir client : `ribs` n'a pas de champ de formulaire ou
* s'ancrer quand la liste est vide ; l'erreur s'affiche donc sous le select
* « Type de règlement », bindé cote front). Le 409 sur DELETE du dernier RIB
* en LCR est porte par ERP-88.
*
* Ces champs vivant dans le groupe d'ecriture comptable (absent du POST, qui
* n'expose que supplier:write:main), la contrainte ne mord en pratique que
@@ -349,7 +352,7 @@ class Supplier implements TimestampableInterface, BlamableInterface
if (self::PAYMENT_TYPE_LCR === $paymentCode && $this->ribs->isEmpty()) {
$context->buildViolation('Au moins un RIB est obligatoire pour le type de règlement LCR.')
->atPath('ribs')
->atPath('paymentType')
->addViolation()
;
}
@@ -199,12 +199,14 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
private Collection $contacts;
// RG-2.10 : categories d'adresse de type FOURNISSEUR (controle au Processor).
// RG-2.10 : au moins une categorie de type FOURNISSEUR par adresse (le type est
// controle par validateCategoryType ; le minimum par Assert\Count, miroir sites).
/** @var Collection<int, CategoryInterface> */
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
#[ORM\JoinTable(name: 'supplier_address_category')]
#[ORM\JoinColumn(name: 'supplier_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
#[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')]
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
private Collection $categories;
@@ -44,7 +44,8 @@ use Symfony\Component\Validator\Constraints as Assert;
* Tout passe par le SupplierRibProcessor (RG-2.08 sur DELETE).
*
* Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony (pas de controle
* banque reelle). Audite (#[Auditable]) + Timestampable / Blamable.
* banque reelle), avec controle croise pays BIC/IBAN (ibanPropertyPath). Audite
* (#[Auditable]) + Timestampable / Blamable.
*/
#[ApiResource(
operations: [
@@ -105,9 +106,15 @@ class SupplierRib implements TimestampableInterface, BlamableInterface
// Bic/Iban bornent deja le format (et donc la longueur) : pas de Length
// redondant calee sur la colonne (auto-exempte du miroir ERP-107).
// ibanPropertyPath : controle croise — le pays du BIC (positions 5-6) doit
// correspondre au pays de l'IBAN (positions 1-2). Violation portee sur `bic`.
#[ORM\Column(length: 20)]
#[Assert\NotBlank(message: 'Le BIC est obligatoire.', normalizer: 'trim')]
#[Assert\Bic(message: 'Le BIC n\'est pas valide.')]
#[Assert\Bic(
message: 'Le BIC n\'est pas valide.',
ibanPropertyPath: 'iban',
ibanMessage: 'Le BIC ne correspond pas au pays de l\'IBAN.',
)]
#[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
private ?string $bic = null;
@@ -94,5 +94,6 @@ final class ClientAddressProcessor implements ProcessorInterface
private function normalize(ClientAddress $address): void
{
$address->setBillingEmail($this->normalizer->normalizeEmail($address->getBillingEmail()));
$address->setBillingEmailSecondary($this->normalizer->normalizeEmail($address->getBillingEmailSecondary()));
}
}
@@ -7,10 +7,8 @@ namespace App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Commercial\Application\Service\SupplierFieldNormalizer;
use App\Module\Commercial\Application\Validator\SupplierInformationCompletenessValidator;
use App\Module\Commercial\Application\Validator\SupplierAccountingCompletenessValidator;
use App\Module\Commercial\Domain\Entity\Supplier;
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
use App\Shared\Domain\Security\BusinessRoles;
use DateTimeImmutable;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
@@ -43,19 +41,17 @@ use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
* collisions d'unicite en 409 (RG-2.11 doublon de nom ; RG-2.15 conflit de
* restauration).
*
* Validators metier (ERP-89). Decision figee : ce processor ne porte QUE
* RG-2.03 (completude Information exigee pour le role Commerciale — detection du
* role cote back, non exprimable en contrainte d'entite). Les RG inter-champs
* RG-2.07 (Virement -> banque), RG-2.08 (LCR -> >= 1 RIB) et RG-2.10 (categorie
* de type FOURNISSEUR) sont portees par des Assert\Callback + ->atPath() sur
* l'entite Supplier (jouees par API Platform AVANT ce processor), pour que
* chaque 422 porte un propertyPath consommable par extractApiViolations
* (mapping inline, pas un toast — convention ERP-101).
* Validators metier (ERP-89). Ce processor porte la completude Comptabilite : a
* la validation complete de l'onglet (les six scalaires obligatoires presents
* dans le payload), chacun doit etre renseigne. (RG-2.03 « Information obligatoire
* pour la Commerciale » a ete retiree, miroir client M1 — l'onglet Information est
* desormais entierement facultatif, quel que soit le role.)
*
* Note : la validation Symfony (Assert\NotBlank, Assert\Count sur categories,
* les Callback RG-2.07/2.08/2.10...) est jouee par API Platform AVANT ce
* processor ; on n'y traite donc que les regles non exprimables en simples
* contraintes d'entite (RG-2.03, qui depend du role de l'utilisateur courant).
* Les RG inter-champs RG-2.07 (Virement -> banque), RG-2.08 (LCR -> >= 1 RIB) et
* RG-2.10 (categorie de type FOURNISSEUR) sont portees par des Assert\Callback +
* ->atPath() sur l'entite Supplier (jouees par API Platform AVANT ce processor),
* pour que chaque 422 porte un propertyPath consommable par extractApiViolations
* (mapping inline, pas un toast — convention ERP-101).
*
* @implements ProcessorInterface<Supplier, Supplier>
*/
@@ -78,6 +74,14 @@ final class SupplierProcessor implements ProcessorInterface
'paymentType', 'bank',
];
/**
* Champs comptables obligatoires a la validation complete de l'onglet
* (spec-front M2 § Onglet Comptabilite). bank est exclu : conditionnel (RG-2.07).
*/
private const array ACCOUNTING_REQUIRED_FIELDS = [
'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay', 'paymentType',
];
/** Champ d'archivage (groupe supplier:write:archive). */
private const string ARCHIVE_FIELD = 'isArchived';
@@ -102,7 +106,7 @@ final class SupplierProcessor implements ProcessorInterface
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
private readonly SupplierFieldNormalizer $normalizer,
private readonly SupplierInformationCompletenessValidator $informationValidator,
private readonly SupplierAccountingCompletenessValidator $accountingValidator,
private readonly Security $security,
private readonly RequestStack $requestStack,
private readonly EntityManagerInterface $em,
@@ -132,7 +136,7 @@ final class SupplierProcessor implements ProcessorInterface
// normalisees des deux cotes (l'etat persiste l'a deja ete).
$this->guardManage($data);
$this->validateInformationCompleteness($data);
$this->validateAccountingCompleteness($data);
try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
@@ -262,35 +266,23 @@ final class SupplierProcessor implements ProcessorInterface
}
/**
* RG-2.03 : si l'utilisateur porte le role metier Commerciale, TOUS les
* champs de l'onglet Information sont obligatoires sur POST comme sur TOUT
* PATCH — independamment des champs reellement envoyes. Garantit qu'un
* fournisseur cree/edite par une Commerciale ne reste jamais avec un onglet
* Information incomplet. Pour les autres roles, ces champs restent optionnels.
*
* Consequence (cf. spec § 7, miroir RG-1.04) : le POST n'exposant que
* supplier:write:main, une Commerciale obtient 422 sur tout POST tant que
* l'Information n'est pas complete -> la completude se fait via les PATCH
* supplier:write:information.
* spec-front M2 § Onglet Comptabilite : a la validation COMPLETE de l'onglet
* (les six champs obligatoires presents dans le payload — le front les envoie
* toujours ensemble), chacun doit etre renseigne, sinon 422 par champ. On ne
* declenche pas sur un PATCH ciblant un sous-ensemble de champs comptables :
* ce n'est pas une validation d'onglet (edition ponctuelle preservee). bank /
* RIB restent geres par validatePaymentTypeConsistency sur l'entite (RG-2.07 /
* RG-2.08). Miroir du ClientProcessor (M1).
*/
private function validateInformationCompleteness(Supplier $data): void
private function validateAccountingCompleteness(Supplier $data): void
{
if ($this->currentUserIsCommerciale()) {
$this->informationValidator->validate($data);
// Declenche uniquement si TOUS les champs requis sont presents dans le
// payload (= soumission d'onglet, pas un PATCH partiel cible).
if ([] !== array_diff(self::ACCOUNTING_REQUIRED_FIELDS, $this->payloadKeys())) {
return;
}
}
/**
* Detection du role metier Commerciale cote back (jamais front), via le
* contrat BusinessRoleAwareInterface (pas d'import de User — regle ABSOLUE
* n°1). Identique au ClientProcessor (M1).
*/
private function currentUserIsCommerciale(): bool
{
$user = $this->security->getUser();
return $user instanceof BusinessRoleAwareInterface
&& $user->hasBusinessRole(BusinessRoles::COMMERCIALE);
$this->accountingValidator->validate($data);
}
/**
@@ -219,19 +219,22 @@ final class ColumnCommentsCatalog
] + self::timestampableBlamableComments(),
'client_address' => [
'_table' => 'Adresses d un client (1:n) — prospect exclusif de livraison/facturation (RG-1.06/07/08), >= 1 site rattache (RG-1.10).',
'id' => 'Identifiant interne auto-incremente.',
'client_id' => 'FK -> client.id, ON DELETE CASCADE — client proprietaire de l adresse.',
'is_prospect' => 'Adresse de prospection — exclusive de is_delivery/is_billing (RG-1.06/07/08, chk_client_address_prospect_exclusive). Faux par defaut.',
'is_delivery' => 'Adresse de livraison. Exclusive de is_prospect. Faux par defaut.',
'is_billing' => 'Adresse de facturation. Exclusive de is_prospect. Impose billing_email (RG-1.11). Faux par defaut.',
'country' => 'Pays de l adresse — defaut France.',
'postal_code' => 'Code postal (4-5 chiffres attendus, RG-1.09).',
'city' => 'Ville — preremplie depuis le code postal via API BAN cote front (RG-1.09).',
'street' => 'Numero et voie de l adresse.',
'street_complement' => 'Complement d adresse (etage, batiment...) — optionnel.',
'billing_email' => 'Email de facturation — obligatoire si is_billing, null sinon (RG-1.11, chk_client_address_billing_email).',
'position' => 'Ordre d affichage de l adresse dans la liste du client (croissant).',
'_table' => 'Adresses d un client (1:n) — types prospect / livraison / facturation (exclusivites RG-1.06/07/08) + Courtier / Distributeur autonomes (exclusifs de tout autre usage), >= 1 site rattache (RG-1.10).',
'id' => 'Identifiant interne auto-incremente.',
'client_id' => 'FK -> client.id, ON DELETE CASCADE — client proprietaire de l adresse.',
'is_prospect' => 'Adresse de prospection — exclusive de is_delivery/is_billing (RG-1.06/07/08, chk_client_address_prospect_exclusive). Faux par defaut.',
'is_delivery' => 'Adresse de livraison. Exclusive de is_prospect. Faux par defaut.',
'is_billing' => 'Adresse de facturation. Exclusive de is_prospect. Impose billing_email (RG-1.11). Faux par defaut.',
'is_broker' => 'Adresse Courtier — type autonome exclusif de tout autre usage (chk_client_address_broker_exclusive). Faux par defaut.',
'is_distributor' => 'Adresse Distributeur — type autonome exclusif de tout autre usage (chk_client_address_distributor_exclusive). Faux par defaut.',
'country' => 'Pays de l adresse — defaut France.',
'postal_code' => 'Code postal (4-5 chiffres attendus, RG-1.09).',
'city' => 'Ville — preremplie depuis le code postal via API BAN cote front (RG-1.09).',
'street' => 'Numero et voie de l adresse.',
'street_complement' => 'Complement d adresse (etage, batiment...) — optionnel.',
'billing_email' => 'Email de facturation — obligatoire si is_billing, null sinon (RG-1.11, chk_client_address_billing_email).',
'billing_email_secondary' => '2e email de facturation, optionnel (max 2). Interdit hors facturation, normalise en minuscules (RG-1.21).',
'position' => 'Ordre d affichage de l adresse dans la liste du client (croissant).',
] + self::timestampableBlamableComments(),
'client_address_site' => [
@@ -137,6 +137,27 @@ abstract class AbstractCommercialApiTestCase extends AbstractApiTestCase
return $client;
}
/**
* Indexe les violations d'un corps de reponse 422 par propertyPath. Permet
* d'asserter qu'un 422 porte bien sur le champ attendu (et n'est pas un 422
* orthogonal) : un test qui se contente du code 422 passerait meme si la RG
* visee etait cassee pour une autre raison. Mutualise ici (et non dans la
* sous-classe Supplier) pour etre accessible a tous les tests Commercial.
*
* @param array<string, mixed> $body corps decode de la reponse (toArray(false))
*
* @return array<string, string> propertyPath => message
*/
protected function violationsByPath(array $body): array
{
$byPath = [];
foreach ($body['violations'] ?? [] as $v) {
$byPath[$v['propertyPath']] = $v['message'];
}
return $byPath;
}
private function cleanupCommercialTestData(): void
{
$em = $this->getEm();
@@ -51,6 +51,9 @@ abstract class AbstractSupplierApiTestCase extends AbstractCommercialApiTestCase
/** IBAN/BIC valides (Assert\Iban / Assert\Bic) reutilises par les seeds. */
protected const string VALID_IBAN = 'FR1420041010050500013M02606';
protected const string VALID_BIC = 'BNPAFRPPXXX';
// BIC allemand valide isolement (pays DE en positions 5-6) : sert au controle
// croise pays BIC/IBAN (DE vs IBAN FR -> mismatch, cf. Assert\Bic ibanPropertyPath).
protected const string FOREIGN_BIC = 'DEUTDEFFXXX';
protected function tearDown(): void
{
@@ -149,7 +152,8 @@ abstract class AbstractSupplierApiTestCase extends AbstractCommercialApiTestCase
$supplier->setCompanyName(mb_strtoupper($companyName.' '.$suffix, 'UTF-8'));
$supplier->addCategory($this->supplierCategory('NEGOCIANT'));
// Onglet Information complet (RG-2.03 : exige pour la Commerciale).
// Onglet Information complet : donnees de reference pour les tests de
// lecture / serialisation / comptabilite (l'Information est facultative).
$supplier->setDescription('Fournisseur de test complet.');
$supplier->setCompetitors('Concurrent A, Concurrent B');
$supplier->setFoundedAt(new DateTimeImmutable('2008-04-01'));
@@ -316,24 +320,4 @@ abstract class AbstractSupplierApiTestCase extends AbstractCommercialApiTestCase
return $entity;
}
/**
* Indexe les violations d'un corps de reponse 422 par propertyPath. Permet
* d'asserter qu'un 422 porte bien sur le champ attendu (et n'est pas un 422
* orthogonal) : un test qui se contente du code 422 passerait meme si la RG
* visee etait cassee pour une autre raison.
*
* @param array<string, mixed> $body corps decode de la reponse (toArray(false))
*
* @return array<string, string> propertyPath => message
*/
protected function violationsByPath(array $body): array
{
$byPath = [];
foreach ($body['violations'] ?? [] as $v) {
$byPath[$v['propertyPath']] = $v['message'];
}
return $byPath;
}
}
@@ -146,6 +146,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isDelivery' => true,
'isBilling' => false,
'billingEmail' => 'parasite@test.fr',
'postalCode' => '86100',
@@ -174,6 +175,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isDelivery' => true,
'isBilling' => false,
'billingEmail' => '',
'postalCode' => '86100',
@@ -187,6 +189,62 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
self::assertResponseStatusCodeSame(201);
}
/**
* ERP-119 : une adresse de facturation accepte un 2e email (optionnel, max 2).
*/
public function testBillingAddressAcceptsTwoEmails(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Billing Two Emails');
$category = $this->createCategory('SECTEUR');
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isBilling' => true,
'billingEmail' => 'facturation@test.fr',
'billingEmailSecondary' => 'compta@test.fr',
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
'categories' => ['/api/categories/'.$category->getId()],
],
]);
self::assertResponseStatusCodeSame(201);
}
/**
* ERP-119 : le 2e email de facturation, comme le principal, n'est autorise que
* sur une adresse de facturation -> 422 avec violation sur billingEmailSecondary.
*/
public function testSecondaryBillingEmailRejectedOnNonBillingAddress(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Secondary Email Non Billing');
$category = $this->createCategory('SECTEUR');
$body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'isDelivery' => true,
'billingEmailSecondary' => 'compta@test.fr',
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
'categories' => ['/api/categories/'.$category->getId()],
],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
$byPath = $this->violationsByPath($body);
self::assertArrayHasKey('billingEmailSecondary', $byPath);
}
/**
* RG-1.29 : poster une categorie de type DISTRIBUTEUR sur une adresse -> 422
* avec violation sur le champ `categories`.
@@ -201,6 +259,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isDelivery' => true,
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
@@ -229,6 +288,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isDelivery' => true,
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
@@ -253,6 +313,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isDelivery' => true,
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
@@ -277,6 +338,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isDelivery' => true,
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
@@ -301,6 +363,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isDelivery' => true,
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
@@ -311,6 +374,115 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
self::assertResponseStatusCodeSame(422);
}
/**
* RG (ERP-119) : au moins un type d'adresse (Prospection / Livraison /
* Facturation) est obligatoire. POST sans aucun drapeau de type -> 422, avec
* une violation portee sur `isProspect` (mappee sous le select « Type
* d'adresse » cote front via ClientAddressBlock).
*/
public function testAddressRequiresAtLeastOneType(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Address No Type');
$category = $this->createCategory('SECTEUR');
$body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
'categories' => ['/api/categories/'.$category->getId()],
],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
$byPath = $this->violationsByPath($body);
self::assertArrayHasKey('isProspect', $byPath);
self::assertSame('Le type d\'adresse est obligatoire.', $byPath['isProspect']);
}
/**
* Nouveaux types d'adresse (ERP-119) : Courtier et Distributeur sont acceptes
* comme types autonomes (avec site + categorie). is_broker / is_distributor.
*/
public function testBrokerAddressAccepted(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Address Broker Type');
$category = $this->createCategory('SECTEUR');
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isBroker' => true,
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
'categories' => ['/api/categories/'.$category->getId()],
],
]);
self::assertResponseStatusCodeSame(201);
}
public function testDistributorAddressAccepted(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Address Distributor Type');
$category = $this->createCategory('SECTEUR');
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isDistributor' => true,
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
'categories' => ['/api/categories/'.$category->getId()],
],
]);
self::assertResponseStatusCodeSame(201);
}
/**
* Courtier / Distributeur sont des types AUTONOMES exclusifs : les combiner avec
* un autre usage (ici Livraison) -> 422, violation sur isProspect (mappee sous le
* select Type d'adresse). Miroir applicatif du CHECK chk_client_address_broker_exclusive.
*/
public function testExclusiveAddressTypeRejected(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Address Broker Mix');
$category = $this->createCategory('SECTEUR');
$body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'isBroker' => true,
'isDelivery' => true,
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
'categories' => ['/api/categories/'.$category->getId()],
],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
$byPath = $this->violationsByPath($body);
self::assertArrayHasKey('isProspect', $byPath);
self::assertSame('Une adresse Courtier ne peut pas avoir d\'autre type.', $byPath['isProspect']);
}
/**
* Retourne l'IRI du premier site seede (fixtures Sites).
*/
@@ -85,4 +85,77 @@ final class ClientFormulaireMainTest extends AbstractCommercialApiTestCase
self::assertNotNull($persisted);
self::assertSame('LEGACY FIELDS SARL', $persisted->getCompanyName());
}
/**
* RG-1.03 bis : declarer une relation « depend d'un distributeur »
* (relationType, champ transitoire) sans renseigner la FK distributor doit
* produire une 422 portee sur `distributor`. Le back ne peut pas deviner
* l'intention depuis la seule FK nullable (distributor=null = client
* independant), d'ou relationType qui la transporte.
*/
public function testRelationDistributeurSansDistributeurEst422(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$body = $client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'Relation Sans Distrib SARL',
'categories' => ['/api/categories/'.$cat->getId()],
'relationType' => 'distributeur',
],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
$byPath = $this->violationsByPath($body);
self::assertArrayHasKey('distributor', $byPath);
self::assertSame('Le nom du distributeur est obligatoire.', $byPath['distributor']);
}
/** Idem courtier : relationType=courtier sans broker -> 422 portee sur `broker`. */
public function testRelationCourtierSansCourtierEst422(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$body = $client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'Relation Sans Courtier SARL',
'categories' => ['/api/categories/'.$cat->getId()],
'relationType' => 'courtier',
],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
$byPath = $this->violationsByPath($body);
self::assertArrayHasKey('broker', $byPath);
self::assertSame('Le nom du courtier est obligatoire.', $byPath['broker']);
}
/**
* Le champ transitoire relationType ne casse pas la creation nominale : avec
* la FK correspondante renseignee, le client se cree (201) et relationType
* n'est jamais serialise en sortie (write-only, aucun groupe de lecture).
*/
public function testRelationDistributeurAvecDistributeurEst201(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$distributor = $this->seedClient('Distrib Cible', false, 'DISTRIBUTEUR');
$data = $client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'Relation Ok SARL',
'categories' => ['/api/categories/'.$cat->getId()],
'relationType' => 'distributeur',
'distributor' => '/api/clients/'.$distributor->getId(),
],
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertArrayNotHasKey('relationType', $data);
}
}
@@ -59,12 +59,18 @@ final class ClientSerializationContractTest extends AbstractCommercialApiTestCas
self::assertArrayHasKey('isProspect', $address);
self::assertArrayHasKey('isDelivery', $address);
self::assertArrayHasKey('isBilling', $address);
// Memes garanties pour les types Courtier / Distributeur (ERP-119, meme
// pattern getter + SerializedName).
self::assertArrayHasKey('isBroker', $address);
self::assertArrayHasKey('isDistributor', $address);
// L'adresse seedee est livraison + facturation (prospect exclusif, RG-1.06).
// Prouve qu'un booleen `true` est bien serialise (le bug masquait meme les true).
self::assertFalse($address['isProspect']);
self::assertTrue($address['isDelivery']);
self::assertTrue($address['isBilling']);
self::assertFalse($address['isBroker']);
self::assertFalse($address['isDistributor']);
}
// === #80 — Gating des RIB par accounting.view ===
@@ -27,6 +27,9 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
private const string MERGE = 'application/merge-patch+json';
private const string VALID_IBAN = 'FR1420041010050500013M02606';
private const string VALID_BIC = 'BNPAFRPPXXX';
// BIC allemand valide isolement (pays DE en positions 5-6) : sert au controle
// croise pays BIC/IBAN (DE vs IBAN FR -> mismatch, cf. Assert\Bic ibanPropertyPath).
private const string FOREIGN_BIC = 'DEUTDEFFXXX';
// === Contacts ===
@@ -86,10 +89,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
]);
self::assertResponseStatusCodeSame(422);
$byPath = [];
foreach ($response->toArray(false)['violations'] ?? [] as $v) {
$byPath[$v['propertyPath']] = $v['message'];
}
$byPath = $this->violationsByPath($response->toArray(false));
self::assertArrayHasKey('email', $byPath, 'La violation email doit porter propertyPath=email (mapping front).');
self::assertSame('L\'adresse email n\'est pas valide.', $byPath['email']);
@@ -132,10 +132,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
]);
self::assertResponseStatusCodeSame(422);
$byPath = [];
foreach ($response->toArray(false)['violations'] ?? [] as $v) {
$byPath[$v['propertyPath']] = $v['message'];
}
$byPath = $this->violationsByPath($response->toArray(false));
self::assertArrayHasKey('email', $byPath);
self::assertSame('L\'adresse email n\'est pas valide.', $byPath['email']);
}
@@ -234,6 +231,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isDelivery' => true,
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
@@ -255,6 +253,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isDelivery' => true,
'postalCode' => '123',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
@@ -284,6 +283,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'isDelivery' => true,
'postalCode' => '75001',
'city' => 'Paris',
'street' => '2 rue Neuve',
@@ -310,6 +310,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/999999/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'isDelivery' => true,
'postalCode' => '75001',
'city' => 'Paris',
'street' => '2 rue Neuve',
@@ -359,6 +360,32 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
self::assertResponseStatusCodeSame(422);
}
/**
* Controle croise pays BIC/IBAN (Assert\Bic ibanPropertyPath) : un BIC (DE) et
* un IBAN (FR) valides isolement mais de pays differents -> 422. La violation
* porte propertyPath=bic et le message FR `ibanMessage` (mapping inline front).
*/
public function testPostRibWithBicIbanCountryMismatchReturns422WithFrenchMessageOnBic(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Rib Pays Mismatch');
$response = $client->request('POST', '/api/clients/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'label' => 'Compte incoherent',
'bic' => self::FOREIGN_BIC,
'iban' => self::VALID_IBAN,
],
]);
self::assertResponseStatusCodeSame(422);
$byPath = $this->violationsByPath($response->toArray(false));
self::assertArrayHasKey('bic', $byPath, 'Le mismatch pays BIC/IBAN doit porter propertyPath=bic (mapping front).');
self::assertSame('Le BIC ne correspond pas au pays de l\'IBAN.', $byPath['bic']);
}
/**
* 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
@@ -49,7 +49,7 @@ final class SupplierAccountingApiTest extends AbstractSupplierApiTestCase
// === RG-2.08 : LCR impose au moins un RIB ===
public function testLcrWithoutRibReturns422OnRibsPath(): void
public function testLcrWithoutRibReturns422OnPaymentTypePath(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Lcr No Rib');
@@ -60,7 +60,9 @@ final class SupplierAccountingApiTest extends AbstractSupplierApiTestCase
]);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('ribs', $this->violationsByPath($response->toArray(false)));
// Miroir client : violation portee sur `paymentType` (select « Type de
// règlement »), `ribs` n'ayant pas de champ de formulaire pour l'ancrer.
self::assertArrayHasKey('paymentType', $this->violationsByPath($response->toArray(false)));
}
public function testLcrWithRibReturns200(): void
@@ -77,5 +79,58 @@ final class SupplierAccountingApiTest extends AbstractSupplierApiTestCase
self::assertResponseStatusCodeSame(200);
}
// === Completude de l'onglet Comptabilite (six scalaires obligatoires) ===
/**
* spec-front M2 § Onglet Comptabilite : a la validation COMPLETE de l'onglet
* (les six champs requis presents dans le payload), chacun vide doit renvoyer
* une 422 sur son propre propertyPath (mapping inline front, ERP-101). Miroir
* du comportement client (ClientAccountingCompletenessValidator).
*/
public function testIncompleteAccountingTabReturns422OnEachField(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Accounting Incomplete');
$response = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE, 'Accept' => self::LD],
'json' => [
'siren' => null,
'accountNumber' => null,
'tvaMode' => null,
'nTva' => null,
'paymentDelay' => null,
'paymentType' => null,
],
]);
self::assertResponseStatusCodeSame(422);
$paths = $this->violationsByPath($response->toArray(false));
self::assertArrayHasKey('siren', $paths);
self::assertArrayHasKey('accountNumber', $paths);
self::assertArrayHasKey('tvaMode', $paths);
self::assertArrayHasKey('nTva', $paths);
self::assertArrayHasKey('paymentDelay', $paths);
self::assertArrayHasKey('paymentType', $paths);
}
/**
* Un PATCH ciblant un sous-ensemble de champs comptables n'est PAS une
* validation d'onglet : la completude ne se declenche pas (edition ponctuelle
* preservee, cf. validateAccountingCompleteness).
*/
public function testPartialAccountingPatchSkipsCompleteness(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Accounting Partial');
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['nTva' => 'FR12345678901'],
]);
self::assertResponseStatusCodeSame(200);
}
// violationsByPath() : helper mutualise dans AbstractSupplierApiTestCase.
}
@@ -13,8 +13,8 @@ use Symfony\Component\Console\Output\NullOutput;
/**
* Matrice RBAC complete du repertoire fournisseurs par role metier (spec-back M2
* § 2.9 + ERP-90). Valide 200/403 par verbe et par onglet pour
* bureau / compta / commerciale / usine, le gating des champs comptables en
* lecture (omission de cle) et le durcissement RG-2.03 (Commerciale) au POST/PATCH.
* bureau / compta / commerciale / usine et le gating des champs comptables en
* lecture (omission de cle).
*
* Les comptes demo et la matrice sont seedes via la commande reelle
* `app:seed-rbac --with-demo-users` (le MEME chemin qu'en recette), idempotente —
@@ -23,7 +23,7 @@ use Symfony\Component\Console\Output\NullOutput;
* Matrice § 2.9 (ERP-90) — rappel :
* - bureau : suppliers.view + manage (ni accounting, ni archive)
* - compta : suppliers.view + accounting.view + accounting.manage (PAS manage)
* - commerciale : suppliers.view + manage (PAS accounting), durcie RG-2.03
* - commerciale : suppliers.view + manage (PAS accounting)
* - usine : aucune permission (403 partout)
* - archive : admin seul (aucun role metier)
*
@@ -93,7 +93,7 @@ final class SupplierRBACMatrixTest extends AbstractSupplierApiTestCase
$client->request('GET', '/api/suppliers', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
// manage : creation OK (bureau n'est pas gate par RG-2.03)
// manage : creation OK
$client->request('POST', '/api/suppliers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Bureau Created', $cat->getId()),
@@ -211,15 +211,13 @@ final class SupplierRBACMatrixTest extends AbstractSupplierApiTestCase
self::assertResponseStatusCodeSame(200);
// manage : la creation passe la security d'operation (pas un 403 comme
// Compta) mais bute sur RG-2.03 (onglet Information incomplet) -> 422.
$response = $client->request('POST', '/api/suppliers', [
// Compta) -> 201. L'onglet Information est facultatif (RG-2.03 retiree,
// miroir client M1) : une Commerciale cree avec le seul onglet principal.
$client->request('POST', '/api/suppliers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Commerciale Post'),
]);
self::assertResponseStatusCodeSame(422);
// Le 422 doit bien etre celui de RG-2.03 (onglet Information) et non un
// 422 orthogonal : on exige une violation sur un champ de completude.
self::assertArrayHasKey('description', $this->violationsByPath($response->toArray(false)));
self::assertResponseStatusCodeSame(201);
// PAS accounting : edition onglet Comptabilite refusee
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
@@ -251,50 +249,6 @@ final class SupplierRBACMatrixTest extends AbstractSupplierApiTestCase
self::assertArrayNotHasKey('ribs', $data);
}
public function testRG203CommercialePostIncompleteIs422AdminIs201(): void
{
$cat = $this->supplierCategory('NEGOCIANT');
// RG-2.03 : Commerciale POST sans onglet Information complet -> 422.
$commerciale = $this->authAs('commerciale');
$response = $commerciale->request('POST', '/api/suppliers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('RG203 Commerciale', $cat->getId()),
]);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('description', $this->violationsByPath($response->toArray(false)));
// Meme payload par un Admin (non gate par RG-2.03) -> 201.
$admin = $this->createAdminClient();
$admin->request('POST', '/api/suppliers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('RG203 Admin', $cat->getId()),
]);
self::assertResponseStatusCodeSame(201);
}
public function testRG203CommercialePatchIncompleteIs422(): void
{
// RG-2.03 : tout PATCH par une Commerciale exige l'Information complete.
// Le fournisseur seede a une Information vide -> meme un PATCH du nom -> 422.
$seed = $this->seedSupplier('Commerciale Patch Incomplete');
$commerciale = $this->authAs('commerciale');
$response = $commerciale->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Commerciale Renamed'],
]);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('description', $this->violationsByPath($response->toArray(false)));
// Le meme PATCH par un Admin passe (non gate par RG-2.03) -> 200.
$admin = $this->createAdminClient();
$admin->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Admin Renamed'],
]);
self::assertResponseStatusCodeSame(200);
}
private function authAs(string $role): Client
{
@@ -172,8 +172,9 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
public function testPostAddressWithIncoherentCityAndPostalCodeReturns201(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Address Incoherent');
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Address Incoherent');
$category = $this->supplierCategory('NEGOCIANT');
// RG-2.05 : pas de controle strict de coherence CP/ville cote serveur.
$client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
@@ -184,6 +185,7 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
'city' => 'Marseille',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
'categories' => ['/api/categories/'.$category->getId()],
],
]);
@@ -217,9 +219,10 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
public function testPostAddressWithEachValidTypeReturns201(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Address Types');
$siteIri = $this->firstSiteIri();
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Address Types');
$siteIri = $this->firstSiteIri();
$category = $this->supplierCategory('NEGOCIANT');
foreach (['PROSPECT', 'DEPART', 'RENDU'] as $type) {
$client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
@@ -230,6 +233,7 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$siteIri],
'categories' => ['/api/categories/'.$category->getId()],
],
]);
self::assertResponseStatusCodeSame(201, sprintf('addressType=%s doit etre accepte.', $type));
@@ -294,6 +298,27 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
self::assertResponseStatusCodeSame(422);
}
/**
* Controle croise pays BIC/IBAN (Assert\Bic ibanPropertyPath) : un BIC (DE) et
* un IBAN (FR) valides isolement mais de pays differents -> 422. La violation
* porte propertyPath=bic et le message FR `ibanMessage` (mapping inline front).
*/
public function testPostRibWithBicIbanCountryMismatchReturns422WithFrenchMessageOnBic(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Rib Pays Mismatch');
$response = $client->request('POST', '/api/suppliers/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['label' => 'Compte incoherent', 'bic' => self::FOREIGN_BIC, 'iban' => self::VALID_IBAN],
]);
self::assertResponseStatusCodeSame(422);
$byPath = $this->violationsByPath($response->toArray(false));
self::assertArrayHasKey('bic', $byPath, 'Le mismatch pays BIC/IBAN doit porter propertyPath=bic (mapping front).');
self::assertSame('Le BIC ne correspond pas au pays de l\'IBAN.', $byPath['bic']);
}
public function testDeleteRibNonLcrReturns204(): void
{
$client = $this->createAdminClient();
@@ -87,12 +87,14 @@ final class SupplierValidationTest extends TestCase
// === RG-2.08 : LCR impose au moins un RIB ===
public function testLcrWithoutRibIsRejectedOnRibsPath(): void
public function testLcrWithoutRibIsRejectedOnPaymentTypePath(): void
{
$supplier = $this->validSupplier();
$supplier->setPaymentType($this->paymentType('LCR'));
self::assertContains('ribs', $this->violationPaths($supplier));
// Miroir client : la violation LCR -> >= 1 RIB est portee sur `paymentType`
// (affichee sous le select « Type de règlement », `ribs` n'ayant pas de champ).
self::assertContains('paymentType', $this->violationPaths($supplier));
}
public function testLcrWithRibPasses(): void
@@ -101,7 +103,7 @@ final class SupplierValidationTest extends TestCase
$supplier->setPaymentType($this->paymentType('LCR'));
$supplier->addRib(new SupplierRib());
self::assertNotContains('ribs', $this->violationPaths($supplier));
self::assertNotContains('paymentType', $this->violationPaths($supplier));
}
public function testNeutralPaymentTypeRequiresNeitherBankNorRib(): void
@@ -1,129 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Unit;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Commercial\Application\Validator\SupplierInformationCompletenessValidator;
use App\Module\Commercial\Domain\Entity\Supplier;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires du SupplierInformationCompletenessValidator (RG-2.03) : pour le
* role Commerciale, TOUS les champs de l'onglet Information sont obligatoires.
* Chaque champ manquant produit une violation portant son propertyPath (ERP-101).
*
* @internal
*/
final class SupplierInformationCompletenessValidatorTest extends TestCase
{
public function testCompleteInformationPasses(): void
{
$supplier = $this->completeSupplier();
$this->validator()->validate($supplier);
// Aucune exception levee : la completude est satisfaite.
$this->addToAssertionCount(1);
}
public function testEmptyInformationListsEveryMissingField(): void
{
$supplier = new Supplier();
$supplier->setCompanyName('Recycla SAS'); // onglet principal, hors Information
try {
$this->validator()->validate($supplier);
self::fail('Une ValidationException etait attendue (onglet Information vide).');
} catch (ValidationException $e) {
$paths = [];
foreach ($e->getConstraintViolationList() as $violation) {
$paths[] = $violation->getPropertyPath();
}
// Les 8 champs Information (dont volumeForecast, NEW vs Client) sont
// tous signales d'un coup, chacun sous son propre propertyPath.
sort($paths);
self::assertSame([
'competitors',
'description',
'directorName',
'employeesCount',
'foundedAt',
'profitAmount',
'revenueAmount',
'volumeForecast',
], $paths);
}
}
public function testPartialInformationReportsOnlyMissingFields(): void
{
$supplier = $this->completeSupplier();
$supplier->setDirectorName(null);
$supplier->setVolumeForecast(null);
try {
$this->validator()->validate($supplier);
self::fail('Une ValidationException etait attendue (2 champs manquants).');
} catch (ValidationException $e) {
$paths = [];
foreach ($e->getConstraintViolationList() as $violation) {
$paths[] = $violation->getPropertyPath();
}
sort($paths);
self::assertSame(['directorName', 'volumeForecast'], $paths);
}
}
public function testZeroNumericValuesAreNotMissing(): void
{
// employeesCount = 0, profitAmount = "0.00", volumeForecast = 0 sont des
// valeurs valides (un zero n'est pas une absence) -> pas de violation.
$supplier = $this->completeSupplier();
$supplier->setEmployeesCount(0);
$supplier->setProfitAmount('0.00');
$supplier->setVolumeForecast(0);
$this->validator()->validate($supplier);
$this->addToAssertionCount(1);
}
public function testBlankStringIsMissing(): void
{
// Une chaine vide apres trim compte comme manquante.
$supplier = $this->completeSupplier();
$supplier->setDescription(' ');
$this->expectException(ValidationException::class);
$this->validator()->validate($supplier);
}
/**
* Fournisseur dont l'onglet Information est entierement renseigne.
*/
private function completeSupplier(): Supplier
{
$supplier = new Supplier();
$supplier->setCompanyName('Recycla SAS');
$supplier->setDescription('Specialiste du recyclage');
$supplier->setCompetitors('Concurrent A, Concurrent B');
$supplier->setFoundedAt(new DateTimeImmutable('2010-01-01'));
$supplier->setEmployeesCount(42);
$supplier->setRevenueAmount('1000000.00');
$supplier->setDirectorName('Marie Durand');
$supplier->setProfitAmount('150000.00');
$supplier->setVolumeForecast(5000);
return $supplier;
}
private function validator(): SupplierInformationCompletenessValidator
{
return new SupplierInformationCompletenessValidator();
}
}
@@ -1,244 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Unit;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Commercial\Application\Service\SupplierFieldNormalizer;
use App\Module\Commercial\Application\Validator\SupplierInformationCompletenessValidator;
use App\Module\Commercial\Domain\Entity\Supplier;
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\SupplierProcessor;
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
use App\Shared\Domain\Security\BusinessRoles;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\UnitOfWork;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Tests unitaires du SupplierProcessor — perimetre ERP-89 : detection du role
* Commerciale cote back (RG-2.03). Les autres responsabilites du processor
* (gating accounting / archive / mode strict) sont heritees d'ERP-87 et testees
* a leur niveau ; les RG inter-champs (RG-2.07/2.08/2.10) sont des contraintes
* d'entite (cf. SupplierValidationTest), non portees ici.
*
* @internal
*/
final class SupplierProcessorTest extends TestCase
{
public function testCommercialeIncompleteInformationIsUnprocessable(): void
{
// RG-2.03 : role Commerciale + onglet Information incomplet -> 422, meme
// sur un POST (les champs Information n'y sont pas renseignables).
$supplier = $this->minimalSupplier();
$supplier->setDescription('Une description'); // les autres champs Information restent null
$processor = $this->makeProcessor(
payload: ['description' => 'Une description'],
user: $this->commercialeUser(),
);
$this->expectException(ValidationException::class);
$processor->process($supplier, $this->operation());
}
public function testCommercialeIncompleteInformationOnMainOnlyPatchIsUnprocessable(): void
{
// RG-2.03 : pour une Commerciale, la completude Information est exigee
// meme quand le payload ne touche PAS l'onglet Information (ici
// companyName seul) -> 422.
$supplier = $this->minimalSupplier();
$supplier->setCompanyName('Renamed Co');
$processor = $this->makeProcessor(
granted: ['commercial.suppliers.manage'],
payload: ['companyName' => 'Renamed Co'],
user: $this->commercialeUser(),
managed: true,
originalData: [
'companyName' => 'TEST CO',
'isArchived' => false,
],
);
$this->expectException(ValidationException::class);
$processor->process($supplier, $this->operation());
}
public function testCommercialeCompleteInformationPasses(): void
{
// RG-2.03 satisfaite : tous les champs Information renseignes -> 200.
$supplier = $this->completeInformationSupplier();
$processor = $this->makeProcessor(
granted: ['commercial.suppliers.manage'],
payload: ['description' => 'desc'],
user: $this->commercialeUser(),
);
self::assertInstanceOf(Supplier::class, $processor->process($supplier, $this->operation()));
}
public function testNonCommercialeSkipsInformationCompleteness(): void
{
// Meme onglet Information incomplet, mais user non-Commerciale -> aucun
// blocage (la completude est specifique a la Commerciale).
$supplier = $this->minimalSupplier();
$supplier->setDescription('Une description');
$processor = $this->makeProcessor(
payload: ['description' => 'Une description'],
user: null,
);
self::assertInstanceOf(Supplier::class, $processor->process($supplier, $this->operation()));
}
public function testAdminIncompleteInformationPasses(): void
{
// Distinct du cas user=null : un utilisateur AUTHENTIFIE mais non-Commerciale
// (ici un admin, BusinessRoleAwareInterface renvoyant false pour tout role
// metier) n'est pas soumis a la completude Information -> 200 malgre un
// onglet Information incomplet. Prouve que le gate porte bien sur le ROLE
// metier Commerciale, et pas sur « il y a un utilisateur connecte ».
$supplier = $this->minimalSupplier();
$supplier->setDescription('Une description');
$processor = $this->makeProcessor(
payload: ['description' => 'Une description'],
user: $this->adminUser(),
);
self::assertInstanceOf(Supplier::class, $processor->process($supplier, $this->operation()));
}
/**
* @param list<string> $granted Permissions accordees a l'utilisateur courant
* @param array<string, mixed> $payload Corps JSON simule de la requete
* @param bool $managed true = entite geree par l'ORM (PATCH), false = creation (POST)
* @param array<string, mixed> $originalData Etat persiste simule (getOriginalEntityData)
*/
private function makeProcessor(
array $granted = [],
array $payload = [],
?UserInterface $user = null,
bool $managed = false,
array $originalData = [],
): SupplierProcessor {
$persist = new class implements ProcessorInterface {
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
return $data;
}
};
$security = $this->createStub(Security::class);
$security->method('isGranted')->willReturnCallback(
static fn (mixed $attribute): bool => is_string($attribute) && in_array($attribute, $granted, true),
);
$security->method('getUser')->willReturn($user);
$requestStack = new RequestStack();
$requestStack->push(new Request([], [], [], [], [], [], json_encode($payload, JSON_THROW_ON_ERROR)));
$uow = $this->createMock(UnitOfWork::class);
$uow->method('getOriginalEntityData')->willReturn($originalData);
$em = $this->createMock(EntityManagerInterface::class);
$em->method('contains')->willReturn($managed);
$em->method('getUnitOfWork')->willReturn($uow);
return new SupplierProcessor(
$persist,
new SupplierFieldNormalizer(),
new SupplierInformationCompletenessValidator(),
$security,
$requestStack,
$em,
);
}
private function minimalSupplier(): Supplier
{
$supplier = new Supplier();
$supplier->setCompanyName('Test Co');
return $supplier;
}
private function completeInformationSupplier(): Supplier
{
$supplier = $this->minimalSupplier();
$supplier->setDescription('desc');
$supplier->setCompetitors('concurrents');
$supplier->setFoundedAt(new DateTimeImmutable('2010-01-01'));
$supplier->setEmployeesCount(10);
$supplier->setRevenueAmount('1000.00');
$supplier->setDirectorName('Marie Durand');
$supplier->setProfitAmount('100.00');
$supplier->setVolumeForecast(500);
return $supplier;
}
private function operation(): Operation
{
return $this->createStub(Operation::class);
}
/**
* Utilisateur authentifie non-Commerciale (profil admin) : porte
* BusinessRoleAwareInterface mais ne reconnait aucun role metier. Sert a
* distinguer « pas de role Commerciale » de « pas d'utilisateur » (null).
*/
private function adminUser(): UserInterface
{
return new class implements UserInterface, BusinessRoleAwareInterface {
public function hasBusinessRole(string $roleCode): bool
{
return false;
}
public function getRoles(): array
{
return ['ROLE_ADMIN'];
}
public function eraseCredentials(): void {}
public function getUserIdentifier(): string
{
return 'admin-test';
}
};
}
private function commercialeUser(): UserInterface
{
return new class implements UserInterface, BusinessRoleAwareInterface {
public function hasBusinessRole(string $roleCode): bool
{
return BusinessRoles::COMMERCIALE === $roleCode;
}
public function getRoles(): array
{
return ['ROLE_USER'];
}
public function eraseCredentials(): void {}
public function getUserIdentifier(): string
{
return 'commerciale-test';
}
};
}
}