Compare commits

..

12 Commits

Author SHA1 Message Date
Matthieu edde89bcba test(commercial) : SupplierExportControllerTest sur base fournisseurs (catégories FOURNISSEUR, dédup F3) (ERP-113)
Fait étendre SupplierExportControllerTest à AbstractSupplierApiTestCase au
lieu d'AbstractCommercialApiTestCase. Supprime le seedSupplier() privé (qui
seedait des catégories de type CLIENT via createCategory(), violant RG-2.10)
et le tearDown() redondant, désormais portés par la base sur des catégories
FOURNISSEUR.

Le contact principal utilise le helper addContact() de la base ; le téléphone
secondaire, non porté par ce helper, est posé via le setter sur le contact
retourné. L'assertion de la colonne Catégories dérive le libellé attendu de
supplierCategory('NEGOCIANT') au lieu de hardcoder le préfixe de nom de test.
2026-06-07 12:49:44 +02:00
Matthieu c23a8c17c4 feat(commercial) : fixtures Doctrine fournisseurs (≈13 suppliers complets + sous-collections) (ERP-112) 2026-06-07 11:46:08 +02:00
Matthieu 85963ec3ff test(commercial) : fix CI anti-N+1 (profiling test) + durcissement 422/gating M2 fournisseurs (ERP-92)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m1s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m6s
- config/packages/test/doctrine.yaml : force dbal profiling en test pour que
  doctrine.debug_data_holder existe sous APP_DEBUG=0 (CI). Le test anti-N+1
  SupplierListTest passait en local (debug=1) mais cassait en CI.
- RBACMatrix/SupplierApi : les 422 RG-2.03 et RG-2.14 assertent desormais le
  propertyPath / message (plus seulement le code) — un 422 orthogonal ne peut
  plus faire passer le test.
- RBACMatrix : gating bureau/commerciale verifie l'ensemble des champs
  comptables (accountNumber/nTva/tvaMode/paymentType), plus seulement siren/ribs.
- violationsByPath() mutualise dans AbstractSupplierApiTestCase (dedup).
2026-06-07 11:18:37 +02:00
Matthieu 42cc9be4ae test(commercial) : tests PHPUnit M2 fournisseurs (matrice RG + contrat sérialisation + DoD JSON réel) (ERP-92)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Failing after 2m4s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m6s
Suite fonctionnelle M2 assertant sur le CORPS JSON (jamais les annotations),
jumelle de la suite clients M1 :
- contrat de sérialisation : 4 régressions M1 re-testées (RIB gaté absent pour
  Commerciale, booléens triageProvider/isArchived présents, embed
  categories[].code/name, embed sites[].name/postalCode objet) + enveloppe AP4
  (member/totalItems/view, archivés exclus) + suppression du contact inline ;
- matrice RBAC réelle (app:seed-rbac) bureau/compta/commerciale/usine 200/403,
  gating accounting par omission de clé, mode strict PATCH (RG-2.16) ;
- RG-2.03/2.04/2.05/2.06/2.07/2.08/2.09/2.10/2.11/2.12/2.14/2.15/2.17 ;
- sous-ressources contacts/adresses/ribs (CRUD, sécurité, normalisation) ;
- anti N+1 liste (compte de requêtes constant), audit Supplier + RIB iban/bic.

Fix de contrat découvert et corrigé (sinon DoD figée sur un contrat faux) :
les référentiels comptables (TvaMode/PaymentType/PaymentDelay/Bank) ne portaient
que le groupe client:read:accounting (M1) → sur un fournisseur ils sortaient en
IRI nu. Ajout de supplier:read:accounting → objet {id, code, label} embarqué.

makefile : test-db-setup recrée l'index partiel uq_supplier_company_name_active
(droppé par schema:update comme pour le client) — oubli M2.

DoD § 4.0.bis : réponses JSON RÉELLES (liste + détail admin/commerciale) collées,
capturées via SupplierSerializationContractTest.
2026-06-07 10:45:07 +02:00
Matthieu e917c413dc feat(commercial) : export XLSX fournisseurs (SupplierExportController, SIREN gaté accounting.view) (ERP-91)
Export XLSX du répertoire fournisseurs (spec § 4.6), jumeau de l'export client M1.

- SupplierExportController avec #[Route(priority: 1)] (anti-conflit API Platform {id}),
  is_granted('commercial.suppliers.view'). Mêmes filtres que la liste
  (includeArchived/archivedOnly/search/categoryCode/siteId) via createListQueryBuilder()
  partagé avec le SupplierProvider ; non archivés par défaut.
- Colonnes : Nom, Contact principal (SupplierContact de plus petit position — ERP-106),
  Tél principal/secondaire, Email, Catégories (CSV), Sites (CSV), SIREN (omis sans
  accounting.view), Date de création.
- hydrateContacts() ajouté au repository (chargement batché des contacts en une requête
  IN, anti-N+1) — méthode dédiée à l'export, la liste paginée n'embarque pas les contacts.
- Tables supplier* ajoutées à ColumnCommentsCatalog : leurs COMMENT (posés par la
  migration ERP-85) étaient dropés par le schema:update --force du test-db-setup et non
  restaurés, cassant ColumnsHaveSqlCommentTest dès un re-setup de la base de test.
- Test fonctionnel SupplierExportControllerTest (9 cas).
2026-06-05 14:56:50 +02:00
Matthieu 48d1904d03 feat(commercial) : RBAC fournisseurs (permissions + 3 sources + seed par rôle + sécurité référentiels) (ERP-90)
- 5 permissions commercial.suppliers.* (view/manage/accounting.view/accounting.manage/archive) dans CommercialModule::permissions()
- 3 sources RBAC synchronisées (règle n°8) : sidebar.php (/suppliers + suppliers.view), personas.ts (user-full), SeedE2ECommand.php (miroir back)
- Assignation par rôle dans RbacSeeder::MATRIX (§ 2.9, idempotent) : Bureau view+manage, Compta view+accounting.view+accounting.manage, Commerciale view+manage, Usine aucune, archive Admin seul
- Sécurité des référentiels (tva_modes/payment_delays/payment_types/banks) élargie : view client OR view fournisseur
2026-06-05 14:21:19 +02:00
Matthieu b580bb6576 feat(commercial) : validators M2 fournisseurs (RG-2.03/2.07/2.08/2.10) (ERP-89)
RG inter-champs via Assert\Callback->atPath() sur l'entite Supplier (decision
figee ERP-89), pour un 422 a propertyPath consommable par extractApiViolations :
- RG-2.10 : categories de type FOURNISSEUR (supplier.categories) -> atPath('categories')
- RG-2.07 : VIREMENT impose une banque -> atPath('bank')
- RG-2.08 : LCR impose au moins un RIB -> atPath('ribs')

RG-2.03 (completude Information pour le role Commerciale, detection back via
BusinessRoleAwareInterface) portee par SupplierInformationCompletenessValidator,
invoque par le SupplierProcessor.

Tests : SupplierValidationTest (Callbacks 2.07/2.08/2.10),
SupplierInformationCompletenessValidatorTest, SupplierProcessorTest (RG-2.03).
2026-06-05 13:55:17 +02:00
Matthieu 3548224298 feat(commercial) : sous-ressources M2 fournisseurs (contacts/adresses/ribs) (ERP-88)
Ajoute les opérations API Platform et les Processors d'écriture des
sous-collections du fournisseur (POST/PATCH/DELETE + GET unitaire) :

- SupplierContactProcessor : rattachement parent, normalisation serveur
  (RG-2.12), validation RG-2.04 (prénom OU nom). DELETE libre (RG-2.13).
- SupplierAddressProcessor : rattachement parent. RG-2.05/2.06/2.09 portées
  par les contraintes d'entité ; RG-2.10 (catégorie type FOURNISSEUR) via
  Assert\Callback validateCategoryType.
- SupplierRibProcessor : rattachement parent, RG-2.08 (refus DELETE du
  dernier RIB sous LCR -> 409).

Security différenciée : contacts/adresses -> commercial.suppliers.manage ;
ribs -> commercial.suppliers.accounting.manage (+ .view pour le GET).
POST en read:false (parent rattaché manuellement, 404 si absent) — parade
NonUniqueResult du M1. Messages FR (ERP-107) + propertyPath aligné (ERP-101).
2026-06-05 11:57:25 +02:00
Matthieu 3838473876 feat(commercial) : SupplierProvider + SupplierProcessor + gating compta (ERP-87)
Branche les operations API du repertoire fournisseurs (M2), jumelles du M1 :

- SupplierProvider : liste paginee (Paginator ORM), exclusion archives +
  soft-deletes par defaut, filtres includeArchived/categoryCode/siteId/search,
  echappatoire ?pagination=false, item 404 si soft-delete (RG-2.17).
- SupplierProcessor : normalisation companyName, archivage isArchived/archivedAt
  (RG-2.14/2.15), gating fin accounting/manage en mode strict (403 sur tout le
  payload hors-permission, RG-2.16), 409 doublon companyName + conflit de
  restauration (RG-2.11).
- SupplierReadGroupContextBuilder : ajoute supplier:read:accounting au contexte
  de lecture si accounting.view (gating compta + RIB par omission de cle). Un
  Provider ne peut pas influencer les groupes de serialisation : c'est le point
  d'extension idiomatique, miroir de ClientReadGroupContextBuilder.
- SupplierFieldNormalizer : normalisation serveur (RG-2.12).
- Supplier : ajout #[ApiResource] (GetCollection/Get/Post/Patch) wirant
  Provider/Processor.

Validators metier (RG-2.03/2.07/2.08/2.10) = ticket suivant.
make test vert (483/483), php-cs-fixer applique.
2026-06-05 11:24:55 +02:00
Matthieu ff47af07d2 feat(commercial) : entités M2 fournisseurs + repositories (ERP-86)
Entités jumelles du M1 client, mapping ORM aligné sur la migration ERP-85,
sans contact inline (ERP-106) :

- Supplier (#[Auditable] + Timestampable/Blamable) : formulaire principal,
  Information (+ volumeForecast), Comptabilité (FK référentiels M1), archivage,
  soft-delete préparé. Catégories M2M via CategoryInterface (règle n°1).
- SupplierContact / SupplierAddress (enum addressType, bennes, triageProvider)
  / SupplierRib.
- Repositories : interfaces Domain + impls Doctrine. DoctrineSupplierRepository
  porte les fetch-joins anti-N+1 de la liste (categories + addresses.sites en
  2 passes, pattern ERP-100) et les filtres (search companyName + contacts,
  categoryCode, siteId, archivage).

Contrat de sérialisation (RETEX M1, 3 maillons posés sur l'entité) :
read-groups sur les propriétés, getters isArchived/isTriageProvider avec
SerializedName, embed contacts/addresses (supplier:item:read) et ribs
(supplier:read:accounting). L'#[ApiResource] + Provider/Processor sont au
ticket suivant (ERP-87).

Validation FR (ERP-107) : messages FR sur toutes les contraintes, Length(max)
calé sur les colonnes. Garde-fou EntityConstraintsHaveFrenchMessageTest étendu
(Assert\Choice + whitelist addressType/postalCode). Clés i18n audit des 4
entités ajoutées.

make test : 483/483 OK.
2026-06-05 10:55:04 +02:00
Matthieu 1d9a656504 feat(commercial) : migration BDD M2 fournisseurs (supplier + sous-collections + M2M) (ERP-85)
Cree le schema M2 sous le module Commercial, jumeau du M1 client :
- supplier (formulaire + Information + Comptabilite + archive + soft-delete)
  sans contact inline (ERP-106) ni auto-reference distributor/broker ;
  ajout volume_forecast.
- Sous-collections : supplier_category (M2M), supplier_contact, supplier_address,
  supplier_rib + jointures supplier_address_site/_contact/_category.
- supplier_address : enum address_type (PROSPECT|DEPART|RENDU, CHECK exclusif),
  bennes + triage_provider, sans billing_email.
- Index partiel unique uq_supplier_company_name_active (nom seul, hors
  archives/soft-delete).
- COMMENT ON COLUMN sur chaque colonne (regle n12) + helper Timestampable/Blamable.

Referentiels comptables (tva_mode/payment_delay/payment_type/bank) et CategoryType
FOURNISSEUR reutilises (zero duplication). Namespace racine DoctrineMigrations
(FK cross-module, exception regle n11).
2026-06-05 10:18:56 +02:00
Matthieu 92a2d4f763 feat(catalog) : taxonomie FOURNISSEUR (type + filtre ?typeCode= + seed) (ERP-84)
Recree le CategoryType FOURNISSEUR (unifie sur CLIENT par ERP-78) et implemente
un vrai filtre ?typeCode= sur GET /api/categories (inexistant en prod).

- CategoryProvider lit ?typeCode= depuis les filtres (meme pattern que
  includeDeleted) et le passe au repository ; naltere pas ?pagination=false.
- DoctrineCategoryRepository::createListQueryBuilder joint le CategoryType et
  filtre sur son code (compatible Paginator ORM fetchJoinCollection).
- Migration racine Version20260605120000 : seed du type FOURNISSEUR en
  ON CONFLICT + 5 categories de demo (Negociant, Cooperative, Producteur,
  Grossiste, Importateur) en NOT EXISTS. Aucune colonne creee.
- CategoryTypeFixtures / CategoryFixtures etendus a FOURNISSEUR (idempotent,
  survit a make db-reset).
- Test CategoryTypeCodeFilterTest : filtre exclusif, compat pagination Hydra,
  code inexistant -> liste vide.
2026-06-05 09:59:37 +02:00
133 changed files with 1641 additions and 9610 deletions
+2 -2
View File
@@ -75,7 +75,7 @@ jobs:
- name: Bootstrap test database - name: Bootstrap test database
# Aligne sur la cible `test-db-setup` du makefile : apres # Aligne sur la cible `test-db-setup` du makefile : apres
# `schema:update --force`, on RECREE manuellement l'index unique # `schema:update --force`, on RECREE manuellement l'index unique
# partiel `uq_category_name_active` car Doctrine ORM ne sait # partiel `uq_category_name_type_active` car Doctrine ORM ne sait
# pas exprimer les index fonctionnels partiels (LOWER(name) + WHERE # pas exprimer les index fonctionnels partiels (LOWER(name) + WHERE
# deleted_at IS NULL) et `schema:update` les considere comme # deleted_at IS NULL) et `schema:update` les considere comme
# orphelins et les DROP — collisions non detectees, tests d'unicite # orphelins et les DROP — collisions non detectees, tests d'unicite
@@ -89,7 +89,7 @@ jobs:
php bin/console app:apply-column-comments --env=test --no-interaction php bin/console app:apply-column-comments --env=test --no-interaction
php bin/console doctrine:fixtures:load --env=test --no-interaction php bin/console doctrine:fixtures:load --env=test --no-interaction
php bin/console app:sync-permissions --env=test --no-interaction php bin/console app:sync-permissions --env=test --no-interaction
php bin/console --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_active ON category (LOWER(name)) WHERE deleted_at IS NULL" php bin/console --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL"
- name: Run PHPUnit - name: Run PHPUnit
run: php -d memory_limit=512M vendor/bin/phpunit run: php -d memory_limit=512M vendor/bin/phpunit
+1 -1
View File
@@ -141,7 +141,7 @@ Disponibles uniquement après `make db-reset` / `make fixtures` (état « avec s
| `bob` | `bob` | ROLE_USER | — | | `bob` | `bob` | ROLE_USER | — |
| `bureau` | `demo` | ROLE_USER | clients : view + manage | | `bureau` | `demo` | ROLE_USER | clients : view + manage |
| `compta` | `demo` | ROLE_USER | clients : view + accounting.view / manage | | `compta` | `demo` | ROLE_USER | clients : view + accounting.view / manage |
| `commerciale` | `demo` | ROLE_USER | clients : view + manage | | `commerciale` | `demo` | ROLE_USER | clients : view + manage (Information obligatoire — RG-1.04) |
| `usine` | `demo` | ROLE_USER | aucun accès clients | | `usine` | `demo` | ROLE_USER | aucun accès clients |
--- ---
+7 -7
View File
@@ -45,13 +45,6 @@ return [
'label' => 'sidebar.commercial.section', 'label' => 'sidebar.commercial.section',
'icon' => 'mdi:account-arrow-left-outline', 'icon' => 'mdi:account-arrow-left-outline',
'items' => [ 'items' => [
[
'label' => 'sidebar.commercial.suppliers',
'to' => '/suppliers',
'icon' => 'mdi:account-arrow-left-outline',
'module' => 'commercial',
'permission' => 'commercial.suppliers.view',
],
[ [
'label' => 'sidebar.commercial.clients', 'label' => 'sidebar.commercial.clients',
'to' => '/clients', 'to' => '/clients',
@@ -59,6 +52,13 @@ return [
'module' => 'commercial', 'module' => 'commercial',
'permission' => 'commercial.clients.view', 'permission' => 'commercial.clients.view',
], ],
[
'label' => 'sidebar.commercial.suppliers',
'to' => '/suppliers',
'icon' => 'mdi:account-arrow-left-outline',
'module' => 'commercial',
'permission' => 'commercial.suppliers.view',
],
], ],
], ],
// Section "Administration" : regroupe toutes les pages de configuration // Section "Administration" : regroupe toutes les pages de configuration
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.109' app.version: '0.1.83'
+5 -4
View File
@@ -10,8 +10,8 @@ trous, zéro duplication »).
ERP-60 n'écrit QUE les tests des RG non déjà couvertes par la stack, et mappe ici ERP-60 n'écrit QUE les tests des RG non déjà couvertes par la stack, et mappe ici
l'intégralité des RG (existantes + nouvelles + déléguées). Les tests dépendants l'intégralité des RG (existantes + nouvelles + déléguées). Les tests dépendants
des **rôles métier** (matrice RBAC bureau/compta/commerciale/usine) sont des **rôles métier** (matrice RBAC bureau/compta/commerciale/usine + RG-1.04
**délégués à ERP-74 (#493)** : ces rôles n'existent qu'après le fonctionnel) sont **délégués à ERP-74 (#493)** : ces rôles n'existent qu'après le
merge de la stack. merge de la stack.
## Mapping RG → test ## Mapping RG → test
@@ -21,7 +21,7 @@ merge de la stack.
| ~~RG-1.01~~ | _(supprimée V1 — refonte-contact)_ contact inline retiré du Client ; complétude couverte par RG-1.05 / RG-1.14 (`ClientContact`) | — | refonte-contact | | ~~RG-1.01~~ | _(supprimée V1 — refonte-contact)_ contact inline retiré du Client ; complétude couverte par RG-1.05 / RG-1.14 (`ClientContact`) | — | refonte-contact |
| ~~RG-1.02~~ | _(supprimée du Client V1)_ téléphones inline retirés du Client (testés sur `ClientContact`) | — | refonte-contact | | ~~RG-1.02~~ | _(supprimée du Client V1)_ téléphones inline retirés du Client (testés sur `ClientContact`) | — | refonte-contact |
| RG-1.03 | distributor/broker exclusifs + type catégorie | `ClientApiTest::testPostWithDistributorAndBrokerReturns422` ; `::testPostDistributorReferencingNonDistributorReturns422` ; `::testPostValidDistributorReturns201` ; `ClientProcessorTest` (unit) | ERP-55 | | RG-1.03 | distributor/broker exclusifs + type catégorie | `ClientApiTest::testPostWithDistributorAndBrokerReturns422` ; `::testPostDistributorReferencingNonDistributorReturns422` ; `::testPostValidDistributorReturns201` ; `ClientProcessorTest` (unit) | ERP-55 |
| ~~RG-1.04~~ | _(SUPPRIMÉE)_ Onglet Information désormais facultatif pour tous les rôles (validation de complétude retirée) | — | ERP-55 / ERP-74 | | RG-1.04 | Onglet Information obligatoire pour rôle Commerciale | `ClientProcessorTest::testCommercialeIncompleteInformationIsUnprocessable` ; `::testNonCommercialeSkipsInformationCompleteness` (unit, dormant). **Test fonctionnel + durcissement → ERP-74** | ERP-55 / **ERP-74** |
| RG-1.05 | Contact : prénom OU nom → 422 (CHECK) | `ClientSubResourceApiTest::testPostContactWithoutNameReturns422` | ERP-57 | | RG-1.05 | Contact : prénom OU nom → 422 (CHECK) | `ClientSubResourceApiTest::testPostContactWithoutNameReturns422` | ERP-57 |
| RG-1.06/07/08 | Adresse prospect exclusive de livraison/facturation → 422 (Assert\Callback + CHECK filet) | `ClientAddressTest::testProspectAddressCannotBeDelivery` ; `::testProspectAddressCannotBeBilling` | ERP-60 / **ERP-76** | | RG-1.06/07/08 | Adresse prospect exclusive de livraison/facturation → 422 (Assert\Callback + CHECK filet) | `ClientAddressTest::testProspectAddressCannotBeDelivery` ; `::testProspectAddressCannotBeBilling` | ERP-60 / **ERP-76** |
| RG-1.09 | Code postal `^[0-9]{4,5}$` → 422 | `ClientSubResourceApiTest::testPostAddressWithInvalidPostalCodeReturns422` | ERP-57 | | RG-1.09 | Code postal `^[0-9]{4,5}$` → 422 | `ClientSubResourceApiTest::testPostAddressWithInvalidPostalCodeReturns422` | ERP-57 |
@@ -60,7 +60,8 @@ merge de la stack.
- **Matrice RBAC différenciée** par rôle métier (Bureau / Compta / Commerciale / - **Matrice RBAC différenciée** par rôle métier (Bureau / Compta / Commerciale /
Usine) : 200/403 par verbe et par onglet selon le rôle. Usine) : 200/403 par verbe et par onglet selon le rôle.
- ~~**RG-1.04 fonctionnel**~~ : _(SUPPRIMÉE)_ l'onglet Information est désormais facultatif pour tous les rôles. - **RG-1.04 fonctionnel** : PATCH onglet Information par une Commerciale avec
champs incomplets → 422 ; même PATCH par Admin → 200 (+ durcissement code/spec).
- Raison : ces rôles métier ne sont seedés qu'après le merge de la stack M1. - Raison : ces rôles métier ne sont seedés qu'après le merge de la stack M1.
## Gaps & suivi ## Gaps & suivi
+5 -4
View File
@@ -310,7 +310,7 @@ CREATE TABLE client (
distributor_id INT REFERENCES client(id) ON DELETE SET NULL, distributor_id INT REFERENCES client(id) ON DELETE SET NULL,
broker_id INT REFERENCES client(id) ON DELETE SET NULL, broker_id INT REFERENCES client(id) ON DELETE SET NULL,
triage_service BOOLEAN NOT NULL DEFAULT FALSE, triage_service BOOLEAN NOT NULL DEFAULT FALSE,
-- Onglet Information (facultatif pour tous — RG-1.04 supprimée) -- Onglet Information (Commerciale obligatoire — RG-1.04 — null sinon)
description TEXT, description TEXT,
competitors VARCHAR(255), competitors VARCHAR(255),
founded_at DATE, founded_at DATE,
@@ -864,7 +864,8 @@ Cf. § 2.6. Pattern Shared standard.
### Onglet Information ### Onglet Information
- ~~**RG-1.04**~~ _(SUPPRIMÉE)_ : l'onglet Information est désormais **facultatif pour tous les rôles**, y compris Commerciale. Les champs `description`, `competitors`, `foundedAt`, `employeesCount`, `revenueAmount`, `directorName`, `profitAmount` restent optionnels (colonnes nullable, aucune validation de complétude). Le validator `ClientInformationCompletenessValidator` et le gating par rôle métier dans le `ClientProcessor` ont été retirés. - **RG-1.04** _(durcie — ERP-74)_ : Pour un utilisateur portant le rôle métier **Commerciale**, **tous** les champs de l'onglet Information (`description`, `competitors`, `foundedAt`, `employeesCount`, `revenueAmount`, `directorName`, `profitAmount`) sont obligatoires sur **POST et sur tout PATCH**, **indépendamment des champs réellement envoyés** (la condition d'intersection avec `client:write:information` a été retirée). Pour les autres rôles, ces champs restent optionnels. Implémenté via un validator custom `ClientInformationCompletenessValidator` invoqué systématiquement par le `ClientProcessor` quand le user porte le rôle Commerciale.
- **Conséquence** : le POST n'exposant que le groupe `client:write:main`, l'onglet Information n'y est pas renseignable → une Commerciale obtient **422** sur tout POST (cf. § 8.1). La complétude se fait donc via les PATCH `client:write:information` ultérieurs. Un Admin (non gaté) crée normalement (201).
### Onglet Contact ### Onglet Contact
@@ -882,7 +883,7 @@ Cf. § 2.6. Pattern Shared standard.
### Onglet Comptabilité ### Onglet Comptabilité
- **RG-1.30** _(ajoutée — correctif incohérence spec-front/spec-back)_ : à la **validation complète de l'onglet Comptabilité**, les six champs scalaires `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType` sont **obligatoires** (alignement sur spec-front § Onglet Comptabilité). Colonnes `nullable` en base (l'onglet est rempli dans un second temps, et l'onglet principal ne les envoie pas) + validateur contextuel `ClientAccountingCompletenessValidator` invoqué par le `ClientProcessor` (parti pris : nullable + validateur d'onglet plutôt qu'un `Assert\NotBlank` sur l'entité). Déclenchement : uniquement quand **les six champs sont présents dans le payload** (le front les envoie toujours ensemble via « Valider ») ; un PATCH ciblant un sous-ensemble de champs comptables (édition ponctuelle) n'est pas soumis à la complétude. Chaque champ manquant → 422 sur son `propertyPath` (mapping inline front, ERP-101). `bank` reste hors complétude (conditionnel RG-1.12). - **RG-1.30** _(ajoutée — correctif incohérence spec-front/spec-back)_ : à la **validation complète de l'onglet Comptabilité**, les six champs scalaires `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType` sont **obligatoires** (alignement sur spec-front § Onglet Comptabilité). Colonnes `nullable` en base (l'onglet est rempli dans un second temps, et l'onglet principal ne les envoie pas) + validateur contextuel `ClientAccountingCompletenessValidator` invoqué par le `ClientProcessor` — même parti que RG-1.04 (Information). Déclenchement : uniquement quand **les six champs sont présents dans le payload** (le front les envoie toujours ensemble via « Valider ») ; un PATCH ciblant un sous-ensemble de champs comptables (édition ponctuelle) n'est pas soumis à la complétude. Chaque champ manquant → 422 sur son `propertyPath` (mapping inline front, ERP-101). `bank` reste hors complétude (conditionnel RG-1.12).
- **RG-1.12** : Le champ `bank` est visible et obligatoire **uniquement** si `paymentType.code = 'VIREMENT'`. Validation server-side dans le `ClientProcessor` : si `payment_type.code = VIREMENT` et `bank IS NULL` → 422. - **RG-1.12** : Le champ `bank` est visible et obligatoire **uniquement** si `paymentType.code = 'VIREMENT'`. Validation server-side dans le `ClientProcessor` : si `payment_type.code = VIREMENT` et `bank IS NULL` → 422.
- **RG-1.13** : Les champs RIB (`label`, `bic`, `iban`) sont obligatoires si **au moins un bloc RIB est présent ET** `paymentType.code = 'LCR'`. C'est-à-dire : - **RG-1.13** : Les champs RIB (`label`, `bic`, `iban`) sont obligatoires si **au moins un bloc RIB est présent ET** `paymentType.code = 'LCR'`. C'est-à-dire :
- Si `paymentType.code = LCR` ET `client.ribs.count() = 0` → 422 « Au moins un RIB est obligatoire pour le type LCR ». - Si `paymentType.code = LCR` ET `client.ribs.count() = 0` → 422 « Au moins un RIB est obligatoire pour le type LCR ».
@@ -937,7 +938,7 @@ Cf. § 2.6. Pattern Shared standard.
- [ ] ~~RG-1.02~~ _(supprimée du Client V1)_ : plus de téléphones inline sur le Client (téléphones testés sur `ClientContact`) - [ ] ~~RG-1.02~~ _(supprimée du Client V1)_ : plus de téléphones inline sur le Client (téléphones testés sur `ClientContact`)
- [ ] **RG-1.03** : POST avec distributor ET broker → 422 ; POST distributor seul → 201 - [ ] **RG-1.03** : POST avec distributor ET broker → 422 ; POST distributor seul → 201
- [ ] **RG-1.03** : POST distributor référençant un client SANS catégorie de code DISTRIBUTEUR → 422 (validation custom `ClientProcessor::hasCategoryCode`) - [ ] **RG-1.03** : POST distributor référençant un client SANS catégorie de code DISTRIBUTEUR → 422 (validation custom `ClientProcessor::hasCategoryCode`)
- [x] ~~**RG-1.04**~~ _(SUPPRIMÉE)_ : onglet Information facultatif pour tous les rôles (plus de validation de complétude) - [ ] **RG-1.04** : PATCH onglet Information par un user Commerciale avec champs incomplets → 422 ; même PATCH par Admin → 200
- [ ] **RG-1.05** : POST contact sans firstName ni lastName → 422 (BDD CHECK lève une exception) - [ ] **RG-1.05** : POST contact sans firstName ni lastName → 422 (BDD CHECK lève une exception)
- [ ] **RG-1.06/07/08** : POST adresse avec isProspect=true ET isDelivery=true → 422 / CHECK - [ ] **RG-1.06/07/08** : POST adresse avec isProspect=true ET isDelivery=true → 422 / CHECK
- [ ] **RG-1.09** : POST adresse avec postalCode invalide (3 chiffres) → 422 ; CP/ville incohérents → 200 (pas de validation stricte côté serveur) - [ ] **RG-1.09** : POST adresse avec postalCode invalide (3 chiffres) → 422 ; CP/ville incohérents → 200 (pas de validation stricte côté serveur)
+8 -8
View File
@@ -13,7 +13,7 @@ date_redaction: 2026-05-28
# === LIENS === # === LIENS ===
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-31898" maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-31898"
regles_metier: [RG-1.01, RG-1.02, RG-1.03, RG-1.05, RG-1.06, RG-1.07, RG-1.08, RG-1.09, RG-1.10, RG-1.11, RG-1.12, RG-1.13, RG-1.14, RG-1.15, RG-1.16, RG-1.17, RG-1.18, RG-1.19, RG-1.20, RG-1.21, RG-1.22, RG-1.23, RG-1.24, RG-1.25, RG-1.26, RG-1.27, RG-1.28, RG-1.29] regles_metier: [RG-1.01, RG-1.02, RG-1.03, RG-1.04, RG-1.05, RG-1.06, RG-1.07, RG-1.08, RG-1.09, RG-1.10, RG-1.11, RG-1.12, RG-1.13, RG-1.14, RG-1.15, RG-1.16, RG-1.17, RG-1.18, RG-1.19, RG-1.20, RG-1.21, RG-1.22, RG-1.23, RG-1.24, RG-1.25, RG-1.26, RG-1.27, RG-1.28, RG-1.29]
roles: [Admin, Bureau, Compta, Commerciale, Usine] roles: [Admin, Bureau, Compta, Commerciale, Usine]
lien_spec_back: ./spec-back.md lien_spec_back: ./spec-back.md
@@ -105,13 +105,13 @@ Saisir les informations de l'entreprise.
| Champ | Type | Obligatoire | Règle | | Champ | Type | Obligatoire | Règle |
|---|---|---|---| |---|---|---|---|
| **Description** | `<MalioInputTextArea>` | Non | — _(onglet facultatif, RG-1.04 supprimée)_ | | **Description** | `<MalioInputTextArea>` | Conditionnel | RG-1.04 (obligatoire pour rôle Commerciale) |
| **Concurrents** | `<MalioInputText>` | Non | — | | **Concurrents** | `<MalioInputText>` | Conditionnel | RG-1.04 |
| **Date de création** (de l'entreprise) | `<input type="date">` (exception Malio — pas de composant date couvert) | Non | — | | **Date de création** (de l'entreprise) | `<input type="date">` (exception Malio — pas de composant date couvert) | Conditionnel | RG-1.04 |
| **Nombre de salariés** | `<MalioInputNumber>` | Non | — | | **Nombre de salariés** | `<MalioInputNumber>` | Conditionnel | RG-1.04 |
| **CA €** | `<MalioInputAmount>` | Non | — | | **CA €** | `<MalioInputAmount>` | Conditionnel | RG-1.04 |
| **Dirigeant** | `<MalioInputText>` | Non | — | | **Dirigeant** | `<MalioInputText>` | Conditionnel | RG-1.04 |
| **Résultat €** | `<MalioInputAmount>` | Non | — | | **Résultat €** | `<MalioInputAmount>` | Conditionnel | RG-1.04 |
**Action** : « Valider » → PATCH partiel `/api/clients/{id}` (groupe `client:write:information`). **Action** : « Valider » → PATCH partiel `/api/clients/{id}` (groupe `client:write:information`).
+12 -22
View File
@@ -126,7 +126,7 @@ Toutes les entités métier nouvelles implémentent `TimestampableInterface` + `
Notes (miroir M1) : Notes (miroir M1) :
- **Compta édite uniquement l'onglet Comptabilité** (`accounting.manage`) d'un fournisseur existant. Compta ne peut pas **créer** un fournisseur (pas de `manage` global). - **Compta édite uniquement l'onglet Comptabilité** (`accounting.manage`) d'un fournisseur existant. Compta ne peut pas **créer** un fournisseur (pas de `manage` global).
- **Commerciale** a `view` + `manage` mais **pas** `accounting.view` → l'onglet Comptabilité est masqué (front) et filtré (back). Mécanisme réel (le code fait foi) : le groupe de lecture `supplier:read:accounting` n'est **pas** dans le contexte de sérialisation par défaut ; le `SupplierReadGroupContextBuilder` ne l'**ajoute** dynamiquement que si l'utilisateur porte `commercial.suppliers.accounting.view` (gating **par ajout** de groupe, jamais par retrait). Sans la permission, les champs comptables (et les RIB) ne sont donc jamais sérialisés. La colonne SIREN de l'export XLSX suit la même règle (`accounting.view`). - **Commerciale** a `view` + `manage` mais **pas** `accounting.view` → l'onglet Comptabilité est masqué (front) et filtré (back, 2 niveaux : `security` API Platform + `SupplierProvider`).
- **Bureau** : `view` + `manage` (tout sauf Comptabilité). - **Bureau** : `view` + `manage` (tout sauf Comptabilité).
- **Usine** : aucune permission → item sidebar invisible, accès direct 403. - **Usine** : aucune permission → item sidebar invisible, accès direct 403.
@@ -159,11 +159,9 @@ final class SupplierFieldNormalizer
Le formatage `XX XX XX XX XX` est fait à l'affichage côté front. Le back stocke `0612345678` (chiffres seuls). Le formatage `XX XX XX XX XX` est fait à l'affichage côté front. Le back stocke `0612345678` (chiffres seuls).
### 2.12 Liste : embed catégories + sites + hydratation anti-N+1 (cohérence M1/ERP-62) ### 2.12 Liste : embed catégories + sites + fetch-joins (cohérence M1/ERP-62)
Décision d'alignement (02/06/2026) : la **liste** `GET /api/suppliers` **embarque** les `categories[]` (avec `code`/`name`) et les `sites[]` (avec `name`/`postalCode` — pas de `code`), comme la liste Clients après ERP-62 — et **non** des champs dérivés aplatis. Décision d'alignement (02/06/2026) : la **liste** `GET /api/suppliers` **embarque** les `categories[]` (avec `code`/`name`) et les `sites[]` (avec `name`/`postalCode` — pas de `code`), comme la liste Clients après ERP-62 — et **non** des champs dérivés aplatis. Conséquence performance : le `DoctrineSupplierRepository` **DOIT** poser des **fetch-joins** (`leftJoin`+`addSelect`) sur `categories` et `addresses.sites` dans la requête de liste pour éviter le N+1. Les `sites` de la liste sont agrégés/dédoublonnés via `Supplier::getSites()` (cf. § 3.3). Le contrat de sérialisation (groupes `category:read` / `site:read` dans le contexte) est posé **une seule fois** sur l'entité — source de vérité unique, le front ne le redéfinit pas.
Conséquence performance — **implémentation réelle (le code fait foi)** : le `DoctrineSupplierRepository` **ne fetch-joine PAS** les to-many dans la requête de sélection (`createListQueryBuilder` ne fait que filtres + tri). L'anti-N+1 passe par `hydrateListCollections()` (puis `hydrateContacts()`) : une fois le jeu de fournisseurs borné (page ou export), des requêtes **`IN` bornées séparées** remplissent `categories`, puis `addresses.sites`, puis `contacts` sur les **mêmes** instances `Supplier` (identity map). Ce découpage évite le **produit cartésien** qu'un fetch-join combiné `categories × addresses.sites` imposerait aux chemins non paginés (export, `?pagination=false`). Les `sites` de la liste sont agrégés/dédoublonnés via `Supplier::getSites()` (cf. § 3.3). Le contrat de sérialisation (groupes `category:read` / `site:read` dans le contexte) est posé **une seule fois** sur l'entité — source de vérité unique, le front ne le redéfinit pas.
> Dépendance confirmée sur le JSON réel (#82 mergé) : `Category` expose `code`/`name` sous `category:read` ; `Site` expose `name`/`postalCode`/`city`/`color` sous `site:read` (**pas de `code`**). L'embed est pleinement matérialisé. > Dépendance confirmée sur le JSON réel (#82 mergé) : `Category` expose `code`/`name` sous `category:read` ; `Site` expose `name`/`postalCode`/`city`/`color` sous `site:read` (**pas de `code`**). L'embed est pleinement matérialisé.
@@ -215,8 +213,6 @@ Namespace : **`DoctrineMigrations` (racine `migrations/`)** — fichier `migrati
> **Rappel règle ABSOLUE n°12** : chaque colonne créée ci-dessous DOIT recevoir son `COMMENT ON COLUMN`. Les 4 colonnes Timestampable/Blamable passent par le helper `addStandardTimestampableBlamableComments($schema, '<table>')`. Le SQL ci-dessous montre la structure ; les `COMMENT ON COLUMN` (un par colonne métier) sont à écrire dans la migration (exemples §3.2.bis). > **Rappel règle ABSOLUE n°12** : chaque colonne créée ci-dessous DOIT recevoir son `COMMENT ON COLUMN`. Les 4 colonnes Timestampable/Blamable passent par le helper `addStandardTimestampableBlamableComments($schema, '<table>')`. Le SQL ci-dessous montre la structure ; les `COMMENT ON COLUMN` (un par colonne métier) sont à écrire dans la migration (exemples §3.2.bis).
> **Types réels de la migration (le code fait foi)** : le SQL ci-dessous est *illustratif*. La migration mergée (`Version20260605130000`) utilise le **style aligné M1** : clés primaires en `INT GENERATED BY DEFAULT AS IDENTITY` (et **non** `SERIAL`) et horodatages en `TIMESTAMP(0) WITHOUT TIME ZONE` (et **non** `TIMESTAMPTZ`, car le `TimestampableBlamableTrait` mappe `datetime_immutable`). Garantit que `schema:update` reste un no-op une fois les entités mappées.
```sql ```sql
-- ===================================================================== -- =====================================================================
-- Seed taxonomie : nouveau type FOURNISSEUR (référentiels comptables = M1, non recréés) -- Seed taxonomie : nouveau type FOURNISSEUR (référentiels comptables = M1, non recréés)
@@ -426,10 +422,8 @@ use Symfony\Component\Validator\Constraints as Assert;
// Cohérence M1/ERP-62 : la LISTE embarque catégories + sites (pas de // Cohérence M1/ERP-62 : la LISTE embarque catégories + sites (pas de
// champ dérivé aplati). Maillon (c) : category:read + site:read dans // champ dérivé aplati). Maillon (c) : category:read + site:read dans
// le contexte pour exposer Category(code/name) + Site(name/postalCode). // le contexte pour exposer Category(code/name) + Site(name/postalCode).
// ⚠ Anti-N+1 : pas de fetch-join dans la requête de liste — le // ⚠ Le SupplierRepository DOIT fetch-join categories + addresses.sites
// SupplierRepository hydrate categories/sites/contacts via des requêtes // pour éviter le N+1 sur la liste (cf. § 2.12).
// IN bornées séparées (hydrateListCollections), pour éviter le produit
// cartésien sur les chemins non paginés (export) — cf. § 2.12.
normalizationContext: ['groups' => [ normalizationContext: ['groups' => [
'supplier:read', 'supplier:read',
'category:read', 'category:read',
@@ -448,14 +442,13 @@ use Symfony\Component\Validator\Constraints as Assert;
normalizationContext: ['groups' => [ normalizationContext: ['groups' => [
'supplier:read', 'supplier:read',
'supplier:item:read', // embed contacts / addresses 'supplier:item:read', // embed contacts / addresses
// ⚠ supplier:read:accounting est volontairement ABSENT ici : il est 'supplier:read:accounting', // scalaires compta + embed ribs (filtré par le Provider selon accounting.view)
// AJOUTÉ dynamiquement par le SupplierReadGroupContextBuilder quand
// l'user porte accounting.view (gating par ajout, pas par retrait —
// parade bug #4 M1). Il porte les scalaires compta + l'embed ribs.
'category:read', // embed des Category (id/code/name) — relation imbriquée 'category:read', // embed des Category (id/code/name) — relation imbriquée
'site:read', // embed des Site (id/name/postalCode/city/color, pas de code) — relation imbriquée 'site:read', // embed des Site (id/name/postalCode/city/color, pas de code) — relation imbriquée
'default:read', 'default:read',
]], ]],
// Le Provider RETIRE supplier:read:accounting du contexte si l'user
// n'a pas is_granted('commercial.suppliers.accounting.view').
provider: SupplierProvider::class, provider: SupplierProvider::class,
), ),
new Post( new Post(
@@ -465,13 +458,10 @@ use Symfony\Component\Validator\Constraints as Assert;
processor: SupplierProcessor::class, processor: SupplierProcessor::class,
), ),
new Patch( new Patch(
// Security élargie : `manage` OU `accounting.manage` — le rôle Compta security: "is_granted('commercial.suppliers.manage')",
// n'a pas `manage` mais doit pouvoir éditer l'onglet Comptabilité d'un // Le SupplierProcessor inspecte les groupes envoyés pour autoriser
// fournisseur existant (§ 2.9). Le SupplierProcessor re-gate ensuite // onglet par onglet (cf. § 2.10 + § 5). Patch des champs comptables
// onglet par onglet (mode strict RG-2.16) : // exige is_granted('commercial.suppliers.accounting.manage') ;
security: "is_granted('commercial.suppliers.manage') or is_granted('commercial.suppliers.accounting.manage')",
// Patch des champs comptables exige accounting.manage (guardAccounting) ;
// champs main/information exigent manage (guardManage) ;
// patch isArchived exige is_granted('commercial.suppliers.archive'). // patch isArchived exige is_granted('commercial.suppliers.archive').
normalizationContext: ['groups' => ['supplier:read', 'default:read']], normalizationContext: ['groups' => ['supplier:read', 'default:read']],
denormalizationContext: ['groups' => [ denormalizationContext: ['groups' => [
+4 -3
View File
@@ -2,9 +2,7 @@
Valeurs en dur issues de la maquette Figma (design Starseed) : Valeurs en dur issues de la maquette Figma (design Starseed) :
- sidebar depliee : 232px (w-[232px], repli laisse par defaut 72px) - sidebar depliee : 232px (w-[232px], repli laisse par defaut 72px)
- marge horizontale du contenu sur desktop : 170px (xl:px-[170px]) - marge horizontale du contenu sur desktop : 170px (xl:px-[170px])
La marge haute du contenu (44px) vit desormais DANS l'entete (PageHeader, - bande blanche sticky sous la navbar : 47px (h-[47px])
pt-11) et non sur le <main> : sinon, l'entete etant sticky, ce padding
laissait un trou blanc entre le SiteSelector et l'entete.
A faire evoluer uniquement avec une mise a jour de maquette. A faire evoluer uniquement avec une mise a jour de maquette.
--> -->
<template> <template>
@@ -27,6 +25,9 @@
<SiteSelector v-if="showSiteSelector"/> <SiteSelector v-if="showSiteSelector"/>
<main <main
class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-10 sm:px-6 lg:px-12 xl:px-11"> class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-10 sm:px-6 lg:px-12 xl:px-11">
<div
aria-hidden="true"
class="pointer-events-none sticky top-0 z-30 h-11 flex-shrink-0 bg-white"/>
<slot/> <slot/>
</main> </main>
</div> </div>
+6 -161
View File
@@ -49,150 +49,6 @@
"commercial": { "commercial": {
"title": "Commercial", "title": "Commercial",
"welcome": "Module 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": { "clients": {
"title": "Répertoire clients", "title": "Répertoire clients",
"add": "Ajouter", "add": "Ajouter",
@@ -232,7 +88,6 @@
"toast": { "toast": {
"createSuccess": "Client créé avec succès", "createSuccess": "Client créé avec succès",
"updateSuccess": "Client mis à jour avec succès", "updateSuccess": "Client mis à jour avec succès",
"addComplete": "Client ajouté",
"archiveSuccess": "Client archivé avec succès", "archiveSuccess": "Client archivé avec succès",
"restoreSuccess": "Client restauré avec succès", "restoreSuccess": "Client restauré avec succès",
"error": "Une erreur est survenue. Réessayez.", "error": "Une erreur est survenue. Réessayez.",
@@ -318,20 +173,15 @@
"addressTypeDelivery": "Livraison", "addressTypeDelivery": "Livraison",
"addressTypeBilling": "Facturation", "addressTypeBilling": "Facturation",
"addressTypeDeliveryBilling": "Adresse + Facturation", "addressTypeDeliveryBilling": "Adresse + Facturation",
"addressTypeBroker": "Adresse Courtier",
"addressTypeDistributor": "Adresse Distributeur",
"categories": "Catégorie", "categories": "Catégorie",
"country": "Pays", "country": "Pays",
"postalCode": "Code postal", "postalCode": "Code postal",
"city": "Ville", "city": "Ville",
"street": "Adresse", "street": "Adresse",
"streetNotFound": "Adresse introuvable ? Saisissez-la directement.",
"streetComplement": "Adresse complémentaire", "streetComplement": "Adresse complémentaire",
"sites": "Sites", "sites": "Sites",
"contacts": "Contact(s) rattaché(s)", "contacts": "Contact(s) rattaché(s)",
"billingEmail": "Email de facturation", "billingEmail": "Email de facturation",
"billingEmailSecondary": "Email de facturation secondaire",
"addBillingEmail": "Ajouter un email",
"remove": "Supprimer l'adresse", "remove": "Supprimer l'adresse",
"add": "Nouvelle adresse", "add": "Nouvelle adresse",
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre." "degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
@@ -566,26 +416,21 @@
"newCategory": "Ajouter", "newCategory": "Ajouter",
"editCategory": "Modifier la catégorie", "editCategory": "Modifier la catégorie",
"createCategory": "Créer une catégorie", "createCategory": "Créer une catégorie",
"viewCategory": "Détail de la catégorie",
"noCategories": "Aucune catégorie pour l'instant.", "noCategories": "Aucune catégorie pour l'instant.",
"table": { "table": {
"name": "Nom", "name": "Nom",
"types": "Types" "type": "Type"
},
"filters": {
"title": "Filtres",
"search": "Recherche",
"types": "Types de catégorie",
"apply": "Voir les résultats",
"reset": "Réinitialiser"
}, },
"form": { "form": {
"name": "Nom", "name": "Nom",
"types": "Types de catégorie" "type": "Type de catégorie",
"typePlaceholder": "Sélectionner un type"
}, },
"validation": { "validation": {
"nameRequired": "Le nom est obligatoire.", "nameRequired": "Le nom est obligatoire.",
"nameLength": "Le nom doit faire entre 2 et 120 caractères.", "nameLength": "Le nom doit faire entre 2 et 120 caractères.",
"typesRequired": "Sélectionnez au moins un type de catégorie." "typeRequired": "Le type de catégorie est obligatoire."
}, },
"delete": { "delete": {
"title": "Supprimer la catégorie", "title": "Supprimer la catégorie",
@@ -595,7 +440,7 @@
"created": "Catégorie créée avec succès", "created": "Catégorie créée avec succès",
"updated": "Catégorie mise à jour avec succès", "updated": "Catégorie mise à jour avec succès",
"deleted": "Catégorie supprimée avec succès", "deleted": "Catégorie supprimée avec succès",
"duplicate": "Une catégorie nommée « {name} » existe déjà.", "duplicate": "Une catégorie nommée « {name} » existe déjà pour ce type.",
"typesLoadFailed": "Impossible de charger les types de catégorie. Réessayez." "typesLoadFailed": "Impossible de charger les types de catégorie. Réessayez."
} }
} }
@@ -24,17 +24,16 @@
required required
/> />
<!-- Types (RG-1.05 : au moins un obligatoire). MalioSelectCheckbox <!-- Type (RG-1.05 obligatoire). MalioSelect porte la valeur en
porte un tableau d'ids (categoryType id) ; conversion en tableau number (categoryType id) ; conversion en IRI au moment du save
d'IRI au moment du save par le composable useCategoryForm. --> par le composable useCategoryForm. -->
<MalioSelectCheckbox <MalioSelect
v-model="form.categoryTypeIds.value" v-model="form.categoryTypeId.value"
:options="typeOptions" :options="typeOptions"
:label="t('admin.categories.form.types')" :label="t('admin.categories.form.type')"
:error="form.errors.categoryTypes" :empty-option-label="t('admin.categories.form.typePlaceholder')"
:display-tag="true" :error="form.errors.categoryType"
:disabled="loadingTypes" :disabled="loadingTypes"
required
/> />
</form> </form>
@@ -90,17 +89,28 @@ const emit = defineEmits<{
delete: [] delete: []
}>() }>()
// Mode du drawer : creation (pas de category prop, POST au save) ou /**
// modification d'une categorie existante (PATCH au save). Pas de distinction * Mode du drawer (dérivé du composable `useCategoryForm`) :
// view/edit : comme les autres drawers, le titre et le bouton Enregistrer sont * - 'create' : pas de category prop, formulaire vide, POST au save.
// stables quel que soit l'etat « dirty » du formulaire. * - 'view' : category prop set, formulaire pre-rempli, save MASQUE
* jusqu'a ce que l'utilisateur modifie un champ.
* - 'edit' : category prop set et formulaire « dirty » (au moins un
* champ different de l'original), PATCH au save.
*/
type DrawerMode = 'create' | 'view' | 'edit'
const isCreateMode = computed(() => props.category === null) const isCreateMode = computed(() => props.category === null)
const headerLabel = computed(() => const mode = computed<DrawerMode>(() => {
isCreateMode.value if (isCreateMode.value) return 'create'
? t('admin.categories.createCategory') return form.isDirty.value ? 'edit' : 'view'
: t('admin.categories.editCategory'), })
)
const headerLabel = computed(() => {
if (mode.value === 'create') return t('admin.categories.createCategory')
if (mode.value === 'edit') return t('admin.categories.editCategory')
return t('admin.categories.viewCategory')
})
// Le bouton Supprimer n'est visible qu'en consultation/edition d'une categorie // Le bouton Supprimer n'est visible qu'en consultation/edition d'une categorie
// existante et seulement pour les users ayant la permission manage. En mode // existante et seulement pour les users ayant la permission manage. En mode
@@ -109,12 +119,10 @@ const canShowDelete = computed(
() => !isCreateMode.value && can('catalog.categories.manage'), () => !isCreateMode.value && can('catalog.categories.manage'),
) )
// Save : visible en creation, et en consultation/edition d'une categorie // Save : visible en creation, ou en edition (apres modification d'un champ).
// existante (l'utilisateur doit pouvoir enregistrer sans qu'un champ ait // Masque en view tant que rien n'a change.
// d'abord ete modifie). Le bouton reste neanmoins protege par son `disabled`
// pendant la soumission / le chargement des types.
const canShowSave = computed( const canShowSave = computed(
() => isCreateMode.value || can('catalog.categories.manage'), () => mode.value === 'create' || mode.value === 'edit',
) )
const typeOptions = computed(() => const typeOptions = computed(() =>
@@ -144,18 +152,18 @@ watch(
) )
/** /**
* Sauvegarde : delegue au composable (POST en creation, PATCH en modification). * Sauvegarde : delegue au composable (POST en mode create, PATCH en mode
* Le toast succes + mapping erreur 409/422 est gere par le composable. Le PATCH * edit). Le toast succes + mapping erreur 409/422 est gere par le composable.
* envoie le payload complet, donc le bouton Enregistrer sauvegarde a tout * En cas de succes, on ferme le drawer et on previent le parent pour qu'il
* moment (meme sans modification). En cas de succes, on ferme le drawer et on * refresh la liste.
* previent le parent pour qu'il refresh la liste.
*/ */
async function handleSave(): Promise<void> { async function handleSave(): Promise<void> {
const result = isCreateMode.value let result: Category | null = null
? await form.submitCreate() if (mode.value === 'create') {
: props.category result = await form.submitCreate()
? await form.submitUpdate(props.category.id) } else if (mode.value === 'edit' && props.category) {
: null result = await form.submitUpdate(props.category.id)
}
if (result) { if (result) {
emit('saved') emit('saved')
emit('update:modelValue', false) emit('update:modelValue', false)
@@ -39,7 +39,7 @@ const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' }
const CAT: Category = { const CAT: Category = {
id: 42, id: 42,
name: 'Vis', name: 'Vis',
categoryTypes: [TYPE_VENTE], categoryType: TYPE_VENTE,
deletedAt: null, deletedAt: null,
createdAt: '2026-01-01T10:00:00+00:00', createdAt: '2026-01-01T10:00:00+00:00',
updatedAt: '2026-01-01T10:00:00+00:00', updatedAt: '2026-01-01T10:00:00+00:00',
@@ -58,25 +58,25 @@ describe('useCategoryForm', () => {
}) })
describe('loadFrom', () => { describe('loadFrom', () => {
it('pre-remplit le formulaire depuis une categorie existante (multi-types)', () => { it('pre-remplit le formulaire depuis une categorie existante', () => {
const form = useCategoryForm() const form = useCategoryForm()
form.loadFrom({ ...CAT, categoryTypes: [TYPE_VENTE, TYPE_ACHAT] }) form.loadFrom(CAT)
expect(form.name.value).toBe('Vis') expect(form.name.value).toBe('Vis')
expect(form.categoryTypeIds.value).toEqual([1, 2]) expect(form.categoryTypeId.value).toBe(1)
expect(form.errors).toEqual({}) expect(form.errors).toEqual({})
}) })
it('vide le formulaire en mode creation (null)', () => { it('vide le formulaire en mode creation (null)', () => {
const form = useCategoryForm() const form = useCategoryForm()
form.name.value = 'old' form.name.value = 'old'
form.categoryTypeIds.value = [99] form.categoryTypeId.value = 99
form.loadFrom(null) form.loadFrom(null)
expect(form.name.value).toBe('') expect(form.name.value).toBe('')
expect(form.categoryTypeIds.value).toEqual([]) expect(form.categoryTypeId.value).toBeNull()
}) })
it('reinitialise le snapshot initial → isDirty=false juste apres', () => { it('reinitialise le snapshot initial → isDirty=false juste apres', () => {
@@ -98,32 +98,13 @@ describe('useCategoryForm', () => {
expect(form.isDirty.value).toBe(true) expect(form.isDirty.value).toBe(true)
}) })
it('passe a true quand on ajoute un type (selection multi)', () => {
const form = useCategoryForm()
form.loadFrom(CAT)
expect(form.isDirty.value).toBe(false)
form.categoryTypeIds.value = [1, 2]
expect(form.isDirty.value).toBe(true)
})
it('reste false si la selection est identique dans un autre ordre', () => {
const form = useCategoryForm()
form.loadFrom({ ...CAT, categoryTypes: [TYPE_VENTE, TYPE_ACHAT] })
form.categoryTypeIds.value = [2, 1]
expect(form.isDirty.value).toBe(false)
})
}) })
describe('validate', () => { describe('validate', () => {
it('signale une erreur si name est vide (RG-1.02)', () => { it('signale une erreur si name est vide (RG-1.02)', () => {
const form = useCategoryForm() const form = useCategoryForm()
form.name.value = '' form.name.value = ''
form.categoryTypeIds.value = [1] form.categoryTypeId.value = 1
const ok = form.validate() const ok = form.validate()
@@ -134,7 +115,7 @@ describe('useCategoryForm', () => {
it('signale erreur si name est whitespace-only (trim → vide)', () => { it('signale erreur si name est whitespace-only (trim → vide)', () => {
const form = useCategoryForm() const form = useCategoryForm()
form.name.value = ' ' form.name.value = ' '
form.categoryTypeIds.value = [1] form.categoryTypeId.value = 1
const ok = form.validate() const ok = form.validate()
@@ -145,7 +126,7 @@ describe('useCategoryForm', () => {
it('signale erreur si name fait 1 caractere (< 2, RG-1.04)', () => { it('signale erreur si name fait 1 caractere (< 2, RG-1.04)', () => {
const form = useCategoryForm() const form = useCategoryForm()
form.name.value = 'A' form.name.value = 'A'
form.categoryTypeIds.value = [1] form.categoryTypeId.value = 1
const ok = form.validate() const ok = form.validate()
@@ -156,7 +137,7 @@ describe('useCategoryForm', () => {
it('signale erreur si name fait 121 caracteres (> 120, RG-1.04)', () => { it('signale erreur si name fait 121 caracteres (> 120, RG-1.04)', () => {
const form = useCategoryForm() const form = useCategoryForm()
form.name.value = 'A'.repeat(121) form.name.value = 'A'.repeat(121)
form.categoryTypeIds.value = [1] form.categoryTypeId.value = 1
const ok = form.validate() const ok = form.validate()
@@ -164,21 +145,21 @@ describe('useCategoryForm', () => {
expect(form.errors.name).toBe('admin.categories.validation.nameLength') expect(form.errors.name).toBe('admin.categories.validation.nameLength')
}) })
it('signale erreur si aucun type selectionne (RG-1.05)', () => { it('signale erreur si categoryTypeId est null (RG-1.05)', () => {
const form = useCategoryForm() const form = useCategoryForm()
form.name.value = 'Vis' form.name.value = 'Vis'
form.categoryTypeIds.value = [] form.categoryTypeId.value = null
const ok = form.validate() const ok = form.validate()
expect(ok).toBe(false) expect(ok).toBe(false)
expect(form.errors.categoryTypes).toBe('admin.categories.validation.typesRequired') expect(form.errors.categoryType).toBe('admin.categories.validation.typeRequired')
}) })
it('passe quand name et au moins un type sont valides', () => { it('passe quand name et categoryType sont valides', () => {
const form = useCategoryForm() const form = useCategoryForm()
form.name.value = 'Vis' form.name.value = 'Vis'
form.categoryTypeIds.value = [1, 2] form.categoryTypeId.value = 1
const ok = form.validate() const ok = form.validate()
@@ -190,7 +171,7 @@ describe('useCategoryForm', () => {
const form = useCategoryForm() const form = useCategoryForm()
// Erreur prealable : une validation en echec peuple errors.name. // Erreur prealable : une validation en echec peuple errors.name.
form.name.value = '' form.name.value = ''
form.categoryTypeIds.value = [1] form.categoryTypeId.value = 1
form.validate() form.validate()
expect(form.errors.name).toBeTruthy() expect(form.errors.name).toBeTruthy()
@@ -203,17 +184,17 @@ describe('useCategoryForm', () => {
}) })
describe('submitCreate', () => { describe('submitCreate', () => {
it('appelle POST /categories avec body { name trimme, categoryTypes en IRI[] }', async () => { it('appelle POST /categories avec body { name trimme, categoryType en IRI }', async () => {
mockPost.mockResolvedValueOnce(CAT) mockPost.mockResolvedValueOnce(CAT)
const form = useCategoryForm() const form = useCategoryForm()
form.name.value = ' Vis ' form.name.value = ' Vis '
form.categoryTypeIds.value = [1, 2] form.categoryTypeId.value = 1
const result = await form.submitCreate() const result = await form.submitCreate()
expect(mockPost).toHaveBeenCalledWith( expect(mockPost).toHaveBeenCalledWith(
'/categories', '/categories',
{ name: 'Vis', categoryTypes: ['/api/category_types/1', '/api/category_types/2'] }, { name: 'Vis', categoryType: '/api/category_types/1' },
{ toast: false }, { toast: false },
) )
expect(result).toEqual(CAT) expect(result).toEqual(CAT)
@@ -222,7 +203,7 @@ describe('useCategoryForm', () => {
it('ne declenche aucun appel API si la validation client echoue', async () => { it('ne declenche aucun appel API si la validation client echoue', async () => {
const form = useCategoryForm() const form = useCategoryForm()
form.name.value = '' form.name.value = ''
form.categoryTypeIds.value = [1] form.categoryTypeId.value = 1
const result = await form.submitCreate() const result = await form.submitCreate()
@@ -234,7 +215,7 @@ describe('useCategoryForm', () => {
mockPost.mockResolvedValueOnce(CAT) mockPost.mockResolvedValueOnce(CAT)
const form = useCategoryForm() const form = useCategoryForm()
form.name.value = 'Vis' form.name.value = 'Vis'
form.categoryTypeIds.value = [1] form.categoryTypeId.value = 1
await form.submitCreate() await form.submitCreate()
@@ -250,7 +231,7 @@ describe('useCategoryForm', () => {
}) })
const form = useCategoryForm() const form = useCategoryForm()
form.name.value = 'Vis' form.name.value = 'Vis'
form.categoryTypeIds.value = [1] form.categoryTypeId.value = 1
const result = await form.submitCreate() const result = await form.submitCreate()
@@ -277,7 +258,7 @@ describe('useCategoryForm', () => {
}) })
const form = useCategoryForm() const form = useCategoryForm()
form.name.value = 'Vis' form.name.value = 'Vis'
form.categoryTypeIds.value = [1] form.categoryTypeId.value = 1
const result = await form.submitCreate() const result = await form.submitCreate()
@@ -288,24 +269,24 @@ describe('useCategoryForm', () => {
expect(mockToastError).not.toHaveBeenCalled() expect(mockToastError).not.toHaveBeenCalled()
}) })
it('mappe une violation sur categoryTypes (hydra:violations alternative)', async () => { it('mappe aussi hydra:violations (negociation de format alternative)', async () => {
mockPost.mockRejectedValueOnce({ mockPost.mockRejectedValueOnce({
response: { response: {
status: 422, status: 422,
_data: { _data: {
'hydra:violations': [ 'hydra:violations': [
{ propertyPath: 'categoryTypes', message: 'Sélectionnez au moins un type de catégorie.' }, { propertyPath: 'categoryType', message: 'Type invalide.' },
], ],
}, },
}, },
}) })
const form = useCategoryForm() const form = useCategoryForm()
form.name.value = 'Vis' form.name.value = 'Vis'
form.categoryTypeIds.value = [1] form.categoryTypeId.value = 1
await form.submitCreate() await form.submitCreate()
expect(form.errors.categoryTypes).toBe('Sélectionnez au moins un type de catégorie.') expect(form.errors.categoryType).toBe('Type invalide.')
}) })
it('fallback en toast generique si le status n est ni 409 ni 422', async () => { it('fallback en toast generique si le status n est ni 409 ni 422', async () => {
@@ -314,7 +295,7 @@ describe('useCategoryForm', () => {
}) })
const form = useCategoryForm() const form = useCategoryForm()
form.name.value = 'Vis' form.name.value = 'Vis'
form.categoryTypeIds.value = [1] form.categoryTypeId.value = 1
await form.submitCreate() await form.submitCreate()
@@ -333,7 +314,7 @@ describe('useCategoryForm', () => {
) )
const form = useCategoryForm() const form = useCategoryForm()
form.name.value = 'Vis' form.name.value = 'Vis'
form.categoryTypeIds.value = [1] form.categoryTypeId.value = 1
const pending = form.submitCreate() const pending = form.submitCreate()
expect(form.submitting.value).toBe(true) expect(form.submitting.value).toBe(true)
@@ -346,52 +327,45 @@ describe('useCategoryForm', () => {
}) })
describe('submitUpdate', () => { describe('submitUpdate', () => {
it('appelle PATCH /categories/{id} avec le payload complet (name + categoryTypes)', async () => { it('appelle PATCH /categories/{id} uniquement avec les champs modifies', async () => {
mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' }) mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' })
const form = useCategoryForm() const form = useCategoryForm()
form.loadFrom(CAT) form.loadFrom(CAT)
form.name.value = 'Vis V2' // types inchanges form.name.value = 'Vis V2' // categoryTypeId inchange
await form.submitUpdate(42)
// Payload complet : meme si seul le name change, on renvoie aussi
// les categoryTypes (PATCH full payload, cf. drawers simples).
expect(mockPatch).toHaveBeenCalledWith(
'/categories/42',
{ name: 'Vis V2', categoryTypes: ['/api/category_types/1'] },
{ toast: false },
)
})
it('envoie categoryTypes en IRI[] quand on ajoute un type', async () => {
mockPatch.mockResolvedValueOnce({ ...CAT, categoryTypes: [TYPE_VENTE, TYPE_ACHAT] })
const form = useCategoryForm()
form.loadFrom(CAT)
form.categoryTypeIds.value = [1, 2]
await form.submitUpdate(42) await form.submitUpdate(42)
expect(mockPatch).toHaveBeenCalledWith( expect(mockPatch).toHaveBeenCalledWith(
'/categories/42', '/categories/42',
{ name: CAT.name, categoryTypes: ['/api/category_types/1', '/api/category_types/2'] }, { name: 'Vis V2' }, // pas de categoryType car non modifie
{ toast: false }, { toast: false },
) )
}) })
it('envoie un PATCH complet meme sans modification (save a tout moment)', async () => { it('envoie categoryType en IRI quand seul le type a change', async () => {
mockPatch.mockResolvedValueOnce(CAT) mockPatch.mockResolvedValueOnce({ ...CAT, categoryType: TYPE_ACHAT })
const form = useCategoryForm() const form = useCategoryForm()
form.loadFrom(CAT) form.loadFrom(CAT)
// Aucune modification : le PATCH part quand meme avec le payload complet. form.categoryTypeId.value = 2
await form.submitUpdate(42)
expect(mockPatch).toHaveBeenCalledWith(
'/categories/42',
{ categoryType: '/api/category_types/2' },
{ toast: false },
)
})
it('court-circuite l appel API si aucun champ n a change', async () => {
const form = useCategoryForm()
form.loadFrom(CAT)
// Aucune modification — isDirty=false, patch payload vide.
const result = await form.submitUpdate(42) const result = await form.submitUpdate(42)
expect(mockPatch).toHaveBeenCalledWith( expect(mockPatch).not.toHaveBeenCalled()
'/categories/42', expect(result).toBeNull()
{ name: CAT.name, categoryTypes: ['/api/category_types/1'] },
{ toast: false },
)
expect(result).toEqual(CAT)
expect(form.submitting.value).toBe(false) expect(form.submitting.value).toBe(false)
}) })
@@ -464,7 +438,7 @@ describe('useCategoryForm', () => {
form.reset() form.reset()
expect(form.name.value).toBe('') expect(form.name.value).toBe('')
expect(form.categoryTypeIds.value).toEqual([]) expect(form.categoryTypeId.value).toBeNull()
expect(form.errors).toEqual({}) expect(form.errors).toEqual({})
expect(form.submitting.value).toBe(false) expect(form.submitting.value).toBe(false)
}) })
@@ -13,10 +13,9 @@
* revalide toujours (defense en profondeur). * revalide toujours (defense en profondeur).
* *
* Erreurs par champ : delegue a `useFormErrors` (convention ERP-101). Les * Erreurs par champ : delegue a `useFormErrors` (convention ERP-101). Les
* violations 422 sont mappees par `propertyPath` (`name`, `categoryTypes`) ; * violations 422 sont mappees par `propertyPath` (`name`, `categoryType`) ;
* l'erreur globale (status != 422 exploitable) part en toast. Le 409 (doublon * l'erreur globale (status != 422 exploitable) part en toast. Le 409 (doublon
* de nom GLOBAL, RG-1.07) reste un cas metier specifique : erreur inline sur * RG-1.07) reste un cas metier specifique : erreur inline sur `name` + toast.
* `name` + toast.
*/ */
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import type { Category } from '~/modules/catalog/types/category' import type { Category } from '~/modules/catalog/types/category'
@@ -43,29 +42,20 @@ export function useCategoryForm() {
// State local du formulaire — pas singleton, chaque appel a useCategoryForm // State local du formulaire — pas singleton, chaque appel a useCategoryForm
// cree son propre state (cohérent avec le pattern « un drawer = un form »). // cree son propre state (cohérent avec le pattern « un drawer = un form »).
const name = ref('') const name = ref('')
const categoryTypeIds = ref<number[]>([]) const categoryTypeId = ref<number | null>(null)
// Snapshot des valeurs initiales : sert a calculer `isDirty` pour le // Snapshot des valeurs initiales : sert a calculer `isDirty` pour le
// pattern view → edit du drawer (le bouton Enregistrer reste masque tant // pattern view → edit du drawer (le bouton Enregistrer reste masque tant
// que rien n'a change en mode consultation). // que rien n'a change en mode consultation).
const initialName = ref('') const initialName = ref('')
const initialCategoryTypeIds = ref<number[]>([]) const initialCategoryTypeId = ref<number | null>(null)
const submitting = ref(false) const submitting = ref(false)
// Compare deux listes d'ids sans tenir compte de l'ordre (la selection
// multi-types n'est pas ordonnee).
function sameIds(a: number[], b: number[]): boolean {
if (a.length !== b.length) return false
const sortedA = [...a].sort((x, y) => x - y)
const sortedB = [...b].sort((x, y) => x - y)
return sortedA.every((v, i) => v === sortedB[i])
}
const isDirty = computed( const isDirty = computed(
() => () =>
name.value !== initialName.value name.value !== initialName.value
|| !sameIds(categoryTypeIds.value, initialCategoryTypeIds.value), || categoryTypeId.value !== initialCategoryTypeId.value,
) )
/** /**
@@ -76,16 +66,15 @@ export function useCategoryForm() {
function loadFrom(category: Category | null): void { function loadFrom(category: Category | null): void {
formErrors.clearErrors() formErrors.clearErrors()
if (category) { if (category) {
const ids = category.categoryTypes.map(t => t.id)
name.value = category.name name.value = category.name
categoryTypeIds.value = [...ids] categoryTypeId.value = category.categoryType.id
initialName.value = category.name initialName.value = category.name
initialCategoryTypeIds.value = [...ids] initialCategoryTypeId.value = category.categoryType.id
} else { } else {
name.value = '' name.value = ''
categoryTypeIds.value = [] categoryTypeId.value = null
initialName.value = '' initialName.value = ''
initialCategoryTypeIds.value = [] initialCategoryTypeId.value = null
} }
} }
@@ -106,23 +95,23 @@ export function useCategoryForm() {
formErrors.setError('name', t('admin.categories.validation.nameLength')) formErrors.setError('name', t('admin.categories.validation.nameLength'))
} }
// RG-1.05 — au moins un type obligatoire. // RG-1.05 — categoryType obligatoire.
if (categoryTypeIds.value.length === 0) { if (categoryTypeId.value === null) {
formErrors.setError('categoryTypes', t('admin.categories.validation.typesRequired')) formErrors.setError('categoryType', t('admin.categories.validation.typeRequired'))
} }
return !formErrors.errors.name && !formErrors.errors.categoryTypes return !formErrors.errors.name && !formErrors.errors.categoryType
} }
/** /**
* Construit le payload POST a partir du state. Les `categoryTypes` sont * Construit le payload POST a partir du state. Le `categoryType` est
* envoyes en tableau d'IRI Hydra (`/api/category_types/{id}`) — convention * envoye en IRI Hydra (`/api/category_types/{id}`) — convention API
* API Platform pour referencer une collection de ressources liees. * Platform pour referencer une ressource liee.
*/ */
function buildCreatePayload(): Record<string, unknown> { function buildCreatePayload(): Record<string, unknown> {
return { return {
name: name.value.trim(), name: name.value.trim(),
categoryTypes: categoryTypeIds.value.map(id => `/api/category_types/${id}`), categoryType: `/api/category_types/${categoryTypeId.value}`,
} }
} }
@@ -174,18 +163,26 @@ export function useCategoryForm() {
} }
/** /**
* PATCH /api/categories/{id}. Envoie le payload complet (name + * PATCH /api/categories/{id}. Envoie uniquement les champs modifies pour
* categoryTypes), comme les autres drawers du projet : le bouton * coller a la semantique merge-patch (Content-Type pose par useApi).
* Enregistrer sauvegarde a tout moment, meme sans modification, et renvoie * Renvoie la categorie mise a jour, ou `null` en cas d'echec.
* toujours un retour (toast succes + refresh). Renvoie la categorie mise a
* jour, ou `null` en cas d'echec.
*/ */
async function submitUpdate(id: number): Promise<Category | null> { async function submitUpdate(id: number): Promise<Category | null> {
if (!validate()) return null if (!validate()) return null
submitting.value = true submitting.value = true
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {}
name: name.value.trim(), if (name.value !== initialName.value) {
categoryTypes: categoryTypeIds.value.map(id => `/api/category_types/${id}`), payload.name = name.value.trim()
}
if (categoryTypeId.value !== initialCategoryTypeId.value) {
payload.categoryType = `/api/category_types/${categoryTypeId.value}`
}
// Garde-fou : un PATCH sans changement ne sert a rien. Theoriquement
// empeche par le drawer (bouton Enregistrer masque si !isDirty) mais
// on protege le composable contre un appel direct mal utilise.
if (Object.keys(payload).length === 0) {
submitting.value = false
return null
} }
try { try {
const updated = await api.patch<Category>(`/categories/${id}`, payload, { const updated = await api.patch<Category>(`/categories/${id}`, payload, {
@@ -236,9 +233,9 @@ export function useCategoryForm() {
*/ */
function reset(): void { function reset(): void {
name.value = '' name.value = ''
categoryTypeIds.value = [] categoryTypeId.value = null
initialName.value = '' initialName.value = ''
initialCategoryTypeIds.value = [] initialCategoryTypeId.value = null
formErrors.clearErrors() formErrors.clearErrors()
submitting.value = false submitting.value = false
} }
@@ -246,7 +243,7 @@ export function useCategoryForm() {
return { return {
// State // State
name, name,
categoryTypeIds, categoryTypeId,
errors: formErrors.errors, errors: formErrors.errors,
submitting, submitting,
isDirty, isDirty,
@@ -3,28 +3,13 @@
<PageHeader> <PageHeader>
{{ t('admin.categories.title') }} {{ t('admin.categories.title') }}
<template #actions> <template #actions>
<!-- gap-8 = 32px d'espacement entre Filtres et Ajouter (meme
design que le Repertoire Clients). -->
<div class="flex items-center gap-8">
<!-- Bouton Filtres a GAUCHE d'Ajouter. Le compteur reflete
les filtres actifs. -->
<MalioButton
variant="tertiary"
:label="filterButtonLabel"
icon-name="mdi:tune"
icon-position="left"
icon-size="24"
@click="openFilters"
/>
<MalioButton <MalioButton
v-if="canManage" v-if="canManage"
variant="secondary"
:label="t('admin.categories.newCategory')" :label="t('admin.categories.newCategory')"
icon-name="mdi:add-bold" icon-name="mdi:add-bold"
icon-position="left" icon-position="left"
@click="openCreateDrawer" @click="openCreateDrawer"
/> />
</div>
</template> </template>
</PageHeader> </PageHeader>
@@ -62,60 +47,6 @@
:loading="deleting" :loading="deleting"
@confirm="handleDelete" @confirm="handleDelete"
/> />
<!-- 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('admin.categories.filters.title') }}</h2>
</template>
<MalioAccordion>
<!-- Recherche par nom (param `name`, partiel insensible a la casse). -->
<MalioAccordionItem :title="t('admin.categories.filters.search')" value="search">
<MalioInputText
v-model="draftSearch"
icon-name="mdi:magnify"
/>
</MalioAccordionItem>
<!-- Type(s) : cases a cocher (multi). Une categorie remonte si
elle porte AU MOINS UN des types coches (OR cote back). -->
<MalioAccordionItem :title="t('admin.categories.filters.types')" value="types">
<div class="flex flex-col">
<MalioCheckbox
v-for="opt in typeFilterOptions"
:id="`filter-type-${opt.value}`"
:key="opt.value"
:label="opt.label"
:model-value="draftTypeIds.includes(opt.value)"
@update:model-value="(val: boolean) => toggleType(opt.value, val)"
/>
</div>
</MalioAccordionItem>
</MalioAccordion>
<template #footer>
<MalioButton
variant="tertiary"
:label="t('admin.categories.filters.reset')"
button-class="w-m-btn-action"
@click="resetFilters"
/>
<MalioButton
variant="primary"
:label="t('admin.categories.filters.apply')"
button-class="w-[170px]"
@click="applyFilters"
/>
</template>
</MalioDrawer>
</div> </div>
</template> </template>
@@ -124,7 +55,7 @@ import type { Category } from '~/modules/catalog/types/category'
const { t } = useI18n() const { t } = useI18n()
const { can } = usePermissions() const { can } = usePermissions()
const { types, fetchTypes } = useCategoriesAdmin() const { fetchTypes } = useCategoriesAdmin()
const { submitDelete } = useCategoryForm() const { submitDelete } = useCategoryForm()
useHead({ title: t('admin.categories.title') }) useHead({ title: t('admin.categories.title') })
@@ -143,7 +74,6 @@ const {
fetch: fetchCategories, fetch: fetchCategories,
goToPage, goToPage,
setItemsPerPage, setItemsPerPage,
setFilters,
} = usePaginatedList<Category>({ url: '/categories' }) } = usePaginatedList<Category>({ url: '/categories' })
const drawerOpen = ref(false) const drawerOpen = ref(false)
@@ -152,96 +82,21 @@ const deleteModalOpen = ref(false)
const categoryToDelete = ref<Category | null>(null) const categoryToDelete = ref<Category | null>(null)
const deleting = ref(false) const deleting = ref(false)
// Colonnes du datatable. Les types sont embarques cote API (ManyToMany) — on // Colonnes du datatable. Le type est embarque cote API (cf. spec-back § 3.4) —
// aplatit en libelles joints par une virgule pour l'affichage. // on aplatit en label lisible pour l'affichage.
const columns = [ const columns = [
{ key: 'name', label: t('admin.categories.table.name') }, { key: 'name', label: t('admin.categories.table.name') },
{ key: 'typesLabel', label: t('admin.categories.table.types') }, { key: 'typeLabel', label: t('admin.categories.table.type') },
] ]
const categoryItems = computed(() => const categoryItems = computed(() =>
categories.value.map(cat => ({ categories.value.map(cat => ({
id: cat.id, id: cat.id,
name: cat.name, name: cat.name,
typesLabel: (cat.categoryTypes ?? []).map(ct => ct.label).join(', '), typeLabel: cat.categoryType?.label ?? '',
})), })),
) )
// ── Filtres (drawer) ────────────────────────────────────────────────────────
// Deux niveaux d'etat (pattern Repertoire Clients) :
// - APPLIED : pilote la liste + 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 draftTypeIds = ref<number[]>([])
const appliedSearch = ref('')
const appliedTypeIds = ref<number[]>([])
// Options du filtre Type(s), derivees du referentiel deja charge (fetchTypes).
const typeFilterOptions = computed(() =>
types.value.map(ct => ({ value: ct.id, label: ct.label })),
)
const activeFilterCount = computed(() => {
let count = 0
if (appliedSearch.value.trim() !== '') count++
if (appliedTypeIds.value.length > 0) count++
return count
})
const filterButtonLabel = computed(() => {
const base = t('admin.categories.filters.title')
return activeFilterCount.value > 0 ? `${base} (${activeFilterCount.value})` : base
})
// Recopie l'etat applique vers le brouillon puis ouvre le drawer.
function openFilters(): void {
draftSearch.value = appliedSearch.value
draftTypeIds.value = [...appliedTypeIds.value]
filterDrawerOpen.value = true
}
function toggleType(id: number, selected: boolean): void {
draftTypeIds.value = selected
? [...draftTypeIds.value, id]
: draftTypeIds.value.filter(t => t !== id)
}
/**
* Construit le payload de filtres serveur a partir de l'etat applique. Cle
* `typeId[]` pour que PHP la parse en tableau (OR cote back). Filtres vides omis.
*/
function buildFilterPayload(): Record<string, string | string[]> {
const payload: Record<string, string | string[]> = {}
if (appliedSearch.value.trim() !== '') payload.name = appliedSearch.value.trim()
if (appliedTypeIds.value.length > 0) payload['typeId[]'] = appliedTypeIds.value.map(String)
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()
appliedTypeIds.value = [...draftTypeIds.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 = ''
draftTypeIds.value = []
appliedSearch.value = ''
appliedTypeIds.value = []
setFilters({}, { replace: true })
}
function getCategoryById(id: number): Category | undefined { function getCategoryById(id: number): Category | undefined {
return categories.value.find(c => c.id === id) return categories.value.find(c => c.id === id)
} }
+10 -11
View File
@@ -4,15 +4,15 @@
* Contrats API consommes : * Contrats API consommes :
* - GET /api/categories → HydraCollection<Category> * - GET /api/categories → HydraCollection<Category>
* - GET /api/categories/{id} → Category * - GET /api/categories/{id} → Category
* - POST /api/categories → body { name, categoryTypes: IRI[] } * - POST /api/categories → body { name, categoryType: IRI }
* - PATCH /api/categories/{id} → body partiel { name?, categoryTypes?: IRI[] } * - PATCH /api/categories/{id} → body partiel { name?, categoryType?: IRI }
* - DELETE /api/categories/{id} → 204 (soft delete via CategoryProcessor) * - DELETE /api/categories/{id} → 204 (soft delete via CategoryProcessor)
* - GET /api/category_types → HydraCollection<CategoryType> * - GET /api/category_types → HydraCollection<CategoryType>
* *
* Notes : * Notes :
* - Les IRI sont envoyes en POST/PATCH (ex. ["/api/category_types/3"]). * - Les IRI sont envoyes en POST/PATCH (ex. "/api/category_types/3").
* - `categoryTypes` est embarque (groupe Serializer `category:read` sur les * - `categoryType` est embarque (groupe Serializer `category:read` sur les
* proprietes de CategoryType) : tableau d'objets type en lecture. * proprietes de CategoryType, cf. spec-back § 3.4).
* - `createdBy` / `updatedBy` peuvent etre `null` (hors contexte HTTP, * - `createdBy` / `updatedBy` peuvent etre `null` (hors contexte HTTP,
* ON DELETE SET NULL en BDD). Affichage : libelle "Systeme" si null. * ON DELETE SET NULL en BDD). Affichage : libelle "Systeme" si null.
*/ */
@@ -43,8 +43,7 @@ export interface CategoryType {
export interface Category { export interface Category {
id: number id: number
name: string name: string
/** Types de la categorie (>= 1, ManyToMany embarque en lecture). */ categoryType: CategoryType
categoryTypes: CategoryType[]
/** Soft delete : null = active, valeur = supprimee logiquement le {date}. */ /** Soft delete : null = active, valeur = supprimee logiquement le {date}. */
deletedAt: string | null deletedAt: string | null
createdAt: string createdAt: string
@@ -54,12 +53,12 @@ export interface Category {
} }
/** /**
* Payload accepte en POST /api/categories. `categoryTypes` est un tableau * Payload accepte en POST /api/categories. `categoryType` est envoye en
* d'IRI Hydra (ex. `['/api/category_types/3', '/api/category_types/5']`). * IRI Hydra (ex. `/api/category_types/3`).
*/ */
export interface CategoryCreateInput { export interface CategoryCreateInput {
name: string name: string
categoryTypes: string[] categoryType: string
} }
/** /**
@@ -68,5 +67,5 @@ export interface CategoryCreateInput {
*/ */
export interface CategoryUpdateInput { export interface CategoryUpdateInput {
name?: string name?: string
categoryTypes?: string[] categoryType?: string
} }
@@ -14,15 +14,12 @@
remplacant les 3 cases. Les options encodent les combinaisons valides remplacant les 3 cases. Les options encodent les combinaisons valides
(exclusivite Prospect, RG-1.06/07/08) ; le back recoit toujours les (exclusivite Prospect, RG-1.06/07/08) ; le back recoit toujours les
drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). --> 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 <MalioSelect
:model-value="addressType" :model-value="addressType"
:options="addressTypeOptions" :options="addressTypeOptions"
:label="t('commercial.clients.form.address.addressType')" :label="t('commercial.clients.form.address.addressType')"
:readonly="readonly" :readonly="readonly"
:required="true" :required="true"
:error="errors?.isProspect"
@update:model-value="onAddressTypeChange" @update:model-value="onAddressTypeChange"
/> />
@@ -34,7 +31,6 @@
:display-tag="true" :display-tag="true"
:readonly="readonly" :readonly="readonly"
:required="true" :required="true"
:error="errors?.sites"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
/> />
@@ -47,10 +43,9 @@
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/> />
<!-- Email(s) de facturation : visible/obligatoire seulement si Facturation <!-- Email de facturation : ligne 1 colonne 4, visible/obligatoire
(RG-1.11). Le « + » revele un 2e email optionnel (max 2, pendant du seulement si Facturation (RG-1.11). Sinon un filler comble la
telephone secondaire) qui coule dans la grille. Sinon un filler comble colonne pour que Categorie reparte au debut de la ligne 2. -->
la colonne pour que Categorie reparte au debut de la ligne suivante. -->
<MalioInputEmail <MalioInputEmail
v-if="isBillingEmailRequired(model)" v-if="isBillingEmailRequired(model)"
:model-value="model.billingEmail" :model-value="model.billingEmail"
@@ -59,23 +54,10 @@
:readonly="readonly" :readonly="readonly"
:lowercase="true" :lowercase="true"
:error="errors?.billingEmail" :error="errors?.billingEmail"
:addable="!model.hasSecondaryBillingEmail && !readonly"
:add-button-label="t('commercial.clients.form.address.addBillingEmail')"
@update:model-value="(v: string) => update('billingEmail', v)" @update:model-value="(v: string) => update('billingEmail', v)"
@add="revealSecondaryBillingEmail"
/> />
<div v-else aria-hidden="true" /> <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 <MalioSelectCheckbox
:model-value="model.categoryIris" :model-value="model.categoryIris"
:options="categoryOptions" :options="categoryOptions"
@@ -83,7 +65,6 @@
:display-tag="true" :display-tag="true"
:readonly="readonly" :readonly="readonly"
:required="true" :required="true"
:error="errors?.categories"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
/> />
@@ -106,9 +87,8 @@
@update:model-value="onPostalCodeChange" @update:model-value="onPostalCodeChange"
/> />
<!-- Ville : MalioSelect alimente par le code postal (BAN). Si la BAN est <!-- Ville : MalioSelect alimente par le code postal (BAN). En mode
indisponible, bascule en saisie libre — recuperable : re-saisir le degrade (service indisponible), bascule en saisie libre. -->
code postal relance la recherche et repasse en select au succes. -->
<MalioSelect <MalioSelect
v-if="!degraded" v-if="!degraded"
:model-value="model.city" :model-value="model.city"
@@ -135,14 +115,11 @@
sur l'input interne, pas sur la cellule de grille. Le wrapper porte sur l'input interne, pas sur la cellule de grille. Le wrapper porte
le col-span-2, le champ le remplit (w-full). --> le col-span-2, le champ le remplit (w-full). -->
<div class="col-span-2"> <div class="col-span-2">
<!-- Adresse : saisie assistee (BAN) en edition ; champ texte simple <!-- Adresse : saisie assistee (BAN) en edition ; champ texte simple en
seulement en lecture seule (MalioInputAutocomplete ne reaffiche pas mode degrade OU en lecture seule (MalioInputAutocomplete ne reaffiche
sa valeur liee, il n'afficherait rien en readonly). allow-create : pas sa valeur liee, il n'afficherait rien en readonly). -->
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 <MalioInputAutocomplete
v-if="!readonly" v-if="!degraded && !readonly"
:model-value="model.street" :model-value="model.street"
:options="addressOptions" :options="addressOptions"
:loading="addressLoading" :loading="addressLoading"
@@ -151,8 +128,6 @@
:readonly="readonly" :readonly="readonly"
:required="true" :required="true"
:error="errors?.street" :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))" @update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch" @search="onAddressSearch"
@select="onAddressSelect" @select="onAddressSelect"
@@ -168,7 +143,7 @@
/> />
</div> </div>
<div class="col-span-1"> <div class="col-span-2">
<MalioInputText <MalioInputText
:model-value="model.streetComplement" :model-value="model.streetComplement"
:label="t('commercial.clients.form.address.streetComplement')" :label="t('commercial.clients.form.address.streetComplement')"
@@ -234,8 +209,6 @@ const addressTypeOptions = computed<RefOption[]>(() => [
{ value: 'delivery', label: t('commercial.clients.form.address.addressTypeDelivery') }, { value: 'delivery', label: t('commercial.clients.form.address.addressTypeDelivery') },
{ value: 'billing', label: t('commercial.clients.form.address.addressTypeBilling') }, { value: 'billing', label: t('commercial.clients.form.address.addressTypeBilling') },
{ value: 'delivery_billing', label: t('commercial.clients.form.address.addressTypeDeliveryBilling') }, { 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). */ /** Applique le type choisi en repercutant les 3 drapeaux back (immutabilite). */
@@ -244,12 +217,8 @@ function onAddressTypeChange(value: string | number | null): void {
emit('update:modelValue', { ...props.modelValue, ...addressFlagsFromType(value as AddressType) }) emit('update:modelValue', { ...props.modelValue, ...addressFlagsFromType(value as AddressType) })
} }
// Repli saisie libre de la VILLE quand la BAN est indisponible (recuperable : // Mode degrade : service BAN indisponible → Ville/Adresse en saisie libre.
// remis a false des qu'une recherche de ville aboutit). N'affecte plus le champ
// Adresse, qui reste en autocompletion et reessaie a chaque frappe.
const degraded = ref(false) const degraded = ref(false)
// Avertissement « service indisponible » envoye au parent une seule fois.
let unavailableNotified = false
// Villes proposees par la BAN (alimentees a la saisie du code postal). // Villes proposees par la BAN (alimentees a la saisie du code postal).
const banCityOptions = ref<RefOption[]>([]) const banCityOptions = ref<RefOption[]>([])
// Adresses proposees par la BAN (alimentees a la saisie d'adresse). // Adresses proposees par la BAN (alimentees a la saisie d'adresse).
@@ -289,15 +258,10 @@ function update<K extends keyof AddressFormDraft>(field: K, value: AddressFormDr
emit('update:modelValue', { ...props.modelValue, [field]: value }) emit('update:modelValue', { ...props.modelValue, [field]: value })
} }
/** Revele le 2e champ email de facturation (clic sur le « + »). */ /** Bascule définitivement en mode degrade et previent le parent (toast unique). */
function revealSecondaryBillingEmail(): void { function enterDegraded(): void {
emit('update:modelValue', { ...props.modelValue, hasSecondaryBillingEmail: true }) if (!degraded.value) {
} degraded.value = true
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
function notifyUnavailable(): void {
if (!unavailableNotified) {
unavailableNotified = true
emit('degraded') emit('degraded')
} }
} }
@@ -306,6 +270,9 @@ function notifyUnavailable(): void {
async function onPostalCodeChange(value: string): Promise<void> { async function onPostalCodeChange(value: string): Promise<void> {
update('postalCode', value) update('postalCode', value)
if (degraded.value) {
return
}
const digits = (value ?? '').replace(/\D/g, '') const digits = (value ?? '').replace(/\D/g, '')
if (digits.length < 5) { if (digits.length < 5) {
return return
@@ -313,22 +280,15 @@ async function onPostalCodeChange(value: string): Promise<void> {
try { try {
const suggestions = await autocomplete.searchCity(digits) const suggestions = await autocomplete.searchCity(digits)
banCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city })) banCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
// Service repondu : on (re)passe la Ville en select assiste.
degraded.value = false
} }
catch { catch {
// BAN indispo : Ville en saisie libre (recuperable au prochain essai). enterDegraded()
degraded.value = true
notifyUnavailable()
} }
} }
/** Recherche d'adresse assistee (event de MalioInputAutocomplete). */ /** Recherche d'adresse assistee (event de MalioInputAutocomplete). */
async function onAddressSearch(query: string): Promise<void> { async function onAddressSearch(query: string): Promise<void> {
// La BAN exige au moins 3 caracteres : on n'envoie rien en deca (evite un 400) if (degraded.value) {
// et on vide les suggestions devenues obsoletes.
if (query.trim().length < 3) {
banAddressOptions.value = []
return return
} }
addressLoading.value = true addressLoading.value = true
@@ -339,10 +299,7 @@ async function onAddressSearch(query: string): Promise<void> {
banAddressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label })) banAddressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
} }
catch { catch {
// Erreur transitoire : on vide les suggestions, la prochaine frappe reessaie enterDegraded()
// (pas de bascule definitive — c'etait le bug). Avertissement une seule fois.
banAddressOptions.value = []
notifyUnavailable()
} }
finally { finally {
addressLoading.value = false addressLoading.value = false
@@ -26,10 +26,6 @@
:error="errors?.firstName" :error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)" @update:model-value="(v: string) => update('firstName', v)"
/> />
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
<div class="col-span-2">
<MalioInputText <MalioInputText
:model-value="model.jobTitle" :model-value="model.jobTitle"
:label="t('commercial.clients.form.contact.jobTitle')" :label="t('commercial.clients.form.contact.jobTitle')"
@@ -37,7 +33,6 @@
:error="errors?.jobTitle" :error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)" @update:model-value="(v: string) => update('jobTitle', v)"
/> />
</div>
<MalioInputEmail <MalioInputEmail
:model-value="model.email" :model-value="model.email"
:label="t('commercial.clients.form.contact.email')" :label="t('commercial.clients.form.contact.email')"
@@ -1,314 +0,0 @@
<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>
@@ -1,109 +0,0 @@
<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)"
/>
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
<div class="col-span-2">
<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)"
/>
</div>
<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>
@@ -1,21 +1,16 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { defineComponent, h, ref, computed } from 'vue' import { defineComponent, h, ref, computed } from 'vue'
import { emptyAddress } from '~/modules/commercial/types/clientForm' import { emptyAddress } from '~/modules/commercial/types/clientForm'
import ClientAddressBlock from '../ClientAddressBlock.vue' import ClientAddressBlock from '../ClientAddressBlock.vue'
// Mocks controlables du composable BAN (hoisted) : chaque test configure le // Le composable BAN est mocke : aucun appel reseau, aucune suggestion chargee.
// comportement de searchCity / searchAddress (succes, rejet, rejet-puis-succes). // On reproduit ainsi l'etat « adresse persistee, mais liste de suggestions
// Par defaut ils renvoient undefined (aucune suggestion) — etat « adresse // vide » (remontage apres validation / edition d'une adresse existante).
// persistee mais liste vide » couvert par les tests d'affichage.
const { searchCityMock, searchAddressMock } = vi.hoisted(() => ({
searchCityMock: vi.fn(),
searchAddressMock: vi.fn(),
}))
vi.mock('~/shared/composables/useAddressAutocomplete', () => ({ vi.mock('~/shared/composables/useAddressAutocomplete', () => ({
useAddressAutocomplete: () => ({ useAddressAutocomplete: () => ({
searchCity: searchCityMock, searchCity: vi.fn(),
searchAddress: searchAddressMock, searchAddress: vi.fn(),
}), }),
})) }))
@@ -36,7 +31,6 @@ const MalioInputAutocompleteStub = defineComponent({
minSearchLength: { type: Number, default: 0 }, minSearchLength: { type: Number, default: 0 },
label: { type: String, default: '' }, label: { type: String, default: '' },
readonly: { type: Boolean, default: false }, readonly: { type: Boolean, default: false },
allowCreate: { type: Boolean, default: false },
}, },
emits: ['update:modelValue', 'search', 'select'], emits: ['update:modelValue', 'search', 'select'],
setup(props) { setup(props) {
@@ -79,14 +73,6 @@ describe('ClientAddressBlock — affichage de l\'adresse persistee', () => {
expect(values).toContain('8 Boulevard du Port') 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)
})
}) })
/** /**
@@ -143,84 +129,4 @@ describe('ClientAddressBlock — mapping erreur par champ (ERP-101)', () => {
) )
expect(field?.attributes('data-error')).toBe('Code postal invalide.') 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)', () => {
beforeEach(() => {
searchAddressMock.mockReset()
})
it('n\'appelle pas la BAN en deca de 3 caracteres', async () => {
const wrapper = mountBlock(null)
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
auto.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(null)
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
// 1er essai -> erreur BAN.
auto.vm.$emit('search', 'boulevard du port')
await flushPromises()
expect(searchAddressMock).toHaveBeenCalledTimes(1)
// 2e essai -> DOIT relancer l'appel (c'etait le bug : plus aucune recherche).
auto.vm.$emit('search', 'boulevard du porte')
await flushPromises()
expect(searchAddressMock).toHaveBeenCalledTimes(2)
// L'autocompletion reste montee (aucune bascule en saisie libre).
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(null)
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)
})
}) })
@@ -1,178 +0,0 @@
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')
})
})
@@ -1,56 +0,0 @@
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('')
})
})
@@ -30,10 +30,6 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
if (url === '/sites') { if (url === '/sites') {
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault', postalCode: '86100' }] }) return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault', postalCode: '86100' }] })
} }
if (url === '/countries') {
// Pays : value === label === name (l'adresse stocke le nom).
return Promise.resolve({ member: [{ '@id': '/api/countries/1', code: 'FR', name: 'France' }] })
}
return Promise.resolve({ return Promise.resolve({
member: [{ '@id': '/api/x/1', code: 'X', label: 'Libelle X' }], member: [{ '@id': '/api/x/1', code: 'X', label: 'Libelle X' }],
}) })
@@ -48,8 +44,6 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: '86' }]) expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: '86' }])
expect(refs.tvaModes.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }]) expect(refs.tvaModes.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }])
expect(refs.banks.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }]) expect(refs.banks.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }])
// Pays : value = nom du pays (et non l'IRI).
expect(refs.countries.value).toEqual([{ value: 'France', label: 'France' }])
// Seul le select en echec reste vide. // Seul le select en echec reste vide.
expect(refs.categories.value).toEqual([]) expect(refs.categories.value).toEqual([])
@@ -1,95 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Mocks des composables auto-importes par Nuxt (indisponibles sous happy-dom).
const mockGet = vi.hoisted(() => vi.fn())
const mockPatch = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({
get: mockGet,
post: vi.fn(),
put: vi.fn(),
patch: mockPatch,
delete: vi.fn(),
}))
const { useSupplier } = await import('../useSupplier')
const SAMPLE = { '@id': '/api/suppliers/85', id: 85, companyName: 'DOD59393F 862875', isArchived: false }
describe('useSupplier', () => {
beforeEach(() => {
mockGet.mockReset()
mockPatch.mockReset()
mockGet.mockResolvedValue(SAMPLE)
mockPatch.mockResolvedValue({ ...SAMPLE, isArchived: true })
})
it('charge le detail via GET /suppliers/{id} en Hydra, sans toast', async () => {
const { supplier, load } = useSupplier(85)
await load()
expect(mockGet).toHaveBeenCalledWith(
'/suppliers/85',
{},
expect.objectContaining({
headers: { Accept: 'application/ld+json' },
toast: false,
}),
)
expect(supplier.value).toEqual(SAMPLE)
})
it('bascule loading pendant le chargement et le retombe a false', async () => {
const { loading, load } = useSupplier(85)
const promise = load()
expect(loading.value).toBe(true)
await promise
expect(loading.value).toBe(false)
})
it('marque error et laisse supplier null si le GET echoue (404...)', async () => {
mockGet.mockRejectedValueOnce(new Error('not found'))
const { supplier, error, load } = useSupplier(99)
await load()
expect(error.value).toBe(true)
expect(supplier.value).toBeNull()
})
it('archive() PATCHe { isArchived: true } sans toast puis RECHARGE le detail complet', async () => {
// 1er GET = chargement initial, 2e GET = rechargement post-archivage.
mockGet.mockResolvedValueOnce(SAMPLE)
mockGet.mockResolvedValueOnce({ ...SAMPLE, isArchived: true })
const { supplier, load, archive } = useSupplier(85)
await load()
await archive()
expect(mockPatch).toHaveBeenCalledWith(
'/suppliers/85',
{ isArchived: true },
expect.objectContaining({ toast: false }),
)
// Le detail est re-fetch (le PATCH ne renvoie pas l'embed complet).
expect(mockGet).toHaveBeenCalledTimes(2)
expect(supplier.value?.isArchived).toBe(true)
})
it('restore() PATCHe { isArchived: false } (payload isArchived SEUL)', async () => {
const { load, restore } = useSupplier(85)
await load()
await restore()
expect(mockPatch).toHaveBeenCalledWith(
'/suppliers/85',
{ isArchived: false },
expect.objectContaining({ toast: false }),
)
})
it('propage l\'erreur (ex: 403 sans permission archive, 409 conflit homonyme) au lieu de l\'avaler', async () => {
const forbidden = { response: { status: 403 } }
mockPatch.mockRejectedValueOnce(forbidden)
const { load, archive } = useSupplier(85)
await load()
await expect(archive()).rejects.toBe(forbidden)
})
})
@@ -1,63 +0,0 @@
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' }])
})
})
@@ -1,85 +0,0 @@
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 }),
)
})
})
@@ -3,7 +3,7 @@ import { ref } from 'vue'
/** /**
* Charge les referentiels (listes courtes) alimentant les selects de l'ecran * Charge les referentiels (listes courtes) alimentant les selects de l'ecran
* « Ajouter un client » : categories, sites, modes de TVA, delais et types de * « Ajouter un client » : categories, sites, modes de TVA, delais et types de
* reglement, banques, pays, et les listes distributeurs / courtiers. * reglement, banques, et les listes distributeurs / courtiers.
* *
* Toutes les collections sont recuperees en entier via l'echappatoire prevue * Toutes les collections sont recuperees en entier via l'echappatoire prevue
* `?pagination=false` (referentiels de quelques dizaines d'entrees max), avec * `?pagination=false` (referentiels de quelques dizaines d'entrees max), avec
@@ -57,11 +57,6 @@ interface ClientMember extends HydraMember {
companyName: string companyName: string
} }
interface CountryMember extends HydraMember {
code: string
name: string
}
const LD_JSON_HEADERS = { Accept: 'application/ld+json' } const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
export function useClientReferentials() { export function useClientReferentials() {
@@ -73,7 +68,6 @@ export function useClientReferentials() {
const paymentDelays = ref<RefOption[]>([]) const paymentDelays = ref<RefOption[]>([])
const paymentTypes = ref<PaymentTypeOption[]>([]) const paymentTypes = ref<PaymentTypeOption[]>([])
const banks = ref<RefOption[]>([]) const banks = ref<RefOption[]>([])
const countries = ref<RefOption[]>([])
const distributors = ref<ClientOption[]>([]) const distributors = ref<ClientOption[]>([])
const brokers = ref<ClientOption[]>([]) const brokers = ref<ClientOption[]>([])
@@ -105,9 +99,7 @@ export function useClientReferentials() {
*/ */
async function loadCommon(): Promise<void> { async function loadCommon(): Promise<void> {
await Promise.allSettled([ await Promise.allSettled([
// Taxonomie multi-types (ERP-84) : un client ne porte que des categories fetchAll<CategoryMember>('/categories')
// de type CLIENT (pas FOURNISSEUR) -> on filtre la collection cote API.
fetchAll<CategoryMember>('/categories', { typeCode: 'CLIENT' })
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }), .then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
fetchAll<SiteMember>('/sites') fetchAll<SiteMember>('/sites')
// Libelle = numero de departement (2 premiers chiffres du code // Libelle = numero de departement (2 premiers chiffres du code
@@ -122,12 +114,6 @@ export function useClientReferentials() {
.then((types) => { paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code })) }), .then((types) => { paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code })) }),
fetchAll<ReferentialMember>('/banks') fetchAll<ReferentialMember>('/banks')
.then((banksList) => { banks.value = banksList.map(b => ({ value: b['@id'], label: b.label })) }), .then((banksList) => { banks.value = banksList.map(b => ({ value: b['@id'], label: b.label })) }),
// Pays (ERP-116) : la valeur d'option est le NOM du pays (et non l'IRI),
// car l'adresse stocke `country` en chaine libre (« France »...). On
// conserve ainsi la compatibilite avec les adresses existantes sans FK
// ni migration de donnees a ce stade. value === label.
fetchAll<CountryMember>('/countries')
.then((list) => { countries.value = list.map(c => ({ value: c.name, label: c.name })) }),
]) ])
} }
@@ -156,7 +142,6 @@ export function useClientReferentials() {
paymentDelays, paymentDelays,
paymentTypes, paymentTypes,
banks, banks,
countries,
distributors, distributors,
brokers, brokers,
loadCommon, loadCommon,
@@ -1,71 +0,0 @@
import { ref } from 'vue'
import type { SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
/**
* Chargement et actions d'archivage d'un fournisseur unique (ecran « Consultation
* fournisseur », ERP-95). Miroir de `useClient` (M1). Lit le detail embarque via
* `GET /api/suppliers/{id}` (contacts / adresses / ribs sous `supplier:item:read` /
* `supplier:read:accounting`) et expose les bascules d'archivage (PATCH `isArchived`
* SEUL — tout autre champ => 422).
*
* L'en-tete `Accept: application/ld+json` est impose pour obtenir le payload
* Hydra complet (sans lui, API Platform 4 renvoie une representation reduite).
*
* Etat 100 % local a l'instance (refs) — aucune persistance URL. Les erreurs
* d'archivage/restauration (notamment le 409 d'homonyme actif a la restauration)
* sont PROPAGEES a l'appelant, qui decide du toast a afficher.
*/
export function useSupplier(id: number | string) {
const api = useApi()
const supplier = ref<SupplierDetail | null>(null)
const loading = ref(false)
const error = ref(false)
/** Recupere le detail complet (embed contacts/adresses/ribs + comptabilite). */
function fetchDetail(): Promise<SupplierDetail> {
return api.get<SupplierDetail>(
`/suppliers/${id}`,
{},
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
}
/** Charge le detail du fournisseur. En cas d'echec : `error = true`, `supplier = null`. */
async function load(): Promise<void> {
loading.value = true
error.value = false
try {
supplier.value = await fetchDetail()
}
catch {
error.value = true
supplier.value = null
}
finally {
loading.value = false
}
}
/**
* Bascule l'archivage (PATCH `isArchived` SEUL — tout autre champ => 422),
* puis RECHARGE le detail complet : la reponse du PATCH ne porte que le groupe
* `supplier:read` (ni l'embed contacts/adresses/ribs ni les libelles des
* referentiels comptables), un simple merge laisserait l'affichage incoherent.
* Toute erreur (notamment le 409 d'homonyme actif a la restauration) est
* propagee a l'appelant AVANT le rechargement.
*/
async function setArchived(isArchived: boolean): Promise<void> {
await api.patch(`/suppliers/${id}`, { isArchived }, { toast: false })
supplier.value = await fetchDetail()
}
return {
supplier,
loading,
error,
load,
archive: () => setArchived(true),
restore: () => setArchived(false),
}
}
@@ -1,88 +0,0 @@
/**
* 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,
}
}
@@ -1,132 +0,0 @@
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
}
interface CountryMember extends HydraMember {
code: string
name: 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[]>([])
const countries = 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 })) }),
// Pays (ERP-116) : la valeur d'option est le NOM du pays (et non l'IRI),
// car l'adresse stocke `country` en chaine libre (« France »...). On
// conserve ainsi la compatibilite avec les adresses existantes sans FK
// ni migration de donnees a ce stade. value === label. Aligne sur les
// clients (`useClientReferentials`) pour une liste de pays identique.
fetchAll<CountryMember>('/countries')
.then((list) => { countries.value = list.map(c => ({ value: c.name, label: c.name })) }),
])
}
return {
categories,
sites,
tvaModes,
paymentDelays,
paymentTypes,
banks,
countries,
loadCommon,
}
}
@@ -1,54 +0,0 @@
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' })
}
@@ -1,205 +0,0 @@
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 })
})
})
@@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<!-- En-tete : retour repertoire + nom du client. --> <!-- En-tete : retour repertoire + nom du client. -->
<div class="flex items-center gap-3 pt-11"> <div class="flex items-center gap-3">
<MalioButtonIcon <MalioButtonIcon
icon="mdi:arrow-left-bold" icon="mdi:arrow-left-bold"
icon-size="24" icon-size="24"
@@ -9,7 +9,7 @@
v-bind="{ ariaLabel: t('commercial.clients.edit.back') }" v-bind="{ ariaLabel: t('commercial.clients.edit.back') }"
@click="goBack" @click="goBack"
/> />
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1> <h1 class="text-[32px] font-bold text-m-primary">{{ headerTitle }}</h1>
</div> </div>
<!-- Etats de chargement / introuvable. --> <!-- Etats de chargement / introuvable. -->
@@ -41,7 +41,6 @@
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)" @update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
/> />
<MalioSelect <MalioSelect
v-if="showRelationAndTriage"
:model-value="main.relationType" :model-value="main.relationType"
:options="relationOptions" :options="relationOptions"
:label="t('commercial.clients.form.main.relation')" :label="t('commercial.clients.form.main.relation')"
@@ -50,7 +49,7 @@
@update:model-value="onRelationChange" @update:model-value="onRelationChange"
/> />
<MalioSelect <MalioSelect
v-if="showRelationAndTriage && main.relationType === 'courtier'" v-if="main.relationType === 'courtier'"
:model-value="main.brokerIri" :model-value="main.brokerIri"
:options="brokerOptions" :options="brokerOptions"
:label="t('commercial.clients.form.main.brokerName')" :label="t('commercial.clients.form.main.brokerName')"
@@ -60,7 +59,7 @@
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
/> />
<MalioSelect <MalioSelect
v-if="showRelationAndTriage && main.relationType === 'distributeur'" v-if="main.relationType === 'distributeur'"
:model-value="main.distributorIri" :model-value="main.distributorIri"
:options="distributorOptions" :options="distributorOptions"
:label="t('commercial.clients.form.main.distributorName')" :label="t('commercial.clients.form.main.distributorName')"
@@ -70,7 +69,6 @@
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
/> />
<MalioCheckbox <MalioCheckbox
v-if="showRelationAndTriage"
v-model="main.triageService" v-model="main.triageService"
:label="t('commercial.clients.form.main.triageService')" :label="t('commercial.clients.form.main.triageService')"
group-class="self-center" group-class="self-center"
@@ -82,7 +80,7 @@
<MalioButton <MalioButton
variant="primary" variant="primary"
:label="t('commercial.clients.edit.save')" :label="t('commercial.clients.edit.save')"
:disabled="mainSubmitting" :disabled="!isMainValid || mainSubmitting"
@click="submitMain" @click="submitMain"
/> />
</div> </div>
@@ -92,14 +90,11 @@
<!-- Onglet Information --> <!-- Onglet Information -->
<template #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)]"> <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)]">
<!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas
sur les inputs (champ 40px centre dans un h-12 -> ~4px de
coussin de chaque cote). -->
<MalioInputTextArea <MalioInputTextArea
v-model="information.description" v-model="information.description"
:label="t('commercial.clients.form.information.description')" :label="t('commercial.clients.form.information.description')"
resize="none" resize="none"
group-class="row-span-2 pt-1 pb-1" group-class="row-span-2 pt-1"
text-input="h-full text-lg" text-input="h-full text-lg"
:readonly="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.description" :error="informationErrors.errors.description"
@@ -114,7 +109,6 @@
v-model="information.foundedAt" v-model="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')" :label="t('commercial.clients.form.information.foundedAt')"
:readonly="businessReadonly" :readonly="businessReadonly"
:editable="true"
:error="informationErrors.errors.foundedAt" :error="informationErrors.errors.foundedAt"
/> />
<MalioInputText <MalioInputText
@@ -179,7 +173,7 @@
<MalioButton <MalioButton
variant="primary" variant="primary"
:label="t('commercial.clients.edit.save')" :label="t('commercial.clients.edit.save')"
:disabled="tabSubmitting" :disabled="!canValidateContacts || tabSubmitting"
@click="submitContacts" @click="submitContacts"
/> />
</div> </div>
@@ -211,13 +205,12 @@
icon-name="mdi:add-bold" icon-name="mdi:add-bold"
icon-position="left" icon-position="left"
:label="t('commercial.clients.form.address.add')" :label="t('commercial.clients.form.address.add')"
:disabled="!canAddAddress"
@click="addAddress" @click="addAddress"
/> />
<MalioButton <MalioButton
variant="primary" variant="primary"
:label="t('commercial.clients.edit.save')" :label="t('commercial.clients.edit.save')"
:disabled="tabSubmitting" :disabled="!canValidateAddresses || tabSubmitting"
@click="submitAddresses" @click="submitAddresses"
/> />
</div> </div>
@@ -296,14 +289,14 @@
</div> </div>
</div> </div>
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-1.13). --> <!-- Blocs RIB (0..n) — obligatoires si type de reglement = LCR (RG-1.13). -->
<div <div
v-for="(rib, index) in visibleRibs" v-for="(rib, index) in ribs"
:key="rib.id ?? `new-${index}`" :key="rib.id ?? `new-${index}`"
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]" class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
> >
<MalioButtonIcon <MalioButtonIcon
v-if="!accountingReadonly && visibleRibs.length > 1" v-if="!accountingReadonly"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="absolute top-3 right-3" button-class="absolute top-3 right-3"
@@ -337,18 +330,16 @@
<div v-if="!accountingReadonly" class="flex justify-center gap-6"> <div v-if="!accountingReadonly" class="flex justify-center gap-6">
<MalioButton <MalioButton
v-if="isRibRequired"
variant="secondary" variant="secondary"
icon-name="mdi:add-bold" icon-name="mdi:add-bold"
icon-position="left" icon-position="left"
:label="t('commercial.clients.form.accounting.addRib')" :label="t('commercial.clients.form.accounting.addRib')"
:disabled="!canAddRib"
@click="addRib" @click="addRib"
/> />
<MalioButton <MalioButton
variant="primary" variant="primary"
:label="t('commercial.clients.edit.save')" :label="t('commercial.clients.edit.save')"
:disabled="tabSubmitting" :disabled="!canValidateAccounting || tabSubmitting"
@click="submitAccounting" @click="submitAccounting"
/> />
</div> </div>
@@ -388,7 +379,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { useClient } from '~/modules/commercial/composables/useClient' import { useClient } from '~/modules/commercial/composables/useClient'
import { useClientReferentials, type CategoryOption, type RefOption } from '~/modules/commercial/composables/useClientReferentials' import { useClientReferentials, type CategoryOption, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors' import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors'
@@ -419,16 +410,16 @@ import {
type MainFormDraft, type MainFormDraft,
} from '~/modules/commercial/utils/clientEdit' } from '~/modules/commercial/utils/clientEdit'
import { import {
addressTypeFromFlags,
buildClientFormTabKeys, buildClientFormTabKeys,
isAddressValid, hasAllRequiredAccountingFields,
hasAtLeastOneValidContact,
isBankRequiredForPaymentType, isBankRequiredForPaymentType,
isBillingEmailRequired, isBillingEmailRequired,
isContactBlank, isContactBlank,
isContactNamed, isContactNamed,
isRibBlank, isRibBlank,
isRibComplete,
isRibRequiredForPaymentType, isRibRequiredForPaymentType,
showsRelationAndTriageFields,
} from '~/modules/commercial/utils/clientFormRules' } from '~/modules/commercial/utils/clientFormRules'
import { import {
emptyAddress, emptyAddress,
@@ -439,7 +430,6 @@ import {
type RibFormDraft, type RibFormDraft,
} from '~/modules/commercial/types/clientForm' } from '~/modules/commercial/types/clientForm'
import { extractApiErrorMessage } from '~/shared/utils/api' import { extractApiErrorMessage } from '~/shared/utils/api'
import { readHistoryTab } from '~/shared/utils/historyTab'
// Masques de saisie (la normalisation finale reste serveur). // Masques de saisie (la normalisation finale reste serveur).
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
@@ -510,9 +500,7 @@ function hydrate(detail: ClientDetail): void {
// un bloc vierge (non persiste tant qu'incomplet — cf. submit*/canValidate*). // un bloc vierge (non persiste tant qu'incomplet — cf. submit*/canValidate*).
if (contacts.value.length === 0) contacts.value.push(emptyContact()) if (contacts.value.length === 0) contacts.value.push(emptyContact())
if (addresses.value.length === 0) addresses.value.push(emptyAddress()) if (addresses.value.length === 0) addresses.value.push(emptyAddress())
// RIB : amorce un bloc vide seulement si le type de reglement est une LCR if (ribs.value.length === 0) ribs.value.push(emptyRib())
// (sinon la section reste masquee — RG-1.13).
if (isRibRequired.value && ribs.value.length === 0) ribs.value.push(emptyRib())
// Charge les listes distributeur / courtier si une relation est deja posee. // Charge les listes distributeur / courtier si une relation est deja posee.
if (main.relationType === 'distributeur') referentials.loadDistributors().catch(() => {}) if (main.relationType === 'distributeur') referentials.loadDistributors().catch(() => {})
if (main.relationType === 'courtier') referentials.loadBrokers().catch(() => {}) if (main.relationType === 'courtier') referentials.loadBrokers().catch(() => {})
@@ -553,49 +541,16 @@ const contactOptions = computed<RefOption[]>(() =>
})), })),
) )
// Pays : referentiel `country` charge via l'API (ERP-116), en remplacement de const countryOptions: RefOption[] = [
// l'ancienne liste codee en dur. Valeur = nom du pays (l'adresse stocke { value: 'France', label: 'France' },
// `country` en chaine libre, donc value === label). On merge la valeur deja { value: 'Espagne', label: 'Espagne' },
// stockee sur chaque adresse (embed) — comme les autres selects de cet ecran — ]
// pour ne pas vider le select si `/countries` echoue (resilience ERP-102) ou si
// un pays historique n'appartient pas au referentiel.
const embedCountryOptions = computed<RefOption[]>(() =>
mergeOptions([], (client.value?.addresses ?? [])
.map(a => a.country)
.filter((c): c is string => !!c)
.map(c => ({ value: c, label: c }))),
)
const countryOptions = computed<RefOption[]>(() =>
mergeOptions(referentials.countries.value, embedCountryOptions.value),
)
const relationOptions = computed<RefOption[]>(() => [ const relationOptions = computed<RefOption[]>(() => [
{ value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') }, { value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') },
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') }, { value: 'courtier', label: t('commercial.clients.form.main.relationBroker') },
]) ])
// Codes des categories selectionnees (resolus depuis l'union referentiel + embed).
const selectedCategoryCodes = computed(() =>
main.categoryIris
.map(iri => mainCategoryOptions.value.find(c => c.value === iri)?.code)
.filter((code): code is string => code !== undefined),
)
// « Relation » + « Prestation de triage » masques par defaut, reveles des qu'une
// categorie ordinaire (autre que Distributeur/Courtier) est selectionnee.
const showRelationAndTriage = computed(() => showsRelationAndTriageFields(selectedCategoryCodes.value))
// Masquer ces champs reinitialise leurs valeurs : pas de relation/triage fantome
// soumis pour un client Distributeur/Courtier.
watch(showRelationAndTriage, (visible) => {
if (!visible) {
main.relationType = null
main.distributorIri = null
main.brokerIri = null
main.triageService = false
}
})
// Distributeur / courtier : referentiel charge a la demande UNION valeur courante. // Distributeur / courtier : referentiel charge a la demande UNION valeur courante.
const currentDistributorOption = computed<RefOption[]>(() => { const currentDistributorOption = computed<RefOption[]>(() => {
const d = client.value?.distributor const d = client.value?.distributor
@@ -637,13 +592,11 @@ const tabs = computed(() => tabKeys.value.map(key => ({
icon: TAB_ICONS[key], icon: TAB_ICONS[key],
}))) })))
// Onglet initial : repris de la consultation (history.state), sinon Information. const activeTab = ref('information')
const activeTab = ref(readHistoryTab(tabKeys.value) ?? 'information')
// ── Navigation ────────────────────────────────────────────────────────────── // ── Navigation ──────────────────────────────────────────────────────────────
/** Retour consultation en conservant l'onglet courant (via history.state). */
function goBack(): void { function goBack(): void {
router.push({ path: `/clients/${clientId}`, state: { tab: activeTab.value } }) router.push(`/clients/${clientId}`)
} }
/** /**
@@ -683,6 +636,17 @@ const {
} = useClientFormErrors() } = useClientFormErrors()
// ── Bloc principal ─────────────────────────────────────────────────────────── // ── 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> { async function onRelationChange(value: string | number | null): Promise<void> {
const relation = (value === null || value === '') ? null : (String(value) as 'distributeur' | 'courtier') const relation = (value === null || value === '') ? null : (String(value) as 'distributeur' | 'courtier')
main.relationType = relation main.relationType = relation
@@ -696,11 +660,11 @@ async function onRelationChange(value: string | number | null): Promise<void> {
/** PATCH /clients/{id} — groupe client:write:main UNIQUEMENT (mode strict). */ /** PATCH /clients/{id} — groupe client:write:main UNIQUEMENT (mode strict). */
async function submitMain(): Promise<void> { async function submitMain(): Promise<void> {
if (businessReadonly.value || mainSubmitting.value) return if (businessReadonly.value || !isMainValid.value || mainSubmitting.value) return
mainSubmitting.value = true mainSubmitting.value = true
mainErrors.clearErrors() mainErrors.clearErrors()
try { try {
const updated = await api.patch<ClientDetail>(`/clients/${clientId}`, buildMainPayload(main, { forUpdate: true }), { const updated = await api.patch<ClientDetail>(`/clients/${clientId}`, buildMainPayload(main), {
headers: { Accept: 'application/ld+json' }, headers: { Accept: 'application/ld+json' },
toast: false, toast: false,
}) })
@@ -749,6 +713,9 @@ const canAddContact = computed(() => {
const last = contacts.value[contacts.value.length - 1] const last = contacts.value[contacts.value.length - 1]
return last === undefined || isContactNamed(last) 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 { function addContact(): void {
if (canAddContact.value) contacts.value.push(emptyContact()) if (canAddContact.value) contacts.value.push(emptyContact())
} }
@@ -770,7 +737,7 @@ function askRemoveContact(index: number): void {
* collection contacts (endpoints client_contact dedies). * collection contacts (endpoints client_contact dedies).
*/ */
async function submitContacts(): Promise<void> { async function submitContacts(): Promise<void> {
if (businessReadonly.value || tabSubmitting.value) return if (businessReadonly.value || !canValidateContacts.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
contactErrors.value = [] contactErrors.value = []
try { try {
@@ -779,11 +746,6 @@ async function submitContacts(): Promise<void> {
} }
removedContactIds.value = [] 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 // On tente TOUS les blocs (collecte des erreurs par index, ERP-110). Seuls
// les blocs TOTALEMENT vides sont ignores : un bloc partiellement rempli // les blocs TOTALEMENT vides sont ignores : un bloc partiellement rempli
// sans nom (email seul) est soumis -> 422 RG-1.05 inline sous le bloc. // sans nom (email seul) est soumis -> 422 RG-1.05 inline sous le bloc.
@@ -806,10 +768,10 @@ async function submitContacts(): Promise<void> {
} }
}, },
error => showError(error), error => showError(error),
// On ne saute une amorce neuve (id null) totalement vide QUE si un autre // On ne saute QUE les amorces neuves (id null) totalement vides. Un
// bloc sera soumis : sinon on la soumet pour declencher la 422 RG-1.05 // bloc existant vide est soumis -> 422 RG-1.05 inline (sinon la modif
// (un onglet Contact vide ne doit pas passer en faux succes). // serait perdue en silence avec un faux toast de succes).
contact => hasSubmittableContact && contact.id === null && isContactBlank(contact), contact => contact.id === null && isContactBlank(contact),
) )
// Tant qu'un bloc reste en erreur : pas de toast succes. // Tant qu'un bloc reste en erreur : pas de toast succes.
if (hasError) return if (hasError) return
@@ -824,14 +786,19 @@ async function submitContacts(): Promise<void> {
} }
// ── Onglet Adresse ─────────────────────────────────────────────────────────── // ── Onglet Adresse ───────────────────────────────────────────────────────────
// « + Adresse » desactive tant que la derniere adresse n'est pas valide. const canValidateAddresses = computed(() =>
const canAddAddress = computed(() => { addresses.value.length > 0
const last = addresses.value[addresses.value.length - 1] && addresses.value.every((a) => {
return last !== undefined && isAddressValid(last) const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== ''
}) return addressTypeFromFlags(a) !== null
&& a.siteIris.length >= 1
&& a.categoryIris.length >= 1
&& (!isBillingEmailRequired(a) || filledBillingEmail)
}),
)
function addAddress(): void { function addAddress(): void {
if (canAddAddress.value) addresses.value.push(emptyAddress()) addresses.value.push(emptyAddress())
} }
function askRemoveAddress(index: number): void { function askRemoveAddress(index: number): void {
@@ -856,7 +823,7 @@ function onAddressDegraded(): void {
/** Valide l'onglet Adresse : DELETE des adresses retirees puis POST/PATCH. */ /** Valide l'onglet Adresse : DELETE des adresses retirees puis POST/PATCH. */
async function submitAddresses(): Promise<void> { async function submitAddresses(): Promise<void> {
if (businessReadonly.value || tabSubmitting.value) return if (businessReadonly.value || !canValidateAddresses.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
addressErrors.value = [] addressErrors.value = []
try { try {
@@ -870,10 +837,7 @@ async function submitAddresses(): Promise<void> {
addresses.value, addresses.value,
addressErrors, addressErrors,
async (address) => { async (address) => {
// Edition d'une adresse existante : champ requis vide envoye en `''` const body = buildAddressPayload(address, isBillingEmailRequired(address))
// (NotBlank 422) au lieu d'etre omis — sinon le PATCH garderait
// l'ancienne valeur (faux 200). Creation (id null) : omit classique.
const body = buildAddressPayload(address, isBillingEmailRequired(address), { forUpdate: address.id !== null })
if (address.id === null) { if (address.id === null) {
const created = await api.post<{ id: number }>( const created = await api.post<{ id: number }>(
`/clients/${clientId}/addresses`, `/clients/${clientId}/addresses`,
@@ -906,34 +870,25 @@ const selectedPaymentTypeCode = computed(() =>
const isBankRequired = computed(() => isBankRequiredForPaymentType(selectedPaymentTypeCode.value)) const isBankRequired = computed(() => isBankRequiredForPaymentType(selectedPaymentTypeCode.value))
const isRibRequired = computed(() => isRibRequiredForPaymentType(selectedPaymentTypeCode.value)) const isRibRequired = computed(() => isRibRequiredForPaymentType(selectedPaymentTypeCode.value))
// Les blocs RIB ne sont affiches que pour une LCR (RG-1.13).
const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
function onPaymentTypeChange(value: string | number | null): void { function onPaymentTypeChange(value: string | number | null): void {
accounting.paymentTypeIri = value === null ? null : String(value) accounting.paymentTypeIri = value === null ? null : String(value)
if (!isBankRequired.value) accounting.bankIri = null if (!isBankRequired.value) accounting.bankIri = null
// ERP-121 : un RIB est une coordonnee bancaire du client, decouplee du mode de
// reglement. Au passage hors-LCR on ne SUPPRIME plus les RIB existants : ils
// restent en base, simplement masques a l'ecran (visibleRibs = []), et
// reapparaissent tels quels si l'on repasse en LCR. Seule la corbeille d'un
// bloc (askRemoveRib) retire reellement un RIB.
if (isRibRequired.value) {
if (ribs.value.length === 0) ribs.value.push(emptyRib())
}
else {
// Hors-LCR : on nettoie seulement les erreurs inline (plus affichees).
ribErrors.value = []
}
} }
// « + RIB » desactive tant que le dernier bloc RIB n'est pas complet. function ribIsComplete(rib: { label: string | null, bic: string | null, iban: string | null }): boolean {
const canAddRib = computed(() => { const filled = (v: string | null) => v !== null && v.trim() !== ''
const last = ribs.value[ribs.value.length - 1] return filled(rib.label) && filled(rib.bic) && filled(rib.iban)
return last !== undefined && isRibComplete(last) }
const canValidateAccounting = computed(() => {
if (!hasAllRequiredAccountingFields(accounting)) return false
if (isBankRequired.value && accounting.bankIri === null) return false
if (isRibRequired.value && !ribs.value.some(ribIsComplete)) return false
return true
}) })
function addRib(): void { function addRib(): void {
if (canAddRib.value) ribs.value.push(emptyRib()) ribs.value.push(emptyRib())
} }
function askRemoveRib(index: number): void { function askRemoveRib(index: number): void {
@@ -948,40 +903,42 @@ function askRemoveRib(index: number): void {
} }
/** /**
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS * Valide l'onglet Comptabilite : PATCH des scalaires (groupe client:write:accounting,
* PATCH des scalaires (groupe client:write:accounting, exige accounting.manage cote * exige accounting.manage cote back) PUIS DELETE/POST/PATCH des RIB sur la
* back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le back * sous-ressource. Aucun champ main/information dans le payload (mode strict
* valide RG-1.13 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
*
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES
* (corbeille d'un bloc, toujours sous LCR), plus l'auto-suppression au changement
* de type de reglement. Aucun champ main/information dans le payload (mode strict
* RG-1.28 : sinon 403 sur tout le payload). * RG-1.28 : sinon 403 sur tout le payload).
*/ */
async function submitAccounting(): Promise<void> { async function submitAccounting(): Promise<void> {
if (accountingReadonly.value || tabSubmitting.value) return if (accountingReadonly.value || !canValidateAccounting.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
accountingErrors.clearErrors() accountingErrors.clearErrors()
// Reset des erreurs RIB des le debut : l'etape 1 (PATCH scalaires) peut
// echouer et `return` avant submitRows (qui porte sinon le reset), laissant
// des erreurs de RIB obsoletes affichees sous les blocs.
ribErrors.value = []
try { try {
// 1) POST/PATCH des RIB d'abord — UNIQUEMENT sous LCR (erreurs inline par // 1) PATCH des scalaires comptables (erreurs inline sur leurs champs).
// ligne, tous les blocs tentes). Le back exige >=1 RIB persiste pour valider try {
// une LCR a l'etape 2. Hors-LCR (ERP-121), les RIB sont des coordonnees await api.patch(`/clients/${clientId}`, buildAccountingPayload(accounting, isBankRequired.value), { toast: false })
// dormantes : rien d'editable n'est affiche, on ne les re-soumet pas. }
// On ne saute une amorce neuve vide QUE s'il reste un autre RIB soumettable : catch (error) {
// sinon (ex. l'unique RIB existant supprime, remplace par un bloc vide), on la accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
// soumet pour declencher la 422 NotBlank inline plutot que de laisser le DELETE return
// echouer en « dernier RIB d'une LCR » (message plat sans propertyPath). }
if (isRibRequired.value) {
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r)) 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).
// 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( const ribHasError = await submitRows(
ribs.value, ribs.value,
ribErrors, ribErrors,
async (rib) => { async (rib) => {
// Edition d'un RIB existant : champ requis vide envoye en `''` (NotBlank const body = buildRibPayload(rib)
// 422) au lieu d'etre omis (sinon le PATCH garderait l'ancienne valeur).
const body = buildRibPayload(rib, { forUpdate: rib.id !== null })
if (rib.id === null) { if (rib.id === null) {
const created = await api.post<{ id: number }>( const created = await api.post<{ id: number }>(
`/clients/${clientId}/ribs`, `/clients/${clientId}/ribs`,
@@ -995,31 +952,12 @@ async function submitAccounting(): Promise<void> {
} }
}, },
error => showError(error), error => showError(error),
// On ne saute une amorce neuve (id null) totalement vide que si un autre RIB // On ne saute QUE les amorces neuves (id null) totalement vides. Un
// est soumettable. Un RIB existant vide est toujours soumis -> 422 NotBlank // RIB existant vide est soumis -> 422 NotBlank inline (sinon la modif
// inline (sinon la modif serait perdue en silence avec un faux toast succes). // serait perdue en silence avec un faux toast de succes).
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib), rib => rib.id === null && isRibBlank(rib),
) )
if (ribHasError) return 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 explicitement retires (corbeille d'un bloc) : APRES le
// PATCH scalaires (le guard back refuse la suppression du dernier RIB d'une
// LCR). ERP-121 : plus aucune suppression automatique au passage hors-LCR.
for (const id of removedRibIds.value) {
await api.delete(`/client_ribs/${id}`, {}, { toast: false })
}
removedRibIds.value = []
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch (e) { catch (e) {
@@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<!-- En-tete : retour repertoire + nom du client + actions (Modifier / Archiver|Restaurer). --> <!-- En-tete : retour repertoire + nom du client + actions (Modifier / Archiver|Restaurer). -->
<div class="flex items-center gap-3 pt-11"> <div class="flex items-center gap-3">
<MalioButtonIcon <MalioButtonIcon
icon="mdi:arrow-left-bold" icon="mdi:arrow-left-bold"
icon-size="24" icon-size="24"
@@ -9,7 +9,7 @@
v-bind="{ ariaLabel: t('commercial.clients.consultation.back') }" v-bind="{ ariaLabel: t('commercial.clients.consultation.back') }"
@click="goBack" @click="goBack"
/> />
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1> <h1 class="text-[32px] font-bold text-m-primary">{{ headerTitle }}</h1>
<!-- gap-12 = 48px : meme espacement que Ajouter / Filtres du repertoire. --> <!-- gap-12 = 48px : meme espacement que Ajouter / Filtres du repertoire. -->
<div class="ml-auto flex items-center gap-12"> <div class="ml-auto flex items-center gap-12">
@@ -88,14 +88,11 @@
<!-- Onglet Information --> <!-- Onglet Information -->
<template #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)]"> <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)]">
<!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas
sur les inputs (champ 40px centre dans un h-12 -> ~4px de
coussin de chaque cote). -->
<MalioInputTextArea <MalioInputTextArea
:model-value="information.description" :model-value="information.description"
:label="t('commercial.clients.form.information.description')" :label="t('commercial.clients.form.information.description')"
resize="none" resize="none"
group-class="row-span-2 pt-1 pb-1" group-class="row-span-2 pt-1"
text-input="h-full text-lg" text-input="h-full text-lg"
readonly readonly
/> />
@@ -280,8 +277,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useClient } from '~/modules/commercial/composables/useClient' import { useClient } from '~/modules/commercial/composables/useClient'
import { buildClientFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/clientFormRules' import { buildClientFormTabKeys } from '~/modules/commercial/utils/clientFormRules'
import { readHistoryTab } from '~/shared/utils/historyTab'
import { import {
canEditClient, canEditClient,
categoryOptionsOf, categoryOptionsOf,
@@ -290,7 +286,6 @@ import {
mapAddressView, mapAddressView,
mapContactToDraft, mapContactToDraft,
mapRibToDraft, mapRibToDraft,
paymentTypeCodeOf,
referentialOptionOf, referentialOptionOf,
relationOf, relationOf,
showArchiveAction, showArchiveAction,
@@ -298,7 +293,7 @@ import {
type ClientDetail, type ClientDetail,
type SelectOption, type SelectOption,
} from '~/modules/commercial/utils/clientConsultation' } from '~/modules/commercial/utils/clientConsultation'
import { emptyAddress, emptyContact } from '~/modules/commercial/types/clientForm' import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/clientForm'
// Masque d'affichage (purement visuel, la donnee reste celle du serveur). // Masque d'affichage (purement visuel, la donnee reste celle du serveur).
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
@@ -355,17 +350,10 @@ const addressViews = computed(() => {
const views = (client.value?.addresses ?? []).map(mapAddressView) const views = (client.value?.addresses ?? []).map(mapAddressView)
return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }] return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }]
}) })
// Exception au placeholder ci-dessus : on n'affiche AUCUN bloc RIB quand le const ribs = computed(() => {
// client n'en a pas. Pas de bloc vierge fantome en consultation. const list = (client.value?.ribs ?? []).map(mapRibToDraft)
// ERP-121 : un client peut desormais conserver des RIB « dormants » apres etre return list.length ? list : [emptyRib()]
// repasse hors-LCR (on ne les supprime plus). En consultation, decision metier = })
// on les masque TOTALEMENT : on n'affiche les RIB que si le type de reglement
// courant est LCR (le `code` est embarque sous client:read:accounting).
const ribs = computed(() =>
isRibRequiredForPaymentType(paymentTypeCodeOf(client.value?.paymentType))
? (client.value?.ribs ?? []).map(mapRibToDraft)
: [],
)
// Draft comptable (tout null si l'utilisateur n'a pas accounting.view). // Draft comptable (tout null si l'utilisateur n'a pas accounting.view).
const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as ClientDetail))) const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as ClientDetail)))
@@ -392,18 +380,10 @@ const relationOptions = computed<SelectOption[]>(() => [
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') }, { value: 'courtier', label: t('commercial.clients.form.main.relationBroker') },
]) ])
// Pays (ERP-116) : options construites depuis l'EMBED des adresses (jamais via const countryOptions: SelectOption[] = [
// GET /countries, sur le meme principe que les autres selects de consultation { value: 'France', label: 'France' },
// — en 403 pour les roles metier non-admin). Valeur = nom du pays stocke tel { value: 'Espagne', label: 'Espagne' },
// quel dans l'adresse, donc value === label ; suffit a afficher le libelle en ]
// lecture seule.
const countryOptions = computed<SelectOption[]>(() =>
[...new Set(
(client.value?.addresses ?? [])
.map(a => a.country)
.filter((c): c is string => !!c),
)].map(c => ({ value: c, label: c })),
)
// Selects comptables : libelle issu de l'embed (option unique ou vide). // Selects comptables : libelle issu de l'embed (option unique ou vide).
const tvaModeOptions = computed(() => referentialOptionOf(client.value?.tvaMode)) const tvaModeOptions = computed(() => referentialOptionOf(client.value?.tvaMode))
@@ -433,17 +413,15 @@ const tabs = computed(() => tabKeys.value.map(key => ({
icon: TAB_ICONS[key], icon: TAB_ICONS[key],
}))) })))
// Onglet initial : repris de l'edition au retour (history.state), sinon Information. const activeTab = ref('information')
const activeTab = ref(readHistoryTab(tabKeys.value) ?? 'information')
// ── Navigation ───────────────────────────────────────────────────────────── // ── Navigation ─────────────────────────────────────────────────────────────
function goBack(): void { function goBack(): void {
router.push('/clients') router.push('/clients')
} }
/** Bascule en edition en conservant l'onglet courant (via history.state). */
function goEdit(): void { function goEdit(): void {
router.push({ path: `/clients/${clientId}/edit`, state: { tab: activeTab.value } }) router.push(`/clients/${clientId}/edit`)
} }
// ── Archivage / Restauration ──────────────────────────────────────────────── // ── Archivage / Restauration ────────────────────────────────────────────────
@@ -3,18 +3,8 @@
<PageHeader> <PageHeader>
{{ t('commercial.clients.title') }} {{ t('commercial.clients.title') }}
<template #actions> <template #actions>
<!-- gap-8 = 32px d'espacement entre Filtres et Ajouter. --> <!-- gap-12 = 48px d'espacement entre Ajouter et Filtres. -->
<div class="flex items-center gap-8"> <div class="flex items-center gap-12">
<!-- 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 <MalioButton
v-if="canManage" v-if="canManage"
variant="secondary" variant="secondary"
@@ -23,6 +13,18 @@
icon-position="left" icon-position="left"
@click="goToCreate" @click="goToCreate"
/> />
<!-- Bouton Filtres a DROITE d'Ajouter : meme design que
l'audit-log. Le compteur reflete les filtres actifs. -->
<MalioButton
v-if="canView"
variant="tertiary"
:label="filterButtonLabel"
icon-name="mdi:tune"
icon-position="left"
icon-size="24"
button-class="w-[184px] justify-start gap-4 text-black"
@click="openFilters"
/>
</div> </div>
</template> </template>
</PageHeader> </PageHeader>
@@ -37,7 +39,7 @@
:per-page="itemsPerPage" :per-page="itemsPerPage"
:per-page-options="itemsPerPageOptions" :per-page-options="itemsPerPageOptions"
row-clickable row-clickable
table-class="table-fixed clients-table" table-class="table-fixed"
:empty-message="t('commercial.clients.empty')" :empty-message="t('commercial.clients.empty')"
@row-click="onRowClick" @row-click="onRowClick"
@update:page="goToPage" @update:page="goToPage"
@@ -54,7 +56,7 @@
<span <span
v-for="site in (item.sites as ClientSite[])" v-for="site in (item.sites as ClientSite[])"
:key="site.id" :key="site.id"
class="inline-flex items-center rounded-full px-2 py-0.5 font-medium text-white" class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium text-white"
:style="{ backgroundColor: site.color }" :style="{ backgroundColor: site.color }"
> >
{{ site.name }} {{ site.name }}
@@ -68,7 +70,7 @@
</template> </template>
</MalioDataTable> </MalioDataTable>
<div class="flex justify-center mt-4"> <div class="flex justify-center mt-6">
<MalioButton <MalioButton
v-if="canView" v-if="canView"
variant="primary" variant="primary"
@@ -348,9 +350,7 @@ async function loadFilterOptions(): Promise<void> {
const [cats, sites] = await Promise.all([ const [cats, sites] = await Promise.all([
api.get<{ member?: Array<{ code: string, name: string }> }>( api.get<{ member?: Array<{ code: string, name: string }> }>(
'/categories', '/categories',
// Taxonomie multi-types (ERP-84) : le filtre du repertoire client ne { pagination: 'false' },
// propose que les categories de type CLIENT (pas les FOURNISSEUR).
{ pagination: 'false', typeCode: 'CLIENT' },
{ headers: { Accept: 'application/ld+json' }, toast: false }, { headers: { Accept: 'application/ld+json' }, toast: false },
), ),
api.get<{ member?: Array<{ id: number, name: string }> }>( api.get<{ member?: Array<{ id: number, name: string }> }>(
@@ -419,16 +419,3 @@ onMounted(() => {
}) })
}) })
</script> </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(.clients-table tbody td:nth-child(3)) {
padding-top: 8px;
padding-bottom: 8px;
}
</style>
+148 -179
View File
@@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<!-- En-tete : retour vers le repertoire + titre. --> <!-- En-tete : retour vers le repertoire + titre. -->
<div class="flex items-center gap-3 pt-11"> <div class="flex items-center gap-3">
<MalioButtonIcon <MalioButtonIcon
icon="mdi:arrow-left-bold" icon="mdi:arrow-left-bold"
icon-size="24" icon-size="24"
@@ -9,7 +9,7 @@
v-bind="{ ariaLabel: t('commercial.clients.form.back') }" v-bind="{ ariaLabel: t('commercial.clients.form.back') }"
@click="goBack" @click="goBack"
/> />
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('commercial.clients.form.title') }}</h1> <h1 class="text-[32px] font-bold text-m-primary">{{ t('commercial.clients.form.title') }}</h1>
</div> </div>
<!-- Formulaire principal (pre-onglets) <!-- Formulaire principal (pre-onglets)
@@ -35,7 +35,6 @@
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)" @update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
/> />
<MalioSelect <MalioSelect
v-if="showRelationAndTriage"
:model-value="main.relationType" :model-value="main.relationType"
:options="relationOptions" :options="relationOptions"
:label="t('commercial.clients.form.main.relation')" :label="t('commercial.clients.form.main.relation')"
@@ -44,7 +43,7 @@
@update:model-value="onRelationChange" @update:model-value="onRelationChange"
/> />
<MalioSelect <MalioSelect
v-if="showRelationAndTriage && main.relationType === 'courtier'" v-if="main.relationType === 'courtier'"
:model-value="main.brokerIri" :model-value="main.brokerIri"
:options="referentials.brokers.value" :options="referentials.brokers.value"
:label="t('commercial.clients.form.main.brokerName')" :label="t('commercial.clients.form.main.brokerName')"
@@ -54,7 +53,7 @@
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
/> />
<MalioSelect <MalioSelect
v-if="showRelationAndTriage && main.relationType === 'distributeur'" v-if="main.relationType === 'distributeur'"
:model-value="main.distributorIri" :model-value="main.distributorIri"
:options="referentials.distributors.value" :options="referentials.distributors.value"
:label="t('commercial.clients.form.main.distributorName')" :label="t('commercial.clients.form.main.distributorName')"
@@ -64,7 +63,6 @@
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
/> />
<MalioCheckbox <MalioCheckbox
v-if="showRelationAndTriage"
v-model="main.triageService" v-model="main.triageService"
:label="t('commercial.clients.form.main.triageService')" :label="t('commercial.clients.form.main.triageService')"
group-class="self-center" group-class="self-center"
@@ -76,7 +74,7 @@
<MalioButton <MalioButton
variant="primary" variant="primary"
:label="t('commercial.clients.form.submit')" :label="t('commercial.clients.form.submit')"
:disabled="mainSubmitting" :disabled="!isMainValid || mainSubmitting"
@click="submitMain" @click="submitMain"
/> />
</div> </div>
@@ -86,15 +84,13 @@
<!-- Onglet Information --> <!-- Onglet Information -->
<template #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)]"> <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)]">
<!-- pt-1/pb-1 alignent le textarea (h-full) sur les inputs, dont <!-- pt-1 : aligne le bord superieur du textarea sur celui des
le champ de 40px est centre dans un conteneur h-12 (~4px de inputs (centres dans un conteneur h-12, soit ~4px de retrait haut). -->
coussin en HAUT et en BAS). Sans pb-1, le textarea descend ~4px
plus bas que les champs voisins. -->
<MalioInputTextArea <MalioInputTextArea
v-model="information.description" v-model="information.description"
:label="t('commercial.clients.form.information.description')" :label="t('commercial.clients.form.information.description')"
resize="none" resize="none"
group-class="row-span-2 pt-1 pb-1" group-class="row-span-2 pt-1"
text-input="h-full text-lg" text-input="h-full text-lg"
:readonly="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.description" :error="informationErrors.errors.description"
@@ -109,7 +105,6 @@
v-model="information.foundedAt" v-model="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')" :label="t('commercial.clients.form.information.foundedAt')"
:readonly="isValidated('information')" :readonly="isValidated('information')"
:editable="true"
:error="informationErrors.errors.foundedAt" :error="informationErrors.errors.foundedAt"
/> />
<MalioInputText <MalioInputText
@@ -139,10 +134,9 @@
/> />
</div> </div>
<div v-if="!isValidated('information')" class="mt-12 flex justify-center"> <div v-if="!isValidated('information')" class="mt-12 flex justify-center">
<!-- Desactive tant que le client n'est pas cree (evite un PATCH <!-- Desactive tant que le client n'est pas cree : evite un PATCH
avant le POST si clic trop tot, Information etant l'onglet avant le POST si l'utilisateur clique trop tot (le panneau
actif par defaut). Onglet facultatif : un enregistrement a Information est l'onglet actif par defaut). -->
vide reste possible, c'est le back qui valide. -->
<MalioButton <MalioButton
variant="primary" variant="primary"
:label="t('commercial.clients.form.submit')" :label="t('commercial.clients.form.submit')"
@@ -178,7 +172,7 @@
<MalioButton <MalioButton
variant="primary" variant="primary"
:label="t('commercial.clients.form.submit')" :label="t('commercial.clients.form.submit')"
:disabled="tabSubmitting" :disabled="!canValidateContacts || tabSubmitting"
@click="submitContacts" @click="submitContacts"
/> />
</div> </div>
@@ -210,13 +204,12 @@
icon-name="mdi:add-bold" icon-name="mdi:add-bold"
icon-position="left" icon-position="left"
:label="t('commercial.clients.form.address.add')" :label="t('commercial.clients.form.address.add')"
:disabled="!canAddAddress"
@click="addAddress" @click="addAddress"
/> />
<MalioButton <MalioButton
variant="primary" variant="primary"
:label="t('commercial.clients.form.submit')" :label="t('commercial.clients.form.submit')"
:disabled="tabSubmitting" :disabled="!canValidateAddresses || tabSubmitting"
@click="submitAddresses" @click="submitAddresses"
/> />
</div> </div>
@@ -294,15 +287,15 @@
</div> </div>
</div> </div>
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-1.13). --> <!-- Blocs RIB (0..n) — obligatoires si type de reglement = LCR. -->
<div <div
v-for="(rib, index) in visibleRibs" v-for="(rib, index) in ribs"
:key="index" :key="index"
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]" class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
> >
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). --> <!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
<MalioButtonIcon <MalioButtonIcon
v-if="!accountingReadonly && visibleRibs.length > 1" v-if="!accountingReadonly"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="absolute top-3 right-3" button-class="absolute top-3 right-3"
@@ -336,18 +329,16 @@
<div v-if="!accountingReadonly" class="flex justify-center gap-6"> <div v-if="!accountingReadonly" class="flex justify-center gap-6">
<MalioButton <MalioButton
v-if="isRibRequired"
variant="secondary" variant="secondary"
icon-name="mdi:add-bold" icon-name="mdi:add-bold"
icon-position="left" icon-position="left"
:label="t('commercial.clients.form.accounting.addRib')" :label="t('commercial.clients.form.accounting.addRib')"
:disabled="!canAddRib"
@click="addRib" @click="addRib"
/> />
<MalioButton <MalioButton
variant="primary" variant="primary"
:label="t('commercial.clients.form.submit')" :label="t('commercial.clients.form.submit')"
:disabled="tabSubmitting" :disabled="!canValidateAccounting || tabSubmitting"
@click="submitAccounting" @click="submitAccounting"
/> />
</div> </div>
@@ -389,24 +380,18 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useClientReferentials, type RefOption } from '~/modules/commercial/composables/useClientReferentials' import { useClientReferentials, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors' import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors'
import { import {
addressTypeFromFlags,
buildClientFormTabKeys, buildClientFormTabKeys,
CLIENT_FORM_PLACEHOLDER_TABS, CLIENT_FORM_PLACEHOLDER_TABS,
isAddressValid, hasAllRequiredAccountingFields,
hasAtLeastOneValidContact,
isBankRequiredForPaymentType, isBankRequiredForPaymentType,
isBillingEmailRequired, isBillingEmailRequired,
isContactBlank, isContactBlank,
isContactNamed, isContactNamed,
isRibBlank, isRibBlank,
isRibComplete,
isRibRequiredForPaymentType, isRibRequiredForPaymentType,
lastFillableTabKey,
showsRelationAndTriageFields,
} from '~/modules/commercial/utils/clientFormRules' } from '~/modules/commercial/utils/clientFormRules'
import {
buildAddressPayload,
buildMainPayload,
buildRibPayload,
} from '~/modules/commercial/utils/clientEdit'
import { import {
emptyAddress, emptyAddress,
emptyContact, emptyContact,
@@ -498,26 +483,23 @@ const relationOptions = computed<RefOption[]>(() => [
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') }, { value: 'courtier', label: t('commercial.clients.form.main.relationBroker') },
]) ])
// Codes des categories selectionnees (resolus depuis les IRI du brouillon). // Validation du formulaire principal (gate le bouton « Valider ») :
const selectedCategoryCodes = computed(() => // - companyName / >= 1 categorie obligatoires ;
main.categoryIris // - relation Distributeur/Courtier optionnelle, mais le nom correspondant
.map(iri => referentials.categories.value.find(c => c.value === iri)?.code) // devient requis si l'un des deux est choisi (spec fonctionnelle).
.filter((code): code is string => code !== undefined), // 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(() => {
// « Relation » + « Prestation de triage » masques par defaut, reveles des qu'une const filled = (v: string | null | undefined) => v !== null && v !== undefined && v.trim() !== ''
// categorie ordinaire (autre que Distributeur/Courtier) est selectionnee. // Relation Distributeur/Courtier OPTIONNELLE ; mais si « Depend du
const showRelationAndTriage = computed(() => showsRelationAndTriageFields(selectedCategoryCodes.value)) // distributeur/courtier » est choisi, le nom correspondant devient requis.
const relationValid
// Masquer ces champs reinitialise leurs valeurs : pas de relation/triage fantome = main.relationType === null
// soumis pour un client Distributeur/Courtier. || (main.relationType === 'distributeur' && filled(main.distributorIri))
watch(showRelationAndTriage, (visible) => { || (main.relationType === 'courtier' && filled(main.brokerIri))
if (!visible) { return filled(main.companyName)
main.relationType = null && main.categoryIris.length >= 1
main.distributorIri = null && relationValid
main.brokerIri = null
main.triageService = false
}
}) })
async function onRelationChange(value: string | number | null): Promise<void> { async function onRelationChange(value: string | number | null): Promise<void> {
@@ -535,13 +517,18 @@ async function onRelationChange(value: string | number | null): Promise<void> {
/** POST /clients (groupe client:write:main). Au succes : verrouille + bascule Information. */ /** POST /clients (groupe client:write:main). Au succes : verrouille + bascule Information. */
async function submitMain(): Promise<void> { async function submitMain(): Promise<void> {
if (mainSubmitting.value) return if (!isMainValid.value || mainSubmitting.value) return
mainSubmitting.value = true mainSubmitting.value = true
mainErrors.clearErrors() mainErrors.clearErrors()
try { try {
// Payload partage avec l'edition (buildMainPayload) : meme logique const payload: Record<string, unknown> = {
// d'omission des requis vides et meme envoi de relationType (ERP-119). companyName: main.companyName,
const created = await api.post<ClientResponse>('/clients', buildMainPayload(main), { 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, {
headers: { Accept: 'application/ld+json' }, headers: { Accept: 'application/ld+json' },
toast: false, toast: false,
}) })
@@ -551,9 +538,7 @@ async function submitMain(): Promise<void> {
main.companyName = created.companyName ?? main.companyName main.companyName = created.companyName ?? main.companyName
mainLocked.value = true mainLocked.value = true
// Information est facultatif : on deverrouille jusqu'a Contact (index 1) unlockedIndex.value = 0
// pour que l'utilisateur puisse y aller directement sans valider Information.
unlockedIndex.value = tabIndex('contact')
activeTab.value = 'information' activeTab.value = 'information'
toast.success({ title: t('commercial.clients.toast.createSuccess') }) toast.success({ title: t('commercial.clients.toast.createSuccess') })
} }
@@ -585,12 +570,6 @@ const validated = reactive<Record<string, boolean>>({})
const tabKeys = computed(() => buildClientFormTabKeys(canAccountingView.value)) 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. // Icone (Iconify) affichee dans l'onglet, par cle. A ajuster librement.
const TAB_ICONS: Record<string, string> = { const TAB_ICONS: Record<string, string> = {
information: 'mdi:account-outline', information: 'mdi:account-outline',
@@ -618,23 +597,12 @@ function tabIndex(key: string): number {
return tabKeys.value.indexOf(key) return tabKeys.value.indexOf(key)
} }
/** /** Marque l'onglet valide, deverrouille et avance automatiquement au suivant. */
* Marque l'onglet valide. Si c'est le dernier onglet remplissable, l'ajout est function completeTab(key: string): void {
* 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 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] const next = tabKeys.value[tabIndex(key) + 1]
unlockedIndex.value = Math.max(unlockedIndex.value, tabIndex(key) + 1) unlockedIndex.value = Math.max(unlockedIndex.value, tabIndex(key) + 1)
if (next) activeTab.value = next if (next) activeTab.value = next
return false
} }
// Passage automatique sur les onglets coquille (Transport, Stats, Rapports, Echanges). // Passage automatique sur les onglets coquille (Transport, Stats, Rapports, Echanges).
@@ -672,7 +640,7 @@ async function submitInformation(): Promise<void> {
profitAmount: information.profitAmount || null, profitAmount: information.profitAmount || null,
directorName: information.directorName || null, directorName: information.directorName || null,
}, { toast: false }) }, { toast: false })
if (completeTab('information')) return completeTab('information')
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch (error) { catch (error) {
@@ -694,6 +662,9 @@ const canAddContact = computed(() => {
return last !== undefined && isContactNamed(last) 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 { function addContact(): void {
if (canAddContact.value) contacts.value.push(emptyContact()) if (canAddContact.value) contacts.value.push(emptyContact())
} }
@@ -707,14 +678,9 @@ function askRemoveContact(index: number): void {
/** POST/PATCH des contacts sur la sous-ressource /clients/{id}/contacts. */ /** POST/PATCH des contacts sur la sous-ressource /clients/{id}/contacts. */
async function submitContacts(): Promise<void> { async function submitContacts(): Promise<void> {
if (clientId.value === null || tabSubmitting.value) return if (clientId.value === null || !canValidateContacts.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
try { 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 // On tente TOUS les blocs (collecte des erreurs par index, ERP-110). Seuls
// les blocs TOTALEMENT vides sont ignores : un bloc partiellement rempli // les blocs TOTALEMENT vides sont ignores : un bloc partiellement rempli
// sans nom (email seul) est soumis -> 422 RG-1.05 inline sous le bloc. // sans nom (email seul) est soumis -> 422 RG-1.05 inline sous le bloc.
@@ -744,14 +710,14 @@ async function submitContacts(): Promise<void> {
} }
}, },
error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }), error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
// On ne saute une amorce neuve (id null) totalement vide QUE si un autre // On ne saute QUE les amorces neuves (id null) totalement vides. Un
// bloc sera soumis : sinon on la soumet pour declencher la 422 RG-1.05 // bloc existant vide est soumis -> 422 RG-1.05 inline (sinon la modif
// (un onglet Contact vide ne doit pas passer en faux succes). // serait perdue en silence avec un faux toast de succes).
contact => hasSubmittableContact && contact.id === null && isContactBlank(contact), contact => contact.id === null && isContactBlank(contact),
) )
// Tant qu'un bloc reste en erreur : pas de validation d'onglet ni de toast succes. // Tant qu'un bloc reste en erreur : pas de validation d'onglet ni de toast succes.
if (hasError) return if (hasError) return
if (completeTab('contact')) return completeTab('contact')
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
finally { finally {
@@ -778,26 +744,27 @@ const contactOptions = computed<RefOption[]>(() =>
})), })),
) )
// Pays disponibles : referentiel `country` charge via l'API (ERP-116), en // Pays disponibles (France preselectionnee par defaut sur chaque adresse).
// remplacement de l'ancienne liste codee en dur. France reste preselectionnee const countryOptions: RefOption[] = [
// par defaut sur chaque adresse (cf. valeur initiale du draft d'adresse) : on { value: 'France', label: 'France' },
// garantit donc sa presence en fallback si `/countries` echoue (resilience { value: 'Espagne', label: 'Espagne' },
// ERP-102), pour ne pas afficher un select vide sur une valeur deja soumise. ]
const countryOptions = computed<RefOption[]>(() => {
const list = referentials.countries.value
return list.some(c => c.value === 'France')
? list
: [{ value: 'France', label: 'France' }, ...list]
})
// « + Adresse » desactive tant que la derniere adresse n'est pas valide. // Type d'adresse (Select) obligatoire + RG-1.10 (>= 1 site) + RG-1.11 (email
const canAddAddress = computed(() => { // facturation si Facturation) sur chaque adresse.
const last = addresses.value[addresses.value.length - 1] const canValidateAddresses = computed(() =>
return last !== undefined && isAddressValid(last) addresses.value.length > 0
}) && addresses.value.every((a) => {
const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== ''
return addressTypeFromFlags(a) !== null
&& a.siteIris.length >= 1
&& a.categoryIris.length >= 1
&& (!isBillingEmailRequired(a) || filledBillingEmail)
}),
)
function addAddress(): void { function addAddress(): void {
if (canAddAddress.value) addresses.value.push(emptyAddress()) addresses.value.push(emptyAddress())
} }
function askRemoveAddress(index: number): void { function askRemoveAddress(index: number): void {
@@ -819,7 +786,7 @@ function onAddressDegraded(): void {
/** POST des adresses sur la sous-ressource /clients/{id}/addresses. */ /** POST des adresses sur la sous-ressource /clients/{id}/addresses. */
async function submitAddresses(): Promise<void> { async function submitAddresses(): Promise<void> {
if (clientId.value === null || tabSubmitting.value) return if (clientId.value === null || !canValidateAddresses.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
try { try {
// On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110). // On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110).
@@ -827,8 +794,20 @@ async function submitAddresses(): Promise<void> {
addresses.value, addresses.value,
addressErrors, addressErrors,
async (address) => { async (address) => {
// Payload partage avec l'edition (buildAddressPayload, ERP-119). const body = {
const body = buildAddressPayload(address, isBillingEmailRequired(address)) 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,
}
if (address.id === null) { if (address.id === null) {
const created = await api.post<{ id: number }>( const created = await api.post<{ id: number }>(
`/clients/${clientId.value}/addresses`, `/clients/${clientId.value}/addresses`,
@@ -844,7 +823,7 @@ async function submitAddresses(): Promise<void> {
error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }), error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
) )
if (hasError) return if (hasError) return
if (completeTab('address')) return completeTab('address')
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
finally { finally {
@@ -874,33 +853,29 @@ const selectedPaymentTypeCode = computed(() =>
const isBankRequired = computed(() => isBankRequiredForPaymentType(selectedPaymentTypeCode.value)) const isBankRequired = computed(() => isBankRequiredForPaymentType(selectedPaymentTypeCode.value))
const isRibRequired = computed(() => isRibRequiredForPaymentType(selectedPaymentTypeCode.value)) const isRibRequired = computed(() => isRibRequiredForPaymentType(selectedPaymentTypeCode.value))
// Les blocs RIB ne sont affiches que pour une LCR (RG-1.13).
const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
function onPaymentTypeChange(value: string | number | null): void { function onPaymentTypeChange(value: string | number | null): void {
accounting.paymentTypeIri = value === null ? null : String(value) accounting.paymentTypeIri = value === null ? null : String(value)
// La banque n'a de sens que pour un virement : on la vide sinon (RG-1.12). // La banque n'a de sens que pour un virement : on la vide sinon (RG-1.12).
if (!isBankRequired.value) accounting.bankIri = null if (!isBankRequired.value) accounting.bankIri = null
// ERP-121 : on ne jette plus la saisie RIB au passage hors-LCR. Les blocs sont
// masques (visibleRibs = []) mais conserves, et reapparaissent si l'on repasse
// en LCR. Ils ne sont persistes qu'a la validation SOUS LCR (cf. submitAccounting),
// donc une saisie abandonnee hors-LCR ne cree aucun RIB orphelin.
if (isRibRequired.value) {
if (ribs.value.length === 0) ribs.value.push(emptyRib())
}
else {
ribErrors.value = []
}
} }
// « + RIB » desactive tant que le dernier bloc RIB n'est pas complet. function ribIsComplete(rib: RibFormDraft): boolean {
const canAddRib = computed(() => { const filled = (v: string | null) => v !== null && v.trim() !== ''
const last = ribs.value[ribs.value.length - 1] return filled(rib.label) && filled(rib.bic) && filled(rib.iban)
return last !== undefined && isRibComplete(last) }
// 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(ribIsComplete)) return false
return true
}) })
function addRib(): void { function addRib(): void {
if (canAddRib.value) ribs.value.push(emptyRib()) ribs.value.push(emptyRib())
} }
function askRemoveRib(index: number): void { function askRemoveRib(index: number): void {
@@ -913,55 +888,20 @@ function askRemoveRib(index: number): void {
} }
/** /**
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur /clients/{id}/ribs PUIS * Valide l'onglet Comptabilite : PATCH des scalaires (groupe client:write:accounting)
* PATCH des scalaires (groupe client:write:accounting). Les RIB d'abord : le back * PUIS POST des RIB sur /clients/{id}/ribs. Deux appels distincts (mode strict
* valide RG-1.13 (LCR => au moins un RIB persiste) sur le PATCH scalaires, les RIB * RG-1.28 : il n'existe pas d'endpoint /accounting, cf. recon back).
* 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> { async function submitAccounting(): Promise<void> {
if (clientId.value === null || tabSubmitting.value) return if (clientId.value === null || !canValidateAccounting.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
accountingErrors.clearErrors() accountingErrors.clearErrors()
// Reset des erreurs RIB des le debut : l'etape 1 (PATCH scalaires) peut
// echouer et `return` avant submitRows (qui porte sinon le reset), laissant
// des erreurs de RIB obsoletes affichees sous les blocs.
ribErrors.value = []
try { try {
// 1) POST/PATCH des RIB d'abord — UNIQUEMENT sous LCR (erreurs inline par // 1) PATCH des scalaires comptables (erreurs inline sur leurs champs).
// ligne, tous les blocs tentes). Le back exige >=1 RIB persiste pour valider
// une LCR a l'etape 2. Hors-LCR (ERP-121), une saisie RIB eventuellement
// restee dans le brouillon est masquee et n'est PAS persistee (pas de RIB
// orphelin sur un client en virement).
// On ne saute une amorce neuve vide QUE s'il reste un autre RIB soumettable :
// sinon (LCR sans aucun RIB rempli) on la soumet -> 422 NotBlank inline.
if (isRibRequired.value) {
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
const ribHasError = await submitRows(
ribs.value,
ribErrors,
async (rib) => {
// 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`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
rib.id = created.id
}
else {
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
}
},
error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
// On ne saute une amorce neuve (id null) totalement vide que si un autre RIB
// est soumettable. Un RIB existant vide est toujours soumis -> 422 NotBlank
// inline (sinon la modif serait perdue en silence avec un faux toast succes).
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
)
if (ribHasError) return
}
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
try { try {
await api.patch(`/clients/${clientId.value}`, { await api.patch(`/clients/${clientId.value}`, {
siren: accounting.siren || null, siren: accounting.siren || null,
@@ -978,7 +918,35 @@ async function submitAccounting(): Promise<void> {
return return
} }
if (completeTab('accounting')) return // 2) POST/PATCH des RIB (erreurs inline par ligne, tous les blocs tentes).
// 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 }
if (rib.id === null) {
const created = await api.post<{ id: number }>(
`/clients/${clientId.value}/ribs`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
rib.id = created.id
}
else {
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
}
},
error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
// On ne saute QUE les amorces neuves (id null) totalement vides. Un
// RIB existant vide est soumis -> 422 NotBlank inline (sinon la modif
// serait perdue en silence avec un faux toast de succes).
rib => rib.id === null && isRibBlank(rib),
)
if (ribHasError) return
completeTab('accounting')
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
finally { finally {
@@ -1019,7 +987,8 @@ interface ContactResponse {
onMounted(() => { onMounted(() => {
// Echec du chargement des referentiels non bloquant : les selects restent vides. // Echec du chargement des referentiels non bloquant : les selects restent vides.
referentials.loadCommon().catch(() => {}) referentials.loadCommon().catch(() => {})
// Pas d'amorce de RIB ici : un bloc vide n'apparait que si LCR est choisi // Au moins un bloc RIB toujours visible en creation : on amorce un bloc vide
// (cf. onPaymentTypeChange). // (non persiste tant qu'incomplet — RG-1.13).
if (ribs.value.length === 0) ribs.value.push(emptyRib())
}) })
</script> </script>
@@ -1,945 +0,0 @@
<template>
<div>
<!-- En-tete : retour consultation + nom du fournisseur. -->
<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.edit.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
</div>
<!-- Etats de chargement / introuvable. -->
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('commercial.suppliers.edit.loading') }}</p>
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('commercial.suppliers.edit.notFound') }}</p>
<template v-else-if="supplier">
<!-- Bloc principal (pre-rempli, editable si `manage`)
Conserve en modification (miroir client) ; edite via son propre
PATCH scope sur le groupe supplier:write:main. Readonly pour les
roles sans `manage` (ex. Compta). 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="businessReadonly"
:error="mainErrors.errors.companyName"
/>
<MalioSelectCheckbox
:model-value="main.categoryIris"
:options="mainCategoryOptions"
:label="t('commercial.suppliers.form.main.categories')"
:display-tag="true"
:readonly="businessReadonly"
:required="true"
:error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
/>
</div>
<div v-if="!businessReadonly" class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('commercial.suppliers.edit.save')"
:disabled="mainSubmitting"
@click="submitMain"
/>
</div>
<!-- Onglets : navigation LIBRE, edition independante par onglet -->
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" 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)]">
<!-- pt-1/pb-1 alignent le textarea (h-full) sur les inputs. -->
<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="businessReadonly"
:error="informationErrors.errors.description"
/>
<MalioInputText
v-model="information.competitors"
:label="t('commercial.suppliers.form.information.competitors')"
:readonly="businessReadonly"
:error="informationErrors.errors.competitors"
/>
<MalioDate
v-model="information.foundedAt"
:label="t('commercial.suppliers.form.information.foundedAt')"
:readonly="businessReadonly"
:editable="true"
:error="informationErrors.errors.foundedAt"
/>
<MalioInputText
v-model="information.employeesCount"
:label="t('commercial.suppliers.form.information.employeesCount')"
:mask="EMPLOYEES_MASK"
:readonly="businessReadonly"
:error="informationErrors.errors.employeesCount"
/>
<MalioInputAmount
v-model="information.revenueAmount"
:label="t('commercial.suppliers.form.information.revenueAmount')"
:readonly="businessReadonly"
:error="informationErrors.errors.revenueAmount"
/>
<MalioInputText
v-model="information.directorName"
:label="t('commercial.suppliers.form.information.directorName')"
:readonly="businessReadonly"
:error="informationErrors.errors.directorName"
/>
<MalioInputAmount
v-model="information.profitAmount"
:label="t('commercial.suppliers.form.information.profitAmount')"
:readonly="businessReadonly"
:error="informationErrors.errors.profitAmount"
/>
<!-- Volume previsionnel : specifique fournisseur (entier). -->
<MalioInputText
v-model="information.volumeForecast"
:label="t('commercial.suppliers.form.information.volumeForecast')"
:mask="VOLUME_FORECAST_MASK"
:readonly="businessReadonly"
:error="informationErrors.errors.volumeForecast"
/>
</div>
<div v-if="!businessReadonly" class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('commercial.suppliers.edit.save')"
:disabled="tabSubmitting"
@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="contact.id ?? `new-${index}`"
:model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
:removable="contacts.length > 1"
:readonly="businessReadonly"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
/>
<div v-if="!businessReadonly" 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.edit.save')"
: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="address.id ?? `new-${index}`"
:model-value="address"
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
:category-options="mainCategoryOptions"
:site-options="siteOptions"
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="addresses.length > 1"
:readonly="businessReadonly"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)"
@degraded="onAddressDegraded"
/>
<div v-if="!businessReadonly" 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.edit.save')"
:disabled="tabSubmitting"
@click="submitAddresses"
/>
</div>
</div>
</template>
<!-- Onglet Comptabilite (present uniquement si accounting.view ;
editable uniquement si accounting.manage). -->
<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="tvaModeOptions"
: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="paymentDelayOptions"
: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="paymentTypeOptions"
: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="bankOptions"
: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="rib.id ?? `new-${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 && visibleRibs.length > 1"
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.edit.save')"
:disabled="tabSubmitting"
@click="submitAccounting"
/>
</div>
</div>
</template>
<!-- Onglets non encore implementes : frame vide (navigation libre). -->
<template #transport><ComingSoonPlaceholder /></template>
<template #statistics><ComingSoonPlaceholder /></template>
<template #reports><ComingSoonPlaceholder /></template>
<template #exchanges><ComingSoonPlaceholder /></template>
</MalioTabList>
</template>
<!-- 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 } from 'vue'
import { useSupplier } from '~/modules/commercial/composables/useSupplier'
import { useSupplierReferentials, type CategoryOption, type RefOption } from '~/modules/commercial/composables/useSupplierReferentials'
import { useSupplierFormErrors } from '~/modules/commercial/composables/useSupplierFormErrors'
import {
canEditSupplier,
categoryOptionsOf,
referentialOptionOf,
siteOptionsOf,
mapContactToDraft,
mapAddressToDraft,
mapRibToDraft,
type SupplierDetail,
} from '~/modules/commercial/utils/supplierConsultation'
import {
buildAccountingPayload,
buildAddressPayload,
buildContactPayload,
buildInformationPayload,
buildMainPayload,
buildRibPayload,
mapAccountingFormDraft,
mapInformationDraft,
mapMainDraft,
resolveTabEditability,
type AccountingFormDraft,
type InformationFormDraft,
type MainFormDraft,
type SupplierEditAbilities,
} from '~/modules/commercial/utils/supplierEdit'
import {
buildSupplierFormTabKeys,
isAddressValid,
isBankRequiredForPaymentType,
isContactBlank,
isContactNamed,
isRibBlank,
isRibComplete,
isRibRequiredForPaymentType,
} from '~/modules/commercial/utils/supplierFormRules'
import {
emptyAddress,
emptyContact,
emptyRib,
type SupplierAddressFormDraft,
type SupplierContactFormDraft,
type SupplierRibFormDraft,
} from '~/modules/commercial/types/supplierForm'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { readHistoryTab } from '~/shared/utils/historyTab'
// 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 route = useRoute()
const router = useRouter()
const { can, canAny } = usePermissions()
// Gating de la route : l'edition exige de pouvoir editer au moins un onglet
// (`manage` OU `accounting.manage`). Usine et roles en lecture seule sont
// rediriges vers le repertoire (lui-meme protege).
if (!canEditSupplier(canAny)) {
await navigateTo('/suppliers')
}
const supplierId = route.params.id as string
const { supplier, loading, error, load } = useSupplier(supplierId)
const referentials = useSupplierReferentials()
// Permissions / editabilite par zone (option 1 ERP-74)
const abilities = computed<SupplierEditAbilities>(() => ({
canManage: can('commercial.suppliers.manage'),
canAccountingView: can('commercial.suppliers.accounting.view'),
canAccountingManage: can('commercial.suppliers.accounting.manage'),
}))
const editability = computed(() => resolveTabEditability(abilities.value))
// Bloc principal + onglets Information / Contacts / Adresses.
const businessReadonly = computed(() => !editability.value.businessEditable)
const canAccountingView = computed(() => editability.value.accountingVisible)
const accountingReadonly = computed(() => !editability.value.accountingEditable)
const headerTitle = computed(() => supplier.value?.companyName ?? t('commercial.suppliers.edit.title'))
// Brouillons editables (pre-remplis depuis le detail)
const main = reactive<MainFormDraft>(mapMainDraft({} as SupplierDetail))
const information = reactive<InformationFormDraft>(mapInformationDraft({} as SupplierDetail))
const accounting = reactive<AccountingFormDraft>(mapAccountingFormDraft({} as SupplierDetail))
const contacts = ref<SupplierContactFormDraft[]>([])
const addresses = ref<SupplierAddressFormDraft[]>([])
const ribs = ref<SupplierRibFormDraft[]>([])
// Ids des sous-ressources existantes supprimees (DELETE differe au « Valider »).
const removedContactIds = ref<number[]>([])
const removedAddressIds = ref<number[]>([])
const removedRibIds = ref<number[]>([])
const mainSubmitting = ref(false)
const tabSubmitting = ref(false)
const addressDegradedNotified = ref(false)
/** Recopie le detail charge dans les brouillons editables. */
function hydrate(detail: SupplierDetail): void {
Object.assign(main, mapMainDraft(detail))
Object.assign(information, mapInformationDraft(detail))
Object.assign(accounting, mapAccountingFormDraft(detail))
contacts.value = (detail.contacts ?? []).map(mapContactToDraft)
addresses.value = (detail.addresses ?? []).map(mapAddressToDraft)
ribs.value = (detail.ribs ?? []).map(mapRibToDraft)
// Chaque bloc reste visible meme vide : si une collection est vide, on amorce
// un bloc vierge (non persiste tant qu'incomplet cf. submit*/canAdd*).
if (contacts.value.length === 0) contacts.value.push(emptyContact())
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
// RIB : amorce un bloc vide seulement si le type de reglement est une LCR
// (sinon la section reste masquee RG-2.08).
if (isRibRequired.value && ribs.value.length === 0) ribs.value.push(emptyRib())
}
// Options de selects (referentiels UNION valeurs courantes de l'embed)
// L'union garantit que les valeurs deja posees s'affichent meme quand le
// referentiel complet n'est pas chargeable (roles metier sans
// catalog.categories.view / sites.view 403, cf. matrice § 2.7).
function mergeOptions<T extends { value: string }>(primary: T[], extra: T[]): T[] {
const seen = new Set(primary.map(o => o.value))
return [...primary, ...extra.filter(o => !seen.has(o.value))]
}
// Categories issues de l'embed (fournisseur + adresses), role-independantes.
const embedCategoryOptions = computed<CategoryOption[]>(() => {
const fromSupplier = categoryOptionsOf(supplier.value?.categories)
const fromAddresses = (supplier.value?.addresses ?? []).flatMap(a => categoryOptionsOf(a.categories))
return mergeOptions(fromSupplier, fromAddresses)
})
// Toutes les categories de type FOURNISSEUR sont autorisees, sur le bloc principal
// comme sur une adresse (pas de restriction Distributeur/Courtier comme au M1 RG-2.10).
const mainCategoryOptions = computed(() => mergeOptions(referentials.categories.value, embedCategoryOptions.value))
const embedSiteOptions = computed<RefOption[]>(() =>
mergeOptions([], (supplier.value?.addresses ?? []).flatMap(a => siteOptionsOf(a.sites))),
)
const siteOptions = computed(() => mergeOptions(referentials.sites.value, embedSiteOptions.value))
// Contacts deja persistes (iri non null), rattachables a une adresse (M2M).
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 : referentiel `country` charge via l'API (ERP-116), aligne sur l'ecran
// client. On merge la valeur deja stockee sur chaque adresse (embed) comme les
// autres selects de cet ecran pour ne pas vider le select si `/countries`
// echoue (resilience ERP-102) ou si un pays historique n'est plus au referentiel.
const embedCountryOptions = computed<RefOption[]>(() =>
mergeOptions([], (supplier.value?.addresses ?? [])
.map(a => a.country)
.filter((c): c is string => !!c)
.map(c => ({ value: c, label: c }))),
)
const countryOptions = computed<RefOption[]>(() =>
mergeOptions(referentials.countries.value, embedCountryOptions.value),
)
// Selects comptables : referentiel UNION valeur courante de l'embed (libelle).
const tvaModeOptions = computed(() => mergeOptions(referentials.tvaModes.value, referentialOptionOf(supplier.value?.tvaMode)))
const paymentDelayOptions = computed(() => mergeOptions(referentials.paymentDelays.value, referentialOptionOf(supplier.value?.paymentDelay)))
const paymentTypeOptions = computed(() => mergeOptions(
referentials.paymentTypes.value.map(p => ({ value: p.value, label: p.label })),
referentialOptionOf(supplier.value?.paymentType),
))
const bankOptions = computed(() => mergeOptions(referentials.banks.value, referentialOptionOf(supplier.value?.bank)))
// Onglets : navigation libre (3 actifs + Compta + 4 coquilles)
const tabKeys = computed(() => buildSupplierFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true }))
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',
statistics: 'mdi:finance',
reports: 'mdi:file-document-edit-outline',
exchanges: 'mdi:account-group-outline',
}
const tabs = computed(() => tabKeys.value.map(key => ({
key,
label: t(`commercial.suppliers.tab.${key}`),
icon: TAB_ICONS[key],
})))
// Onglet initial : repris de la consultation (history.state), sinon Information.
const activeTab = ref(readHistoryTab(tabKeys.value) ?? 'information')
// Navigation
/** Retour consultation en conservant l'onglet courant (via history.state). */
function goBack(): void {
router.push({ path: `/suppliers/${supplierId}`, state: { tab: activeTab.value } })
}
/**
* Message d'erreur a afficher : violation 422 / detail renvoye par le serveur,
* sinon un libelle generique. Le 409 d'unicite de nom (bloc principal) est
* traduit explicitement par l'appelant.
*/
function apiErrorMessage(e: unknown): string {
const data = (e as { data?: unknown })?.data
return extractApiErrorMessage(data) || t('commercial.suppliers.toast.error')
}
function showError(e: unknown): void {
toast.error({ title: t('commercial.suppliers.toast.error'), message: apiErrorMessage(e) })
}
// Erreurs de validation par champ (ERP-101)
const {
mainErrors,
informationErrors,
accountingErrors,
contactErrors,
addressErrors,
ribErrors,
submitRows,
} = useSupplierFormErrors()
// Bloc principal
/** PATCH /suppliers/{id} — groupe supplier:write:main UNIQUEMENT (mode strict). */
async function submitMain(): Promise<void> {
if (businessReadonly.value || mainSubmitting.value) return
mainSubmitting.value = true
mainErrors.clearErrors()
try {
const updated = await api.patch<SupplierDetail>(`/suppliers/${supplierId}`, buildMainPayload(main, { forUpdate: true }), {
headers: { Accept: 'application/ld+json' },
toast: false,
})
// Reaffiche les valeurs normalisees renvoyees par le serveur (UPPERCASE, RG-2.12).
Object.assign(main, mapMainDraft(updated))
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
}
catch (e) {
// 409 = doublon nom de societe erreur inline + toast ; 422 mapping
// inline par champ ; autre toast de fallback. Cf. ERP-101.
const status = (e 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(e, { fallbackMessage: t('commercial.suppliers.toast.error') })
}
}
finally {
mainSubmitting.value = false
}
}
// Onglet Information
/** PATCH /suppliers/{id} — groupe supplier:write:information UNIQUEMENT. */
async function submitInformation(): Promise<void> {
if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
informationErrors.clearErrors()
try {
await api.patch(`/suppliers/${supplierId}`, buildInformationPayload(information), { toast: false })
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
}
catch (e) {
informationErrors.handleApiError(e, { fallbackMessage: t('commercial.suppliers.toast.error') })
}
finally {
tabSubmitting.value = false
}
}
// Onglet Contacts
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'), () => {
const removed = contacts.value[index]
if (removed?.id != null) removedContactIds.value.push(removed.id)
contacts.value.splice(index, 1)
contactErrors.value.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (contacts.value.length === 0) contacts.value.push(emptyContact())
})
}
/**
* Valide l'onglet Contacts : DELETE des contacts retires (existants), puis
* POST/PATCH des blocs restants sur la sous-ressource. Strictement scope a la
* collection contacts (endpoints supplier_contact dedies).
*/
async function submitContacts(): Promise<void> {
if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
contactErrors.value = []
try {
for (const id of removedContactIds.value) {
await api.delete(`/supplier_contacts/${id}`, {}, { toast: false })
}
removedContactIds.value = []
// RG-2.13 : au moins un contact requis. Si l'onglet ne contient QUE des
// amorces neuves vides, on les soumet -> 422 RG-2.04 inline (nom OU prenom).
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<{ '@id'?: string, id: number }>(
`/suppliers/${supplierId}/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 => showError(error),
contact => hasSubmittableContact && contact.id === null && isContactBlank(contact),
)
if (hasError) return
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
}
catch (e) {
showError(e)
}
finally {
tabSubmitting.value = false
}
}
// Onglet Adresses
// « + 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'), () => {
const removed = addresses.value[index]
if (removed?.id != null) removedAddressIds.value.push(removed.id)
addresses.value.splice(index, 1)
addressErrors.value.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
})
}
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'),
})
}
/** Valide l'onglet Adresses : DELETE des adresses retirees puis POST/PATCH. */
async function submitAddresses(): Promise<void> {
if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
addressErrors.value = []
try {
for (const id of removedAddressIds.value) {
await api.delete(`/supplier_addresses/${id}`, {}, { toast: false })
}
removedAddressIds.value = []
const hasError = await submitRows(
addresses.value,
addressErrors,
async (address) => {
// Edition d'une adresse existante : champ requis vide envoye en `''`
// (NotBlank 422) au lieu d'etre omis sinon le PATCH garderait
// l'ancienne valeur (faux 200). Creation (id null) : omit classique.
const body = buildAddressPayload(address, { forUpdate: address.id !== null })
if (address.id === null) {
const created = await api.post<{ id: number }>(
`/suppliers/${supplierId}/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 => showError(error),
)
if (hasError) return
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
}
catch (e) {
showError(e)
}
finally {
tabSubmitting.value = false
}
}
// Onglet Comptabilite
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)
if (!isBankRequired.value) accounting.bankIri = null
// ERP-121 : un RIB est une coordonnee bancaire du fournisseur, decouplee du mode
// de reglement. Au passage hors-LCR on ne SUPPRIME plus les RIB existants : ils
// restent en base, simplement masques a l'ecran (visibleRibs = []), et
// reapparaissent tels quels si l'on repasse en LCR. Seule la corbeille d'un
// bloc (askRemoveRib) retire reellement un RIB.
if (isRibRequired.value) {
if (ribs.value.length === 0) ribs.value.push(emptyRib())
}
else {
// Hors-LCR : on nettoie seulement les erreurs inline (plus affichees).
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'), () => {
const removed = ribs.value[index]
if (removed?.id != null) removedRibIds.value.push(removed.id)
ribs.value.splice(index, 1)
ribErrors.value.splice(index, 1)
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation).
if (ribs.value.length === 0) ribs.value.push(emptyRib())
})
}
/**
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
* PATCH des scalaires (groupe supplier:write:accounting, exige accounting.manage
* cote back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le
* back valide RG-2.08 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
*
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR hors-LCR ce sont des
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES
* (corbeille d'un bloc, toujours sous LCR). Aucun champ main/information dans le
* payload (mode strict RG-2.16 : sinon 403 sur tout le payload).
*/
async function submitAccounting(): Promise<void> {
if (accountingReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
accountingErrors.clearErrors()
try {
// 1) POST/PATCH des RIB d'abord UNIQUEMENT sous LCR (erreurs inline par
// ligne, tous les blocs tentes). Hors-LCR (ERP-121), les RIB sont des
// coordonnees dormantes : rien d'editable n'est affiche, on ne les re-soumet
// pas. On ne saute une amorce neuve vide QUE s'il reste un autre RIB
// soumettable : sinon (ex. l'unique RIB existant supprime, remplace par un
// bloc vide), on la soumet pour declencher la 422 NotBlank inline plutot que
// de laisser le DELETE echouer en « dernier RIB d'une LCR » (message plat).
if (isRibRequired.value) {
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
const ribHasError = await submitRows(
ribs.value,
ribErrors,
async (rib) => {
// Edition d'un RIB existant : champ requis vide envoye en `''` (NotBlank
// 422) au lieu d'etre omis (sinon le PATCH garderait l'ancienne valeur).
const body = buildRibPayload(rib, { forUpdate: rib.id !== null })
if (rib.id === null) {
const created = await api.post<{ id: number }>(
`/suppliers/${supplierId}/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 => showError(error),
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
)
if (ribHasError) return
}
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
try {
await api.patch(`/suppliers/${supplierId}`, buildAccountingPayload(accounting, isBankRequired.value), { toast: false })
}
catch (error) {
accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.suppliers.toast.error') })
return
}
// 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
// PATCH scalaires (le guard back refuse la suppression du dernier RIB d'une
// LCR). ERP-121 : plus aucune suppression automatique au passage hors-LCR.
for (const id of removedRibIds.value) {
await api.delete(`/supplier_ribs/${id}`, {}, { toast: false })
}
removedRibIds.value = []
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
}
catch (e) {
showError(e)
}
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
}
useHead({ title: headerTitle })
onMounted(async () => {
// Referentiels en best-effort (echec non bloquant : l'embed alimente les
// libelles des valeurs courantes).
referentials.loadCommon().catch(() => {})
await load()
if (supplier.value) hydrate(supplier.value)
})
</script>
@@ -1,468 +0,0 @@
<template>
<div>
<!-- En-tete : retour repertoire + nom du fournisseur + actions (Modifier / Archiver|Restaurer). -->
<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.consultation.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
<!-- gap-12 = 48px : meme espacement que Ajouter / Filtres du repertoire. -->
<div class="ml-auto flex items-center gap-12">
<MalioButton
v-if="canEdit"
variant="secondary"
icon-name="mdi:pencil-outline"
icon-position="left"
:label="t('commercial.suppliers.action.edit')"
@click="goEdit"
/>
<MalioButton
v-if="showArchive"
variant="secondary"
icon-name="mdi:archive-arrow-down-outline"
icon-position="left"
:label="t('commercial.suppliers.action.archive')"
@click="askToggleArchive"
/>
<MalioButton
v-if="showRestore"
variant="secondary"
icon-name="mdi:archive-arrow-up-outline"
icon-position="left"
:label="t('commercial.suppliers.action.restore')"
@click="askToggleArchive"
/>
</div>
</div>
<!-- Etats de chargement / introuvable. -->
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('commercial.suppliers.consultation.loading') }}</p>
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('commercial.suppliers.consultation.notFound') }}</p>
<template v-else-if="supplier">
<!-- Formulaire principal (lecture seule) -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
:model-value="supplier.companyName"
:label="t('commercial.suppliers.form.main.companyName')"
readonly
/>
<MalioSelectCheckbox
:model-value="categoryIris"
:options="mainCategoryOptions"
:label="t('commercial.suppliers.form.main.categories')"
:display-tag="true"
readonly
/>
</div>
<!-- Onglets (navigation libre, tout en lecture seule) -->
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" 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)]">
<!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas
sur les inputs (champ 40px centre dans un h-12). -->
<MalioInputTextArea
:model-value="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
/>
<MalioInputText
:model-value="information.competitors"
:label="t('commercial.suppliers.form.information.competitors')"
readonly
/>
<MalioDate
:model-value="information.foundedAt"
:label="t('commercial.suppliers.form.information.foundedAt')"
readonly
/>
<MalioInputText
:model-value="information.employeesCount"
:label="t('commercial.suppliers.form.information.employeesCount')"
readonly
/>
<MalioInputAmount
:model-value="information.revenueAmount"
:label="t('commercial.suppliers.form.information.revenueAmount')"
readonly
/>
<MalioInputText
:model-value="information.directorName"
:label="t('commercial.suppliers.form.information.directorName')"
readonly
/>
<MalioInputAmount
:model-value="information.profitAmount"
:label="t('commercial.suppliers.form.information.profitAmount')"
readonly
/>
<!-- Volume previsionnel : specifique fournisseur (entier). -->
<MalioInputText
:model-value="information.volumeForecast"
:label="t('commercial.suppliers.form.information.volumeForecast')"
readonly
/>
</div>
</template>
<!-- Onglet Contacts -->
<template #contacts>
<div class="mt-12 flex flex-col gap-6">
<SupplierContactBlock
v-for="(contact, index) in contacts"
:key="contact.id ?? index"
:model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
readonly
/>
</div>
</template>
<!-- Onglet Adresses -->
<template #addresses>
<div class="mt-12 flex flex-col gap-6">
<SupplierAddressBlock
v-for="(view, index) in addressViews"
:key="view.draft.id ?? index"
:model-value="view.draft"
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
:category-options="view.categoryOptions"
:site-options="allSiteOptions"
:contact-options="contactOptions"
:country-options="countryOptions"
readonly
/>
</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
:model-value="accounting.siren"
:label="t('commercial.suppliers.form.accounting.siren')"
:mask="SIREN_MASK"
readonly
/>
<MalioInputText
:model-value="accounting.accountNumber"
:label="t('commercial.suppliers.form.accounting.accountNumber')"
readonly
/>
<MalioSelect
:model-value="accounting.tvaModeIri"
:options="tvaModeOptions"
:label="t('commercial.suppliers.form.accounting.tvaMode')"
empty-option-label=""
readonly
/>
<MalioInputText
:model-value="accounting.nTva"
:label="t('commercial.suppliers.form.accounting.nTva')"
readonly
/>
<MalioSelect
:model-value="accounting.paymentDelayIri"
:options="paymentDelayOptions"
:label="t('commercial.suppliers.form.accounting.paymentDelay')"
empty-option-label=""
readonly
/>
<MalioSelect
:model-value="accounting.paymentTypeIri"
:options="paymentTypeOptions"
:label="t('commercial.suppliers.form.accounting.paymentType')"
empty-option-label=""
readonly
/>
<MalioSelect
v-if="accounting.bankIri"
:model-value="accounting.bankIri"
:options="bankOptions"
:label="t('commercial.suppliers.form.accounting.bank')"
empty-option-label=""
readonly
/>
</div>
</div>
<!-- Blocs RIB (0..n), lecture seule. -->
<div
v-for="(rib, index) in ribs"
:key="rib.id ?? index"
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
:model-value="rib.label"
:label="t('commercial.suppliers.form.accounting.ribLabel')"
readonly
/>
<MalioInputText
:model-value="rib.bic"
:label="t('commercial.suppliers.form.accounting.ribBic')"
readonly
/>
<MalioInputText
:model-value="rib.iban"
:label="t('commercial.suppliers.form.accounting.ribIban')"
readonly
/>
</div>
</div>
</div>
</template>
<!-- Onglets non encore implementes : frame vide (navigation libre). -->
<template #transport><ComingSoonPlaceholder /></template>
<template #statistics><ComingSoonPlaceholder /></template>
<template #reports><ComingSoonPlaceholder /></template>
<template #exchanges><ComingSoonPlaceholder /></template>
</MalioTabList>
</template>
<!-- Modal de confirmation Archiver / Restaurer. -->
<MalioModal v-model="confirmOpen" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">
{{ isArchived ? t('commercial.suppliers.consultation.confirmRestore.title') : t('commercial.suppliers.consultation.confirmArchive.title') }}
</h2>
</template>
<p>{{ isArchived ? t('commercial.suppliers.consultation.confirmRestore.message') : t('commercial.suppliers.consultation.confirmArchive.message') }}</p>
<template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('commercial.suppliers.form.confirmDelete.cancel')"
@click="confirmOpen = false"
/>
<MalioButton
:variant="isArchived ? 'primary' : 'danger'"
button-class="flex-1"
:label="t('commercial.suppliers.form.confirmDelete.confirm')"
:disabled="toggling"
@click="confirmToggleArchive"
/>
</template>
</MalioModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useSupplier } from '~/modules/commercial/composables/useSupplier'
import { buildSupplierFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/supplierFormRules'
import { readHistoryTab } from '~/shared/utils/historyTab'
import {
canEditSupplier,
categoryOptionsOf,
contactOptionsOf,
emptyAddress,
mapAccountingDraft,
mapAddressView,
mapContactToDraft,
mapRibToDraft,
paymentTypeCodeOf,
referentialOptionOf,
showArchiveAction,
showRestoreAction,
type SelectOption,
type SupplierDetail,
} from '~/modules/commercial/utils/supplierConsultation'
import { emptyContact } from '~/modules/commercial/types/supplierForm'
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
const SIREN_MASK = '#########'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const toast = useToast()
const { can, canAny } = usePermissions()
const authStore = useAuthStore()
// Gating de la route : la consultation exige `view`. Usine (sans view) est
// redirige vers le repertoire (lui-meme protege). Cf. matrice § 2.7.
if (!can('commercial.suppliers.view')) {
await navigateTo('/suppliers')
}
const supplierId = route.params.id as string
const { supplier, loading, error, load, archive, restore } = useSupplier(supplierId)
// Permissions / visibilite des actions
const canAccountingView = computed(() => can('commercial.suppliers.accounting.view'))
const canEdit = computed(() => canEditSupplier(canAny))
const isArchived = computed(() => supplier.value?.isArchived === true)
const showArchive = computed(() => showArchiveAction(can, isArchived.value))
const showRestore = computed(() => showRestoreAction(can, isArchived.value))
const headerTitle = computed(() => supplier.value?.companyName ?? t('commercial.suppliers.consultation.title'))
// Donnees derivees du payload (lecture seule)
const categoryIris = computed(() => (supplier.value?.categories ?? []).map(c => c['@id']))
const information = computed(() => ({
description: supplier.value?.description ?? null,
competitors: supplier.value?.competitors ?? null,
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime renvoye.
foundedAt: supplier.value?.foundedAt ? supplier.value.foundedAt.slice(0, 10) : null,
employeesCount: supplier.value?.employeesCount != null ? String(supplier.value.employeesCount) : null,
revenueAmount: supplier.value?.revenueAmount ?? null,
profitAmount: supplier.value?.profitAmount ?? null,
directorName: supplier.value?.directorName ?? null,
volumeForecast: supplier.value?.volumeForecast != null ? String(supplier.value.volumeForecast) : null,
}))
// Chaque bloc reste visible meme vide en consultation : si la collection est
// vide, on affiche un bloc vierge en lecture seule (pas de message « Aucun »).
const contacts = computed(() => {
const list = (supplier.value?.contacts ?? []).map(mapContactToDraft)
return list.length ? list : [emptyContact()]
})
// Vue par adresse : brouillon + options (sites/categories) propres a l'adresse.
const addressViews = computed(() => {
const views = (supplier.value?.addresses ?? []).map(mapAddressView)
return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }]
})
// Exception au placeholder ci-dessus : on n'affiche AUCUN bloc RIB quand le
// fournisseur n'en a pas. ERP-121 : un fournisseur peut desormais conserver des RIB
// « dormants » apres etre repasse hors-LCR (on ne les supprime plus). En consultation,
// decision metier = on les masque TOTALEMENT : on n'affiche les RIB que si le type de
// reglement courant est LCR (le `code` est embarque sous supplier:read:accounting).
const ribs = computed(() =>
isRibRequiredForPaymentType(paymentTypeCodeOf(supplier.value?.paymentType))
? (supplier.value?.ribs ?? []).map(mapRibToDraft)
: [],
)
// Draft comptable (tout null si l'utilisateur n'a pas accounting.view).
const accounting = computed(() => mapAccountingDraft(supplier.value ?? ({} as SupplierDetail)))
// Options des selects (construites depuis l'EMBED, jamais via un GET de
// referentiel : /categories et /sites sont en 403 pour les roles metier
// non-admin, ce qui laisserait les libelles vides).
const mainCategoryOptions = computed(() => categoryOptionsOf(supplier.value?.categories))
const contactOptions = computed(() => contactOptionsOf(supplier.value?.contacts))
// Liste COMPLETE des sites disponibles, issue de /api/me (groupe me:read donc
// pas de 403 pour les roles metier, contrairement a GET /sites). Libelle = numero
// de departement (2 premiers chiffres du code postal). Permet d'afficher TOUJOURS
// toutes les cases « Sites » (86 / 17 / 82) dans le bloc adresse, meme celles non
// rattachees a l'adresse consultee (les rattachees restent cochees via siteIris).
const allSiteOptions = computed<SelectOption[]>(() =>
(authStore.user?.sites ?? []).map(s => ({
value: `/api/sites/${s.id}`,
label: (s.postalCode ?? '').slice(0, 2),
})),
)
// Pays (consultation, lecture seule) : derive des adresses du fournisseur, comme
// l'ecran client. Le referentiel `country` (ERP-116) n'est pas charge ici, l'ecran
// n'affiche que les valeurs deja stockees.
const countryOptions = computed<SelectOption[]>(() =>
[...new Set(
(supplier.value?.addresses ?? [])
.map(a => a.country)
.filter((c): c is string => !!c),
)].map(c => ({ value: c, label: c })),
)
// Selects comptables : libelle issu de l'embed (option unique ou vide).
const tvaModeOptions = computed(() => referentialOptionOf(supplier.value?.tvaMode))
const paymentDelayOptions = computed(() => referentialOptionOf(supplier.value?.paymentDelay))
const paymentTypeOptions = computed(() => referentialOptionOf(supplier.value?.paymentType))
const bankOptions = computed(() => referentialOptionOf(supplier.value?.bank))
// Onglets : navigation LIBRE (pas de sequence forcee en consultation)
// 3 onglets actifs (Information, Contacts, Adresses, + Comptabilite si droit) et
// 4 coquilles (Transport, Statistiques, Rapports, Echanges).
const tabKeys = computed(() => buildSupplierFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true }))
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',
statistics: 'mdi:finance',
reports: 'mdi:file-document-edit-outline',
exchanges: 'mdi:account-group-outline',
}
const tabs = computed(() => tabKeys.value.map(key => ({
key,
label: t(`commercial.suppliers.tab.${key}`),
icon: TAB_ICONS[key],
})))
// Onglet initial : repris de l'edition au retour (history.state), sinon Information.
const activeTab = ref(readHistoryTab(tabKeys.value) ?? 'information')
// Navigation
function goBack(): void {
router.push('/suppliers')
}
/** Bascule en edition en conservant l'onglet courant (via history.state). */
function goEdit(): void {
router.push({ path: `/suppliers/${supplierId}/edit`, state: { tab: activeTab.value } })
}
// Archivage / Restauration
const confirmOpen = ref(false)
const toggling = ref(false)
function askToggleArchive(): void {
confirmOpen.value = true
}
/**
* Confirme l'archivage ou la restauration (PATCH isArchived seul). Gere le 409
* de conflit d'homonyme actif a la restauration avec un message dedie.
*/
async function confirmToggleArchive(): Promise<void> {
if (toggling.value) return
toggling.value = true
const restoring = isArchived.value
try {
if (restoring) {
await restore()
toast.success({ title: t('commercial.suppliers.toast.restoreSuccess') })
}
else {
await archive()
toast.success({ title: t('commercial.suppliers.toast.archiveSuccess') })
}
confirmOpen.value = false
}
catch (e) {
const status = (e as { response?: { status?: number } })?.response?.status
toast.error({
title: t('commercial.suppliers.toast.error'),
message: restoring && status === 409
? t('commercial.suppliers.toast.restoreConflict')
: t('commercial.suppliers.toast.error'),
})
}
finally {
toggling.value = false
}
}
useHead({ title: headerTitle })
onMounted(load)
</script>
@@ -1,434 +0,0 @@
<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>
@@ -1,876 +0,0 @@
<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 && visibleRibs.length > 1"
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 : referentiel `country` charge via l'API (ERP-116), aligne sur l'ecran
// client. France garantie en tete pour rester preselectionnable par defaut sur
// chaque adresse meme si `/countries` echoue (resilience ERP-102).
const countryOptions = computed<RefOption[]>(() => {
const list = referentials.countries.value
return list.some(c => c.value === 'France')
? list
: [{ value: 'France', label: 'France' }, ...list]
})
// « + Adresse » desactive tant que la derniere adresse n'est pas valide.
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
// ERP-121 : on ne jette plus la saisie RIB au passage hors-LCR. Les blocs sont
// masques (visibleRibs = []) mais conserves, et reapparaissent si l'on repasse
// en LCR. Ils ne sont persistes qu'a la validation SOUS LCR (cf. submitAccounting),
// donc une saisie abandonnee hors-LCR ne cree aucun RIB orphelin.
if (isRibRequired.value) {
if (ribs.value.length === 0) ribs.value.push(emptyRib())
}
else {
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 UNIQUEMENT sous LCR (erreurs inline par
// ligne). Hors-LCR (ERP-121), une saisie RIB eventuellement restee dans le
// brouillon est masquee et n'est PAS persistee (pas de RIB orphelin sur un
// fournisseur en virement). On ne saute une amorce neuve vide QUE s'il reste
// un autre RIB soumettable : sinon (LCR sans aucun RIB rempli) on la soumet
// pour declencher la 422 NotBlank inline.
if (isRibRequired.value) {
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
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 => hasSubmittableRib && 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,10 +30,6 @@ export interface AddressFormDraft {
isProspect: boolean isProspect: boolean
isDelivery: boolean isDelivery: boolean
isBilling: boolean isBilling: boolean
/** Adresse Courtier — type autonome exclusif. */
isBroker: boolean
/** Adresse Distributeur — type autonome exclusif. */
isDistributor: boolean
country: string country: string
postalCode: string | null postalCode: string | null
city: string | null city: string | null
@@ -47,10 +43,6 @@ export interface AddressFormDraft {
contactIris: string[] contactIris: string[]
/** Email de facturation (obligatoire si isBilling — RG-1.11). */ /** Email de facturation (obligatoire si isBilling — RG-1.11). */
billingEmail: string | null 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). */ /** Un RIB du client (onglet Comptabilite). */
@@ -83,8 +75,6 @@ export function emptyAddress(): AddressFormDraft {
isProspect: false, isProspect: false,
isDelivery: false, isDelivery: false,
isBilling: false, isBilling: false,
isBroker: false,
isDistributor: false,
country: 'France', country: 'France',
postalCode: null, postalCode: null,
city: null, city: null,
@@ -94,8 +84,6 @@ export function emptyAddress(): AddressFormDraft {
siteIris: [], siteIris: [],
contactIris: [], contactIris: [],
billingEmail: null, billingEmail: null,
billingEmailSecondary: null,
hasSecondaryBillingEmail: false,
} }
} }
@@ -1,109 +0,0 @@
/**
* 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,
}
}
@@ -9,7 +9,6 @@ import {
mapAddressView, mapAddressView,
mapContactToDraft, mapContactToDraft,
mapRibToDraft, mapRibToDraft,
paymentTypeCodeOf,
referentialOptionOf, referentialOptionOf,
relationOf, relationOf,
showArchiveAction, showArchiveAction,
@@ -234,17 +233,3 @@ describe('showArchiveAction / showRestoreAction', () => {
expect(showRestoreAction(can([]), true)).toBe(false) expect(showRestoreAction(can([]), true)).toBe(false)
}) })
}) })
describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', () => {
it('retourne le code metier quand le type de reglement est embarque', () => {
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1', code: 'LCR' })).toBe('LCR')
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/2', code: 'VIREMENT' })).toBe('VIREMENT')
})
it('retourne null pour un IRI nu, un objet sans code, ou une relation absente', () => {
expect(paymentTypeCodeOf('/api/payment_types/1')).toBeNull()
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1' })).toBeNull()
expect(paymentTypeCodeOf(null)).toBeNull()
expect(paymentTypeCodeOf(undefined)).toBeNull()
})
})
@@ -61,9 +61,7 @@ function accountingDraft(overrides: Partial<AccountingFormDraft> = {}): Accounti
// Le contact inline (nom/prenom/telephones/email) ne fait plus partie du groupe // Le contact inline (nom/prenom/telephones/email) ne fait plus partie du groupe
// main : les coordonnees vivent desormais sur la sous-ressource ClientContact. // main : les coordonnees vivent desormais sur la sous-ressource ClientContact.
const MAIN_KEYS = [ const MAIN_KEYS = [
// relationType : champ transitoire envoye au back pour la validation croisee 'companyName', 'categories', 'distributor', 'broker', 'triageService',
// « relation choisie => FK obligatoire » (RG-1.03 bis, ERP-119).
'companyName', 'categories', 'relationType', 'distributor', 'broker', 'triageService',
] ]
const INFORMATION_KEYS = [ const INFORMATION_KEYS = [
'description', 'competitors', 'foundedAt', 'employeesCount', 'description', 'competitors', 'foundedAt', 'employeesCount',
@@ -101,27 +99,6 @@ describe('buildMainPayload — scoping strict groupe client:write:main', () => {
expect(payload.distributor).toBeNull() expect(payload.distributor).toBeNull()
expect(payload.broker).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', () => { describe('buildInformationPayload — scoping strict groupe client:write:information', () => {
@@ -165,82 +142,19 @@ describe('buildContactPayload / buildAddressPayload / buildRibPayload', () => {
it('adresse : email facturation conserve uniquement si requis (RG-1.11)', () => { it('adresse : email facturation conserve uniquement si requis (RG-1.11)', () => {
const address: AddressFormDraft = { const address: AddressFormDraft = {
id: 3, isProspect: false, isDelivery: false, isBilling: true, isBroker: false, isDistributor: false, country: 'France', id: 3, isProspect: false, isDelivery: false, isBilling: true, country: 'France',
postalCode: '86100', city: 'Châtellerault', street: '1 rue X', streetComplement: null, postalCode: '86100', city: 'Châtellerault', street: '1 rue X', streetComplement: null,
categoryIris: ['/api/categories/2'], siteIris: ['/api/sites/1'], contactIris: [], categoryIris: ['/api/categories/2'], siteIris: ['/api/sites/1'], contactIris: [],
billingEmail: 'facturation@acme.fr', billingEmailSecondary: 'compta@acme.fr', hasSecondaryBillingEmail: true, billingEmail: 'facturation@acme.fr',
} }
expect(buildAddressPayload(address, true).billingEmail).toBe('facturation@acme.fr') expect(buildAddressPayload(address, true).billingEmail).toBe('facturation@acme.fr')
expect(buildAddressPayload(address, false).billingEmail).toBeNull() 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', () => { it('rib : label / bic / iban transmis tels quels', () => {
const rib: RibFormDraft = { id: 1, label: 'Compte principal', bic: 'BNPAFRPP', iban: 'FR76...' } const rib: RibFormDraft = { id: 1, label: 'Compte principal', bic: 'BNPAFRPP', iban: 'FR76...' }
expect(buildRibPayload(rib)).toEqual({ 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'])
})
})
// Bug edition : en PATCH (merge), une cle de champ requis OMISE laisse la valeur
// serveur inchangee -> faux 200 quand l'utilisateur vide le champ. En `forUpdate`,
// on envoie `''` (chaine valide, pas de 400 de type) -> NotBlank 422 inline.
describe('forUpdate (EDITION/PATCH) : champ requis vide -> `\'\'` au lieu d\'etre omis', () => {
it('buildMainPayload : companyName vide envoye en `\'\'`', () => {
const payload = buildMainPayload(mainDraft({ companyName: '' }), { forUpdate: true })
expect('companyName' in payload).toBe(true)
expect(payload.companyName).toBe('')
})
it('buildAddressPayload : postalCode / city / street vides envoyes en `\'\'`', () => {
const address: AddressFormDraft = {
id: 7, isProspect: false, isDelivery: true, isBilling: false, isBroker: false, isDistributor: false, country: 'France',
postalCode: '', city: null, street: '1 rue X', streetComplement: null,
categoryIris: ['/api/categories/2'], siteIris: ['/api/sites/1'], contactIris: [],
billingEmail: null, billingEmailSecondary: null, hasSecondaryBillingEmail: false,
}
const payload = buildAddressPayload(address, false, { forUpdate: true })
expect(payload.postalCode).toBe('')
expect(payload.city).toBe('')
// Un champ requis renseigne reste tel quel.
expect(payload.street).toBe('1 rue X')
})
it('buildRibPayload : label / bic vides envoyes en `\'\'`, iban conserve', () => {
const payload = buildRibPayload({ id: 4, label: '', bic: null, iban: 'FR7612345' }, { forUpdate: true })
expect(payload.label).toBe('')
expect(payload.bic).toBe('')
expect(payload.iban).toBe('FR7612345')
})
}) })
describe('mapMainDraft — pre-remplissage bloc principal', () => { describe('mapMainDraft — pre-remplissage bloc principal', () => {
@@ -7,22 +7,14 @@ import {
canSelectDeliveryOrBilling, canSelectDeliveryOrBilling,
canSelectProspect, canSelectProspect,
hasAllRequiredAccountingFields, hasAllRequiredAccountingFields,
hasAtLeastOneInformationField,
hasAtLeastOneValidContact, hasAtLeastOneValidContact,
isAddressValid,
isBankRequiredForPaymentType, isBankRequiredForPaymentType,
isBillingEmailRequired, isBillingEmailRequired,
isBlankRow, isBlankRow,
isContactBlank, isContactBlank,
isContactNamed, isContactNamed,
isRibBlank, isRibBlank,
isRibComplete,
isRibRequiredForPaymentType, isRibRequiredForPaymentType,
lastFillableTabKey,
omitEmptyRequired,
showsRelationAndTriageFields,
type AddressFlagsDraft,
type AddressValidityDraft,
type ContactDraft, type ContactDraft,
type ContactFillableDraft, type ContactFillableDraft,
} from '../clientFormRules' } from '../clientFormRules'
@@ -71,24 +63,6 @@ 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)', () => { describe('isContactNamed (RG-1.05)', () => {
it('vrai si le prenom est renseigne', () => { it('vrai si le prenom est renseigne', () => {
expect(isContactNamed({ firstName: 'Alice', lastName: null })).toBe(true) expect(isContactNamed({ firstName: 'Alice', lastName: null })).toBe(true)
@@ -169,79 +143,83 @@ 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)', () => { describe('exclusivite Prospect / Livraison / Facturation (RG-1.06/07/08)', () => {
it('Prospect est selectionnable tant que ni Livraison ni Facturation', () => { it('Prospect est selectionnable tant que ni Livraison ni Facturation', () => {
expect(canSelectProspect(flags())).toBe(true) expect(canSelectProspect({ isProspect: false, isDelivery: false, isBilling: false })).toBe(true)
expect(canSelectProspect(flags({ isDelivery: true }))).toBe(false) expect(canSelectProspect({ isProspect: false, isDelivery: true, isBilling: false })).toBe(false)
expect(canSelectProspect(flags({ isBilling: true }))).toBe(false) expect(canSelectProspect({ isProspect: false, isDelivery: false, isBilling: true })).toBe(false)
}) })
it('Livraison / Facturation selectionnables tant que pas Prospect', () => { it('Livraison / Facturation selectionnables tant que pas Prospect', () => {
expect(canSelectDeliveryOrBilling(flags())).toBe(true) expect(canSelectDeliveryOrBilling({ isProspect: false, isDelivery: false, isBilling: false })).toBe(true)
expect(canSelectDeliveryOrBilling(flags({ isProspect: true }))).toBe(false) expect(canSelectDeliveryOrBilling({ isProspect: true, isDelivery: false, isBilling: false })).toBe(false)
}) })
it('cocher Prospect efface Livraison et Facturation', () => { it('cocher Prospect efface Livraison et Facturation', () => {
const next = applyProspectExclusivity(flags({ isDelivery: true, isBilling: true }), 'isProspect', true) const next = applyProspectExclusivity(
expect(next).toEqual(flags({ isProspect: true })) { isProspect: false, isDelivery: true, isBilling: true },
'isProspect',
true,
)
expect(next).toEqual({ isProspect: true, isDelivery: false, isBilling: false })
}) })
it('cocher Livraison efface Prospect', () => { it('cocher Livraison efface Prospect', () => {
const next = applyProspectExclusivity(flags({ isProspect: true }), 'isDelivery', true) const next = applyProspectExclusivity(
expect(next).toEqual(flags({ isDelivery: true })) { isProspect: true, isDelivery: false, isBilling: false },
'isDelivery',
true,
)
expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: false })
}) })
it('cocher Facturation efface Prospect mais conserve Livraison', () => { it('cocher Facturation efface Prospect mais conserve Livraison', () => {
const next = applyProspectExclusivity(flags({ isProspect: true, isDelivery: true }), 'isBilling', true) const next = applyProspectExclusivity(
expect(next).toEqual(flags({ isDelivery: true, isBilling: true })) { isProspect: true, isDelivery: true, isBilling: false },
'isBilling',
true,
)
expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: true })
}) })
it('decocher un drapeau ne reactive rien d autre', () => { it('decocher un drapeau ne reactive rien d autre', () => {
const next = applyProspectExclusivity(flags({ isDelivery: true, isBilling: true }), 'isBilling', false) const next = applyProspectExclusivity(
expect(next).toEqual(flags({ isDelivery: true })) { isProspect: false, isDelivery: true, isBilling: true },
'isBilling',
false,
)
expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: false })
}) })
}) })
describe('isBillingEmailRequired (RG-1.11)', () => { describe('isBillingEmailRequired (RG-1.11)', () => {
it('obligatoire uniquement si Facturation est coche', () => { it('obligatoire uniquement si Facturation est coche', () => {
expect(isBillingEmailRequired(flags({ isBilling: true }))).toBe(true) expect(isBillingEmailRequired({ isProspect: false, isDelivery: false, isBilling: true })).toBe(true)
expect(isBillingEmailRequired(flags({ isDelivery: true }))).toBe(false) expect(isBillingEmailRequired({ isProspect: false, isDelivery: true, isBilling: false })).toBe(false)
}) })
}) })
describe('type d\'adresse (Select front) <-> drapeaux back', () => { describe('type d\'adresse (Select front) <-> drapeaux back', () => {
it('addressFlagsFromType mappe chaque type vers les bons drapeaux', () => { it('addressFlagsFromType mappe chaque type vers les bons drapeaux', () => {
expect(addressFlagsFromType('prospect')).toEqual(flags({ isProspect: true })) expect(addressFlagsFromType('prospect')).toEqual({ isProspect: true, isDelivery: false, isBilling: false })
expect(addressFlagsFromType('delivery')).toEqual(flags({ isDelivery: true })) expect(addressFlagsFromType('delivery')).toEqual({ isProspect: false, isDelivery: true, isBilling: false })
expect(addressFlagsFromType('billing')).toEqual(flags({ isBilling: true })) expect(addressFlagsFromType('billing')).toEqual({ isProspect: false, isDelivery: false, isBilling: true })
expect(addressFlagsFromType('delivery_billing')).toEqual(flags({ isDelivery: true, isBilling: true })) expect(addressFlagsFromType('delivery_billing')).toEqual({ isProspect: false, isDelivery: true, isBilling: true })
expect(addressFlagsFromType('broker')).toEqual(flags({ isBroker: true }))
expect(addressFlagsFromType('distributor')).toEqual(flags({ isDistributor: true }))
}) })
it('addressTypeFromFlags reconstruit le type (Prospect/Courtier/Distributeur autonomes, livraison+facturation groupes)', () => { it('addressTypeFromFlags reconstruit le type (Prospect prioritaire, livraison+facturation groupes)', () => {
expect(addressTypeFromFlags(flags({ isProspect: true }))).toBe('prospect') expect(addressTypeFromFlags({ isProspect: true, isDelivery: false, isBilling: false })).toBe('prospect')
expect(addressTypeFromFlags(flags({ isDelivery: true }))).toBe('delivery') expect(addressTypeFromFlags({ isProspect: false, isDelivery: true, isBilling: false })).toBe('delivery')
expect(addressTypeFromFlags(flags({ isBilling: true }))).toBe('billing') expect(addressTypeFromFlags({ isProspect: false, isDelivery: false, isBilling: true })).toBe('billing')
expect(addressTypeFromFlags(flags({ isDelivery: true, isBilling: true }))).toBe('delivery_billing') expect(addressTypeFromFlags({ isProspect: false, 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)', () => { it('addressTypeFromFlags retourne null quand aucun drapeau (amorce vierge -> bouton bloque)', () => {
expect(addressTypeFromFlags(flags())).toBeNull() expect(addressTypeFromFlags({ isProspect: false, isDelivery: false, isBilling: false })).toBeNull()
}) })
it('aller-retour type -> drapeaux -> type stable pour les 6 types', () => { it('aller-retour type -> drapeaux -> type stable pour les 4 types', () => {
for (const type of ['prospect', 'delivery', 'billing', 'delivery_billing', 'broker', 'distributor'] as const) { for (const type of ['prospect', 'delivery', 'billing', 'delivery_billing'] as const) {
expect(addressTypeFromFlags(addressFlagsFromType(type))).toBe(type) expect(addressTypeFromFlags(addressFlagsFromType(type))).toBe(type)
} }
}) })
@@ -293,128 +271,3 @@ describe('hasAllRequiredAccountingFields (RG-1.30)', () => {
})).toBe(false) })).toBe(false)
}) })
}) })
describe('showsRelationAndTriageFields (affichage Relation + Triage selon categorie)', () => {
it('faux par defaut (aucune categorie selectionnee)', () => {
expect(showsRelationAndTriageFields([])).toBe(false)
})
it('faux si seules des categories Distributeur / Courtier sont selectionnees', () => {
expect(showsRelationAndTriageFields(['DISTRIBUTEUR'])).toBe(false)
expect(showsRelationAndTriageFields(['COURTIER'])).toBe(false)
expect(showsRelationAndTriageFields(['DISTRIBUTEUR', 'COURTIER'])).toBe(false)
})
it('vrai des qu\'une categorie ordinaire est selectionnee', () => {
expect(showsRelationAndTriageFields(['CLIENT'])).toBe(true)
expect(showsRelationAndTriageFields(['DISTRIBUTEUR', 'CLIENT'])).toBe(true)
})
})
describe('hasAtLeastOneInformationField (pas de validation a vide a la creation)', () => {
const blank = {
description: null,
competitors: null,
foundedAt: null,
employeesCount: null,
revenueAmount: null,
profitAmount: null,
directorName: null,
}
it('faux quand aucun champ n\'est rempli (onglet vierge)', () => {
expect(hasAtLeastOneInformationField(blank)).toBe(false)
expect(hasAtLeastOneInformationField({ ...blank, description: ' ' })).toBe(false)
})
it('vrai des qu\'un champ porte une valeur', () => {
expect(hasAtLeastOneInformationField({ ...blank, description: 'Acteur majeur' })).toBe(true)
expect(hasAtLeastOneInformationField({ ...blank, employeesCount: '42' })).toBe(true)
expect(hasAtLeastOneInformationField({ ...blank, foundedAt: '2020-01-01' })).toBe(true)
})
})
describe('isAddressValid (gating « + Adresse » + validation onglet)', () => {
/** Adresse de livraison valide (type + site + categorie ; pas de facturation). */
function validDelivery(): AddressValidityDraft {
return {
isProspect: false,
isDelivery: true,
isBilling: false,
isBroker: false,
isDistributor: false,
categoryIris: ['/api/client_categories/1'],
siteIris: ['/api/sites/1'],
billingEmail: null,
}
}
it('vrai quand type + >= 1 site + >= 1 categorie (sans facturation)', () => {
expect(isAddressValid(validDelivery())).toBe(true)
})
it('faux si aucun drapeau (type d\'adresse non renseigne, amorce vierge)', () => {
expect(isAddressValid({ ...validDelivery(), isDelivery: false })).toBe(false)
})
it('faux si aucun site (RG-1.10)', () => {
expect(isAddressValid({ ...validDelivery(), siteIris: [] })).toBe(false)
})
it('faux si aucune categorie', () => {
expect(isAddressValid({ ...validDelivery(), categoryIris: [] })).toBe(false)
})
it('RG-1.11 : email de facturation obligatoire quand l\'adresse est de facturation', () => {
const billing: AddressValidityDraft = { ...validDelivery(), isBilling: true }
expect(isAddressValid({ ...billing, billingEmail: null })).toBe(false)
expect(isAddressValid({ ...billing, billingEmail: ' ' })).toBe(false)
expect(isAddressValid({ ...billing, billingEmail: 'facture@acme.fr' })).toBe(true)
})
})
describe('isRibComplete (gating « + RIB » + RG-1.13)', () => {
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 (null ou vide apres trim)', () => {
expect(isRibComplete({ label: null, bic: 'BNPAFRPP', iban: 'FR14...' })).toBe(false)
expect(isRibComplete({ label: 'Compte', bic: ' ', iban: 'FR14...' })).toBe(false)
expect(isRibComplete({ label: 'Compte', bic: 'BNPAFRPP', iban: null })).toBe(false)
})
it('faux pour un bloc totalement vide (amorce)', () => {
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 })
})
})
@@ -1,239 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
canEditSupplier,
categoryOptionsOf,
contactOptionsOf,
iriOf,
mapAccountingDraft,
mapAddressToDraft,
mapAddressView,
mapContactToDraft,
mapRibToDraft,
paymentTypeCodeOf,
referentialOptionOf,
showArchiveAction,
showRestoreAction,
siteOptionsOf,
type SupplierDetail,
} from '../supplierConsultation'
describe('iriOf', () => {
it('retourne l\'@id d\'une relation embarquee (objet)', () => {
expect(iriOf({ '@id': '/api/payment_types/14', code: 'LCR' })).toBe('/api/payment_types/14')
})
it('retourne la chaine telle quelle si la relation est deja un IRI', () => {
expect(iriOf('/api/banks/3')).toBe('/api/banks/3')
})
it('retourne null pour une relation absente (null / undefined / skip_null_values)', () => {
expect(iriOf(null)).toBeNull()
expect(iriOf(undefined)).toBeNull()
})
})
describe('mapContactToDraft', () => {
it('formate les telephones en XX XX XX XX XX et conserve l\'iri', () => {
const draft = mapContactToDraft({
'@id': '/api/supplier_contacts/39',
id: 39,
firstName: 'Marie',
lastName: 'Martin',
jobTitle: 'Responsable achats',
phonePrimary: '0612345678',
email: 'marie.martin@seed.test',
})
expect(draft.id).toBe(39)
expect(draft.iri).toBe('/api/supplier_contacts/39')
expect(draft.phonePrimary).toBe('06 12 34 56 78')
expect(draft.hasSecondaryPhone).toBe(false)
})
it('revele le 2e telephone quand phoneSecondary est present', () => {
const draft = mapContactToDraft({
'@id': '/api/supplier_contacts/40',
id: 40,
phonePrimary: '0600000000',
phoneSecondary: '0611111111',
})
expect(draft.hasSecondaryPhone).toBe(true)
expect(draft.phoneSecondary).toBe('06 11 11 11 11')
})
})
describe('mapAddressToDraft', () => {
it('mappe l\'enum addressType, les champs fournisseur et extrait les iris', () => {
const draft = mapAddressToDraft({
'@id': '/api/supplier_addresses/33',
id: 33,
addressType: 'DEPART',
country: 'France',
postalCode: '86000',
city: 'Poitiers',
street: '12 rue des Acacias',
bennes: 3,
triageProvider: true,
sites: [{ '@id': '/api/sites/87', name: 'Chatellerault', color: '#056CF2' }],
categories: [{ '@id': '/api/categories/2279', code: 'NEGOCIANT' }],
contacts: [{ '@id': '/api/supplier_contacts/39' }, '/api/supplier_contacts/41'],
})
expect(draft.addressType).toBe('DEPART')
expect(draft.siteIris).toEqual(['/api/sites/87'])
expect(draft.categoryIris).toEqual(['/api/categories/2279'])
expect(draft.contactIris).toEqual(['/api/supplier_contacts/39', '/api/supplier_contacts/41'])
// bennes (entier) → chaine pour MalioInputNumber.
expect(draft.bennes).toBe('3')
expect(draft.triageProvider).toBe(true)
expect(draft.city).toBe('Poitiers')
expect(draft.country).toBe('France')
})
it('tolere les champs absents (defauts : France, bennes « 0 », triage faux, type null)', () => {
const draft = mapAddressToDraft({ '@id': '/api/supplier_addresses/9', id: 9 })
expect(draft.addressType).toBeNull()
expect(draft.siteIris).toEqual([])
expect(draft.categoryIris).toEqual([])
expect(draft.contactIris).toEqual([])
expect(draft.country).toBe('France')
expect(draft.bennes).toBe('0')
expect(draft.triageProvider).toBe(false)
})
})
describe('mapRibToDraft', () => {
it('mappe label / bic / iban et l\'id serveur', () => {
const draft = mapRibToDraft({ '@id': '/api/supplier_ribs/27', id: 27, label: 'Compte principal', bic: 'BNPAFRPPXXX', iban: 'FR14...' })
expect(draft).toEqual({ id: 27, label: 'Compte principal', bic: 'BNPAFRPPXXX', iban: 'FR14...' })
})
})
describe('mapAccountingDraft', () => {
it('mappe les scalaires et resout les iris des referentiels embarques', () => {
const acc = mapAccountingDraft({
'@id': '/api/suppliers/85',
id: 85,
siren: '123456789',
accountNumber: 'F0001',
nTva: 'FR00123456789',
tvaMode: { '@id': '/api/tva_modes/30' },
paymentDelay: { '@id': '/api/payment_delays/11' },
paymentType: { '@id': '/api/payment_types/14', code: 'LCR' },
bank: { '@id': '/api/banks/3' },
} as SupplierDetail)
expect(acc).toEqual({
siren: '123456789',
accountNumber: 'F0001',
nTva: 'FR00123456789',
tvaModeIri: '/api/tva_modes/30',
paymentDelayIri: '/api/payment_delays/11',
paymentTypeIri: '/api/payment_types/14',
bankIri: '/api/banks/3',
})
})
it('renvoie des null quand les champs comptables sont absents (gating par omission, sans accounting.view)', () => {
const acc = mapAccountingDraft({} as SupplierDetail)
expect(acc).toEqual({
siren: null,
accountNumber: null,
nTva: null,
tvaModeIri: null,
paymentDelayIri: null,
paymentTypeIri: null,
bankIri: null,
})
})
})
describe('options construites depuis l\'embed (role-independantes)', () => {
it('categoryOptionsOf expose value=IRI, label=nom, code', () => {
expect(categoryOptionsOf([{ '@id': '/api/categories/2279', name: 'Negociant', code: 'NEGOCIANT' }])).toEqual([
{ value: '/api/categories/2279', label: 'Negociant', code: 'NEGOCIANT' },
])
})
it('siteOptionsOf expose value=IRI, label=nom', () => {
expect(siteOptionsOf([{ '@id': '/api/sites/87', name: 'Chatellerault', color: '#000' }])).toEqual([
{ value: '/api/sites/87', label: 'Chatellerault' },
])
})
it('contactOptionsOf compose le libelle (nom complet, sinon email)', () => {
expect(contactOptionsOf([
{ '@id': '/api/supplier_contacts/1', id: 1, firstName: 'Marie', lastName: 'Martin' },
{ '@id': '/api/supplier_contacts/2', id: 2, email: 'a@b.fr' },
])).toEqual([
{ value: '/api/supplier_contacts/1', label: 'Marie Martin' },
{ value: '/api/supplier_contacts/2', label: 'a@b.fr' },
])
})
it('referentialOptionOf : option unique depuis l\'embed, vide pour IRI nu / absent', () => {
expect(referentialOptionOf({ '@id': '/api/payment_types/14', label: 'LCR' })).toEqual([
{ value: '/api/payment_types/14', label: 'LCR' },
])
expect(referentialOptionOf('/api/banks/3')).toEqual([])
expect(referentialOptionOf(null)).toEqual([])
})
it('mapAddressView assemble brouillon + options propres a l\'adresse', () => {
const view = mapAddressView({
'@id': '/api/supplier_addresses/33',
id: 33,
addressType: 'RENDU',
city: 'Poitiers',
sites: [{ '@id': '/api/sites/87', name: 'Chatellerault' }],
categories: [{ '@id': '/api/categories/2279', name: 'Negociant', code: 'NEGOCIANT' }],
})
expect(view.draft.id).toBe(33)
expect(view.draft.addressType).toBe('RENDU')
expect(view.siteOptions).toEqual([{ value: '/api/sites/87', label: 'Chatellerault' }])
expect(view.categoryOptions).toEqual([{ value: '/api/categories/2279', label: 'Negociant', code: 'NEGOCIANT' }])
})
})
describe('canEditSupplier', () => {
const can = (granted: string[]) => (codes: string[]) => codes.some(c => granted.includes(c))
it('visible pour manage', () => {
expect(canEditSupplier(can(['commercial.suppliers.manage']))).toBe(true)
})
it('visible pour accounting.manage (role Compta)', () => {
expect(canEditSupplier(can(['commercial.suppliers.accounting.manage']))).toBe(true)
})
it('masque sans aucune des deux permissions (role Usine)', () => {
expect(canEditSupplier(can(['commercial.suppliers.view']))).toBe(false)
})
})
describe('showArchiveAction / showRestoreAction', () => {
const can = (granted: string[]) => (code: string) => granted.includes(code)
it('Archiver : visible avec la permission archive ET fournisseur non archive', () => {
expect(showArchiveAction(can(['commercial.suppliers.archive']), false)).toBe(true)
expect(showArchiveAction(can(['commercial.suppliers.archive']), true)).toBe(false)
expect(showArchiveAction(can([]), false)).toBe(false)
})
it('Restaurer : visible avec la permission archive ET fournisseur archive', () => {
expect(showRestoreAction(can(['commercial.suppliers.archive']), true)).toBe(true)
expect(showRestoreAction(can(['commercial.suppliers.archive']), false)).toBe(false)
expect(showRestoreAction(can([]), true)).toBe(false)
})
})
describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', () => {
it('retourne le code metier quand le type de reglement est embarque', () => {
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1', code: 'LCR' })).toBe('LCR')
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/2', code: 'VIREMENT' })).toBe('VIREMENT')
})
it('retourne null pour un IRI nu, un objet sans code, ou une relation absente', () => {
expect(paymentTypeCodeOf('/api/payment_types/1')).toBeNull()
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1' })).toBeNull()
expect(paymentTypeCodeOf(null)).toBeNull()
expect(paymentTypeCodeOf(undefined)).toBeNull()
})
})
@@ -1,218 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
buildAccountingPayload,
buildAddressPayload,
buildContactPayload,
buildInformationPayload,
buildMainPayload,
buildRibPayload,
mapAccountingFormDraft,
mapInformationDraft,
mapMainDraft,
resolveTabEditability,
} from '../supplierEdit'
import type { SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
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('CREATION : omet companyName vide (-> 422 NotBlank, ERP-119)', () => {
const payload = buildMainPayload({ companyName: null, categoryIris: [] })
expect('companyName' in payload).toBe(false)
expect(payload.categories).toEqual([])
})
it('EDITION (forUpdate) : companyName vide envoye en `\'\'` (PATCH -> 422 NotBlank, pas un faux 200)', () => {
const payload = buildMainPayload({ companyName: '', categoryIris: [] }, { forUpdate: true })
expect('companyName' in payload).toBe(true)
expect(payload.companyName).toBe('')
})
})
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('EDITION (forUpdate) : un champ requis vide est envoye en `\'\'` (et NON omis) pour declencher la 422 NotBlank au PATCH', () => {
// Bug edition : omettre la cle d'un champ requis vide laisse le PATCH garder
// l'ancienne valeur (faux 200). En `forUpdate`, on envoie `''` -> NotBlank 422.
const payload = buildAddressPayload({ ...emptyAddress(), addressType: 'DEPART', postalCode: '' }, { forUpdate: true })
expect('postalCode' in payload).toBe(true)
expect(payload.postalCode).toBe('')
// Un champ requis renseigne reste tel quel.
expect(payload.addressType).toBe('DEPART')
})
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')
})
})
describe('mapMainDraft — pre-remplissage bloc principal (companyName + categories, pas de relation M2)', () => {
it('extrait companyName et les IRI de categories', () => {
const draft = mapMainDraft({
'@id': '/api/suppliers/85', id: 85,
companyName: 'DOD862875',
categories: [{ '@id': '/api/categories/2279', code: 'NEGOCIANT' }],
} as SupplierDetail)
expect(draft.companyName).toBe('DOD862875')
expect(draft.categoryIris).toEqual(['/api/categories/2279'])
})
it('gere les cles omises (skip_null_values) sans planter', () => {
const draft = mapMainDraft({ '@id': '/api/suppliers/2', id: 2 } as SupplierDetail)
expect(draft.companyName).toBeNull()
expect(draft.categoryIris).toEqual([])
})
})
describe('mapInformationDraft — pre-remplissage onglet Information (+ volumeForecast M2)', () => {
it('tronque foundedAt, stringifie employeesCount et volumeForecast', () => {
const draft = mapInformationDraft({
'@id': '/api/suppliers/85', id: 85,
foundedAt: '2008-04-01T00:00:00+02:00', employeesCount: 42, volumeForecast: 8000,
} as SupplierDetail)
expect(draft.foundedAt).toBe('2008-04-01')
expect(draft.employeesCount).toBe('42')
expect(draft.volumeForecast).toBe('8000')
})
it('cles omises -> null (volumeForecast inclus)', () => {
const draft = mapInformationDraft({ '@id': '/api/suppliers/1', id: 1 } as SupplierDetail)
expect(draft.foundedAt).toBeNull()
expect(draft.employeesCount).toBeNull()
expect(draft.volumeForecast).toBeNull()
expect(draft.description).toBeNull()
})
})
describe('mapAccountingFormDraft — pre-remplissage onglet Comptabilite', () => {
it('extrait les scalaires et les IRI des referentiels embarques', () => {
const draft = mapAccountingFormDraft({
'@id': '/api/suppliers/85', id: 85,
siren: '123456789', accountNumber: 'F0001', nTva: 'FR00123456789',
tvaMode: { '@id': '/api/tva_modes/30', label: 'France (ventes)' },
paymentType: '/api/payment_types/14',
} as SupplierDetail)
expect(draft.siren).toBe('123456789')
expect(draft.tvaModeIri).toBe('/api/tva_modes/30')
expect(draft.paymentTypeIri).toBe('/api/payment_types/14')
expect(draft.bankIri).toBeNull()
})
it('cles comptables absentes (gating par omission) -> scalaires/IRI null', () => {
const draft = mapAccountingFormDraft({ '@id': '/api/suppliers/1', id: 1 } as SupplierDetail)
expect(draft.siren).toBeNull()
expect(draft.tvaModeIri).toBeNull()
expect(draft.bankIri).toBeNull()
})
})
describe('resolveTabEditability — gating par role (matrice § 2.7)', () => {
it('Admin : tout editable', () => {
expect(resolveTabEditability({ canManage: true, canAccountingView: true, canAccountingManage: true }))
.toEqual({ businessEditable: true, accountingVisible: true, accountingEditable: true })
})
it('Bureau / Commerciale (manage seul) : metier editable, Comptabilite masquee', () => {
expect(resolveTabEditability({ canManage: true, canAccountingView: false, canAccountingManage: false }))
.toEqual({ businessEditable: true, accountingVisible: false, accountingEditable: false })
})
it('Compta (accounting seul) : metier readonly, Comptabilite editable', () => {
expect(resolveTabEditability({ canManage: false, canAccountingView: true, canAccountingManage: true }))
.toEqual({ businessEditable: false, accountingVisible: true, accountingEditable: true })
})
it('Sans permission d\'edition : rien d\'editable', () => {
expect(resolveTabEditability({ canManage: false, canAccountingView: false, canAccountingManage: false }))
.toEqual({ businessEditable: false, accountingVisible: false, accountingEditable: false })
})
})
@@ -1,190 +0,0 @@
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,12 +63,9 @@ export interface AddressRead extends HydraRef {
street?: string | null street?: string | null
streetComplement?: string | null streetComplement?: string | null
billingEmail?: string | null billingEmail?: string | null
billingEmailSecondary?: string | null
isProspect?: boolean isProspect?: boolean
isDelivery?: boolean isDelivery?: boolean
isBilling?: boolean isBilling?: boolean
isBroker?: boolean
isDistributor?: boolean
sites?: SiteRead[] sites?: SiteRead[]
categories?: CategoryRead[] categories?: CategoryRead[]
// L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu. // L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu.
@@ -212,8 +209,6 @@ export function mapAddressToDraft(address: AddressRead): AddressFormDraft {
isProspect: address.isProspect ?? false, isProspect: address.isProspect ?? false,
isDelivery: address.isDelivery ?? false, isDelivery: address.isDelivery ?? false,
isBilling: address.isBilling ?? false, isBilling: address.isBilling ?? false,
isBroker: address.isBroker ?? false,
isDistributor: address.isDistributor ?? false,
country: address.country ?? 'France', country: address.country ?? 'France',
postalCode: address.postalCode ?? null, postalCode: address.postalCode ?? null,
city: address.city ?? null, city: address.city ?? null,
@@ -223,8 +218,6 @@ export function mapAddressToDraft(address: AddressRead): AddressFormDraft {
siteIris: (address.sites ?? []).map(s => s['@id']), siteIris: (address.sites ?? []).map(s => s['@id']),
contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])), contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])),
billingEmail: address.billingEmail ?? null, billingEmail: address.billingEmail ?? null,
billingEmailSecondary: address.billingEmailSecondary ?? null,
hasSecondaryBillingEmail: (address.billingEmailSecondary ?? null) !== null && address.billingEmailSecondary !== '',
} }
} }
@@ -293,21 +286,6 @@ export function referentialOptionOf(relation: Relation): SelectOption[] {
return [{ value: relation['@id'], label }] return [{ value: relation['@id'], label }]
} }
/**
* Code metier d'un referentiel embarque (ex: PaymentType.code = 'LCR' / 'VIREMENT'),
* ou null si la relation est absente / serialisee en IRI nu. Type-safe : la branche
* chaine (IRI nu) et l'absence sont court-circuitees avant l'acces au code. Sert a
* conditionner l'affichage selon le type de reglement courant (ERP-121 : RIB masques
* hors-LCR en consultation).
*/
export function paymentTypeCodeOf(relation: Relation): string | null {
if (!relation || typeof relation === 'string') {
return null
}
return (relation.code as string | undefined) ?? null
}
/** Vue d'une adresse (brouillon + options de select propres a l'adresse). */ /** Vue d'une adresse (brouillon + options de select propres a l'adresse). */
export function mapAddressView(address: AddressRead): AddressView { export function mapAddressView(address: AddressRead): AddressView {
return { return {
+12 -55
View File
@@ -12,8 +12,10 @@
* *
* Ces helpers ne touchent ni a l'API ni a l'etat reactif. * Ces helpers ne touchent ni a l'API ni a l'etat reactif.
* *
* NOTE : l'onglet Information est facultatif pour tous les roles (RG-1.04 * NOTE RG-1.04 (Information obligatoire pour la Commerciale) : volontairement NON
* « Information obligatoire pour la Commerciale » retiree cote back). * miroitee cote front (cf. clientFormRules.ts) /api/me n'expose pas le code de
* role et Bureau partage les permissions de Commerciale. Le back l'applique de
* maniere fiable (422) ; on laisse remonter ce 422 en toast.
*/ */
import { import {
@@ -21,13 +23,6 @@ import {
relationOf, relationOf,
type ClientDetail, type ClientDetail,
} from '~/modules/commercial/utils/clientConsultation' } from '~/modules/commercial/utils/clientConsultation'
import {
ADDRESS_REQUIRED_NON_NULLABLE_KEYS,
blankEmptyRequired,
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' import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
/** /**
@@ -140,50 +135,19 @@ export function mapAccountingFormDraft(client: ClientDetail): AccountingFormDraf
// ── Scoping strict des payloads PATCH ──────────────────────────────────────── // ── Scoping strict des payloads PATCH ────────────────────────────────────────
/**
* Options de construction d'un payload d'ecriture.
* - `forUpdate: false` (defaut, CREATION/POST) : champs requis vides OMIS -> 422
* NotBlank (le back ne reçoit pas la cle, la propriete garde son defaut).
* - `forUpdate: true` (EDITION/PATCH d'une ligne existante) : champs requis vides
* envoyes en `''` -> 422 NotBlank (sinon une cle omise laisse la valeur serveur
* inchangee, faux 200 cf. blankEmptyRequired).
*/
export interface BuildPayloadOptions {
forUpdate?: boolean
}
/** Selectionne le finaliseur des champs requis selon création (omit) vs édition (blank). */
function finalizeRequired<T extends Record<string, unknown>>(
payload: T,
requiredKeys: readonly string[],
options: BuildPayloadOptions,
): T {
return options.forUpdate
? blankEmptyRequired(payload, requiredKeys)
: omitEmptyRequired(payload, requiredKeys)
}
/** /**
* Payload du bloc principal groupe client:write:main UNIQUEMENT. La relation * Payload du bloc principal groupe client:write:main UNIQUEMENT. La relation
* Distributeur/Courtier est mutuellement exclusive (RG-1.03) : on ne renseigne * Distributeur/Courtier est mutuellement exclusive (RG-1.03) : on ne renseigne
* que la FK correspondant au type choisi, l'autre est forcee a null. * que la FK correspondant au type choisi, l'autre est forcee a null.
*/ */
export function buildMainPayload(main: MainFormDraft, options: BuildPayloadOptions = {}): Record<string, unknown> { export function buildMainPayload(main: MainFormDraft): Record<string, unknown> {
// companyName omis si vide -> 422 NotBlank au lieu d'un 400 de type (ERP-119). return {
// 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 finalizeRequired({
companyName: main.companyName, companyName: main.companyName,
categories: main.categoryIris, categories: main.categoryIris,
relationType: main.relationType,
distributor: main.relationType === 'distributeur' ? main.distributorIri : null, distributor: main.relationType === 'distributeur' ? main.distributorIri : null,
broker: main.relationType === 'courtier' ? main.brokerIri : null, broker: main.relationType === 'courtier' ? main.brokerIri : null,
triageService: main.triageService, triageService: main.triageService,
}, MAIN_REQUIRED_NON_NULLABLE_KEYS, options) }
} }
/** Payload de l'onglet Information — groupe client:write:information UNIQUEMENT. */ /** Payload de l'onglet Information — groupe client:write:information UNIQUEMENT. */
@@ -235,15 +199,11 @@ export function buildContactPayload(contact: ContactFormDraft): Record<string, u
export function buildAddressPayload( export function buildAddressPayload(
address: AddressFormDraft, address: AddressFormDraft,
isBillingEmailRequired: boolean, isBillingEmailRequired: boolean,
options: BuildPayloadOptions = {},
): Record<string, unknown> { ): Record<string, unknown> {
// postalCode / city / street : omis a la creation, `''` en edition -> 422 NotBlank (ERP-119). return {
return finalizeRequired({
isProspect: address.isProspect, isProspect: address.isProspect,
isDelivery: address.isDelivery, isDelivery: address.isDelivery,
isBilling: address.isBilling, isBilling: address.isBilling,
isBroker: address.isBroker,
isDistributor: address.isDistributor,
country: address.country, country: address.country,
postalCode: address.postalCode || null, postalCode: address.postalCode || null,
city: address.city || null, city: address.city || null,
@@ -253,19 +213,16 @@ export function buildAddressPayload(
sites: address.siteIris, sites: address.siteIris,
contacts: address.contactIris, contacts: address.contactIris,
billingEmail: isBillingEmailRequired ? (address.billingEmail || null) : null, billingEmail: isBillingEmailRequired ? (address.billingEmail || null) : null,
billingEmailSecondary: isBillingEmailRequired && address.hasSecondaryBillingEmail ? (address.billingEmailSecondary || null) : null, }
}, ADDRESS_REQUIRED_NON_NULLABLE_KEYS, options)
} }
/** Payload d'un RIB (sous-ressource client_rib). */ /** Payload d'un RIB (sous-ressource client_rib). */
export function buildRibPayload(rib: RibFormDraft, options: BuildPayloadOptions = {}): Record<string, unknown> { export function buildRibPayload(rib: RibFormDraft): Record<string, unknown> {
// label / bic / iban : omis a la creation, `''` en edition -> 422 NotBlank au lieu return {
// d'un 400 de type (ou d'un faux 200 PATCH qui garderait l'ancienne valeur). ERP-119.
return finalizeRequired({
label: rib.label, label: rib.label,
bic: rib.bic, bic: rib.bic,
iban: rib.iban, iban: rib.iban,
}, RIB_REQUIRED_NON_NULLABLE_KEYS, options) }
} }
// ── Gating par permission ──────────────────────────────────────────────────── // ── Gating par permission ────────────────────────────────────────────────────
@@ -9,9 +9,12 @@
* Le back reste la source de verite (les RG sont re-validees serveur) ; ces * Le back reste la source de verite (les RG sont re-validees serveur) ; ces
* regles ne servent qu'au feedback UI immediat (gating de boutons, visibilite). * regles ne servent qu'au feedback UI immediat (gating de boutons, visibilite).
* *
* NOTE : l'onglet Information est facultatif pour tous les roles. L'ancienne * NOTE RG-1.04 (Information obligatoire pour la Commerciale) : volontairement
* RG-1.04 (« Information obligatoire pour la Commerciale ») a ete retiree cote * NON miroite cote front pour l'instant. Le payload /api/me ne porte pas le code
* back rien a miroiter ici. * de role (roles = IRIs opaques) et Bureau partage les memes permissions que
* Commerciale : aucun signal fiable pour distinguer le role cote front. Le back
* (ClientProcessor, via BusinessRoleAware) applique la regle de maniere fiable ;
* a rebrancher ici des qu'un code de role sera expose dans /api/me.
*/ */
/** /**
@@ -50,38 +53,6 @@ export function buildClientFormTabKeys(
return keys 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 /
* courtier) ni prestation de triage. Sert a conditionner l'affichage des champs
* « Relation » et « Prestation de triage » du formulaire principal.
*/
export const DISTRIBUTOR_BROKER_CATEGORY_CODES = ['DISTRIBUTEUR', 'COURTIER'] as const
/**
* Vrai des qu'au moins une categorie « ordinaire » (autre que Distributeur /
* Courtier) est selectionnee. Les champs « Relation » (depend du distributeur /
* courtier) et « Prestation de triage » du formulaire principal sont masques par
* defaut et reveles uniquement dans ce cas.
*/
export function showsRelationAndTriageFields(selectedCategoryCodes: string[]): boolean {
return selectedCategoryCodes.some(
code => !(DISTRIBUTOR_BROKER_CATEGORY_CODES as readonly string[]).includes(code),
)
}
/** Sous-ensemble d'un contact necessaire aux regles de nommage (RG-1.05/1.14). */ /** Sous-ensemble d'un contact necessaire aux regles de nommage (RG-1.05/1.14). */
export interface ContactDraft { export interface ContactDraft {
firstName: string | null firstName: string | null
@@ -93,10 +64,6 @@ export interface AddressFlagsDraft {
isProspect: boolean isProspect: boolean
isDelivery: boolean isDelivery: boolean
isBilling: 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. */ /** Vrai si une chaine porte au moins un caractere non-espace. */
@@ -171,16 +138,6 @@ export function isRibBlank(rib: RibFillableDraft): boolean {
return isBlankRow([rib.label, rib.bic, rib.iban]) return isBlankRow([rib.label, rib.bic, rib.iban])
} }
/**
* RG-1.13 : un RIB est complet quand ses trois champs sont remplis (label, BIC,
* IBAN). Predicat par-bloc partage entre le gating du bouton « + RIB » (le
* dernier bloc doit etre complet avant d'en ajouter un autre) 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)
}
/** /**
* RG-1.06/07/08 : une adresse de prospection est exclusive d'une adresse de * RG-1.06/07/08 : une adresse de prospection est exclusive d'une adresse de
* livraison/facturation. Prospect n'est selectionnable que si ni Livraison ni * livraison/facturation. Prospect n'est selectionnable que si ni Livraison ni
@@ -236,30 +193,22 @@ export function isBillingEmailRequired(flags: AddressFlagsDraft): boolean {
* drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). Les seules * drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). Les seules
* combinaisons proposees respectent l'exclusivite Prospect (RG-1.06/07/08). * combinaisons proposees respectent l'exclusivite Prospect (RG-1.06/07/08).
*/ */
export type AddressType = 'prospect' | 'delivery' | 'billing' | 'delivery_billing' | 'broker' | 'distributor' export type AddressType = 'prospect' | 'delivery' | 'billing' | 'delivery_billing'
/** /**
* Mappe le type d'adresse choisi vers les cinq drapeaux back. * Mappe le type d'adresse choisi vers les trois drapeaux back.
* « Adresse + Facturation » = livraison ET facturation sur la meme adresse. * « 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 { export function addressFlagsFromType(type: AddressType): AddressFlagsDraft {
const none: AddressFlagsDraft = {
isProspect: false, isDelivery: false, isBilling: false, isBroker: false, isDistributor: false,
}
switch (type) { switch (type) {
case 'prospect': case 'prospect':
return { ...none, isProspect: true } return { isProspect: true, isDelivery: false, isBilling: false }
case 'delivery': case 'delivery':
return { ...none, isDelivery: true } return { isProspect: false, isDelivery: true, isBilling: false }
case 'billing': case 'billing':
return { ...none, isBilling: true } return { isProspect: false, isDelivery: false, isBilling: true }
case 'delivery_billing': case 'delivery_billing':
return { ...none, isDelivery: true, isBilling: true } return { isProspect: false, isDelivery: true, isBilling: true }
case 'broker':
return { ...none, isBroker: true }
case 'distributor':
return { ...none, isDistributor: true }
} }
} }
@@ -270,8 +219,6 @@ export function addressFlagsFromType(type: AddressType): AddressFlagsDraft {
*/ */
export function addressTypeFromFlags(flags: AddressFlagsDraft): AddressType | null { export function addressTypeFromFlags(flags: AddressFlagsDraft): AddressType | null {
if (flags.isProspect) return 'prospect' 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 && flags.isBilling) return 'delivery_billing'
if (flags.isDelivery) return 'delivery' if (flags.isDelivery) return 'delivery'
if (flags.isBilling) return 'billing' if (flags.isBilling) return 'billing'
@@ -279,31 +226,6 @@ export function addressTypeFromFlags(flags: AddressFlagsDraft): AddressType | nu
return null return null
} }
/**
* Sous-ensemble d'une adresse necessaire a sa validite par-bloc : drapeaux
* d'usage (pour le type + l'email de facturation conditionnel), sites et
* categories rattaches, email de facturation.
*/
export interface AddressValidityDraft extends AddressFlagsDraft {
categoryIris: string[]
siteIris: string[]
billingEmail: string | null
}
/**
* Validite par-bloc d'une adresse : type renseigne (RG-1.06/07/08), >= 1 site
* (RG-1.10), >= 1 categorie, et email de facturation rempli si l'adresse est de
* facturation (RG-1.11). 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).
*/
export function isAddressValid(address: AddressValidityDraft): boolean {
return addressTypeFromFlags(address) !== null
&& address.siteIris.length >= 1
&& address.categoryIris.length >= 1
&& (!isBillingEmailRequired(address) || isFilled(address.billingEmail))
}
/** Code stable du type de reglement « virement » (cf. PaymentType.code, RG-1.12). */ /** Code stable du type de reglement « virement » (cf. PaymentType.code, RG-1.12). */
const PAYMENT_TYPE_TRANSFER = 'VIREMENT' const PAYMENT_TYPE_TRANSFER = 'VIREMENT'
@@ -326,36 +248,6 @@ export function isRibRequiredForPaymentType(code: string | null | undefined): bo
return code === PAYMENT_TYPE_LCR return code === PAYMENT_TYPE_LCR
} }
/** Champs saisissables de l'onglet Information (tous facultatifs). */
export interface InformationFieldsDraft {
description: string | null
competitors: string | null
foundedAt: string | null
employeesCount: string | null
revenueAmount: string | null
profitAmount: string | null
directorName: string | null
}
/**
* Vrai si au moins un champ de l'onglet Information est rempli. L'onglet est
* facultatif (aucun champ obligatoire), mais on n'autorise pas une validation
* « a vide » a la creation : sans donnee, rien a enregistrer, l'utilisateur
* passe directement a l'onglet Contact. (En edition, vider tous les champs reste
* une action legitime : ce gate n'y est pas applique.)
*/
export function hasAtLeastOneInformationField(information: InformationFieldsDraft): boolean {
return !isBlankRow([
information.description,
information.competitors,
information.foundedAt,
information.employeesCount,
information.revenueAmount,
information.profitAmount,
information.directorName,
])
}
/** Sous-ensemble du brouillon comptable portant les six champs obligatoires. */ /** Sous-ensemble du brouillon comptable portant les six champs obligatoires. */
export interface AccountingRequiredDraft { export interface AccountingRequiredDraft {
siren: string | null siren: string | null
@@ -384,63 +276,3 @@ export function hasAllRequiredAccountingFields(accounting: AccountingRequiredDra
&& filled(accounting.paymentDelayIri) && filled(accounting.paymentDelayIri)
&& filled(accounting.paymentTypeIri) && 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
}
/**
* Variante PATCH (edition d'une ligne EXISTANTE) : remplace les cles requises
* laissees vides par une chaine vide `''` au lieu de les OMETTRE.
*
* Pourquoi pas `omitEmptyRequired` en edition : un PATCH a semantique merge une
* cle absente laisse la valeur serveur INCHANGEE. Vider un champ requis puis valider
* renverrait alors un 200 trompeur (l'ancienne valeur est conservee). En envoyant
* `''` (chaine valide), on evite le 400 de type (« must be string, NULL given ») et
* le Validator `NotBlank(trim)` rejette la valeur -> 422 avec propertyPath, mappee
* inline sous le champ. Mute et retourne le payload.
*/
export function blankEmptyRequired<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 === '') {
(payload as Record<string, unknown>)[key] = ''
}
}
return payload
}
@@ -1,316 +0,0 @@
/**
* Helpers purs de l'ecran « Consultation fournisseur » (M2 Commercial, lecture
* seule). Miroir de `clientConsultation.ts` (M1), adapte aux differences M2.
*
* Mappent le payload `GET /api/suppliers/{id}` (relations embarquees, cf. groupe
* `supplier:item:read` + `supplier:read:accounting`) vers les brouillons « plats »
* partages avec les blocs reutilisables `SupplierContactBlock` / `SupplierAddressBlock`
* et l'onglet Comptabilite. Ne touchent ni a l'API ni a l'etat reactif : testables
* unitairement (cf. supplierConsultation.spec.ts).
*
* Rappels de contrat back (verifies sur le JSON reel fige ERP-92, spec-back § 4.0.bis) :
* - les relations ManyToOne (tvaMode/paymentDelay/paymentType/bank) sont
* serialisees en OBJETS embarques (`{id, code, label}`), pas en IRI nu ;
* - les champs nuls sont OMIS du JSON (skip_null_values) toujours lire avec `?? null` ;
* - les champs comptables et `ribs` sont TOTALEMENT ABSENTS (cle omise, pas `null`)
* sans permission accounting.view (gate serveur via SupplierReadGroupContextBuilder).
*
* Differences M2 vs M1 :
* - Adresse via enum `addressType` (PROSPECT/DEPART/RENDU, RG-2.09) pas de
* drapeaux isProspect/isDelivery/isBilling.
* - Adresse : champs specifiques fournisseur `bennes` (nombre) et `triageProvider`.
* Pas d'email de facturation.
* - Information : champ specifique fournisseur `volumeForecast`.
* - Pas de relation Distributeur/Courtier ni de triage sur le bloc principal.
*/
import { formatPhoneFR } from '~/shared/utils/phone'
import {
emptyAddress,
type SupplierAddressFormDraft,
type SupplierAddressType,
type SupplierContactFormDraft,
type SupplierRibFormDraft,
} from '~/modules/commercial/types/supplierForm'
/** Reference Hydra embarquee minimale (@id toujours present). */
export interface HydraRef {
'@id': string
[key: string]: unknown
}
/** Une relation peut etre embarquee (objet), un IRI nu (chaine) ou absente. */
export type Relation = HydraRef | string | null | undefined
/** Site embarque dans une adresse (groupe site:read). */
export interface SiteRead extends HydraRef {
name?: string
color?: string
}
/** Categorie embarquee (groupe category:read). */
export interface CategoryRead extends HydraRef {
code?: string
name?: string
}
/** Contact embarque (groupe supplier_contact:read). */
export interface ContactRead extends HydraRef {
id: number
firstName?: string | null
lastName?: string | null
jobTitle?: string | null
phonePrimary?: string | null
phoneSecondary?: string | null
email?: string | null
}
/** Adresse embarquee (groupe supplier_address:read). */
export interface AddressRead extends HydraRef {
id: number
addressType?: SupplierAddressType | null
country?: string | null
postalCode?: string | null
city?: string | null
street?: string | null
streetComplement?: string | null
bennes?: number | null
triageProvider?: boolean
sites?: SiteRead[]
categories?: CategoryRead[]
// L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu.
contacts?: Array<HydraRef | string>
}
/** RIB embarque (groupe supplier:read:accounting, present ssi accounting.view). */
export interface RibRead extends HydraRef {
id: number
label?: string | null
bic?: string | null
iban?: string | null
}
/**
* Detail d'un fournisseur tel que renvoye par `GET /api/suppliers/{id}`. Tous les
* champs sont optionnels : skip_null_values cote serveur et gating accounting
* peuvent omettre n'importe quelle cle.
*/
export interface SupplierDetail extends HydraRef {
id: number
companyName?: string | null
isArchived?: boolean
categories?: CategoryRead[]
contacts?: ContactRead[]
addresses?: AddressRead[]
ribs?: RibRead[]
// Onglet Information
description?: string | null
competitors?: string | null
foundedAt?: string | null
employeesCount?: number | null
revenueAmount?: string | null
profitAmount?: string | null
directorName?: string | null
/** Volume previsionnel (entier, specifique fournisseur). */
volumeForecast?: number | null
// Onglet Comptabilite (present ssi accounting.view)
siren?: string | null
accountNumber?: string | null
nTva?: string | null
tvaMode?: Relation
paymentDelay?: Relation
paymentType?: Relation
bank?: Relation
}
/** Etat « plat » de l'onglet Comptabilite (miroir lecture du formulaire). */
export interface AccountingDraft {
siren: string | null
accountNumber: string | null
nTva: string | null
tvaModeIri: string | null
paymentDelayIri: string | null
paymentTypeIri: string | null
bankIri: string | null
}
/** Option de select ({ value, label }) construite a partir de l'embed. */
export interface SelectOption {
value: string
label: string
}
/** Option de categorie enrichie de son code (compatible CategoryOption des blocs). */
export interface CategorySelectOption extends SelectOption {
code: string
}
/**
* Vue d'une adresse pour la consultation : le brouillon + ses options de select
* construites a partir de l'embed (sites/categories propres a CETTE adresse).
*/
export interface AddressView {
draft: SupplierAddressFormDraft
siteOptions: SelectOption[]
categoryOptions: CategorySelectOption[]
}
/** Extrait l'IRI d'une relation (objet embarque, IRI nu, ou null si absente). */
export function iriOf(relation: Relation): string | null {
if (relation === null || relation === undefined) {
return null
}
if (typeof relation === 'string') {
return relation
}
return relation['@id'] ?? null
}
/** Mappe un contact embarque vers un brouillon (telephones formates XX XX XX XX XX). */
export function mapContactToDraft(contact: ContactRead): SupplierContactFormDraft {
const phoneSecondary = contact.phoneSecondary ?? null
return {
id: contact.id,
iri: contact['@id'] ?? null,
firstName: contact.firstName ?? null,
lastName: contact.lastName ?? null,
jobTitle: contact.jobTitle ?? null,
phonePrimary: contact.phonePrimary ? formatPhoneFR(contact.phonePrimary) : null,
phoneSecondary: phoneSecondary ? formatPhoneFR(phoneSecondary) : null,
email: contact.email ?? null,
hasSecondaryPhone: phoneSecondary !== null && phoneSecondary !== '',
}
}
/**
* Mappe une adresse embarquee vers un brouillon (IRI extraits des sous-collections).
* `bennes` (entier) est converti en chaine pour MalioInputNumber (defaut « 0 »).
*/
export function mapAddressToDraft(address: AddressRead): SupplierAddressFormDraft {
return {
id: address.id,
addressType: address.addressType ?? null,
country: address.country ?? 'France',
postalCode: address.postalCode ?? null,
city: address.city ?? null,
street: address.street ?? null,
streetComplement: address.streetComplement ?? null,
categoryIris: (address.categories ?? []).map(c => c['@id']),
siteIris: (address.sites ?? []).map(s => s['@id']),
contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])),
bennes: address.bennes != null ? String(address.bennes) : '0',
triageProvider: address.triageProvider ?? false,
}
}
/** Mappe un RIB embarque vers un brouillon. */
export function mapRibToDraft(rib: RibRead): SupplierRibFormDraft {
return {
id: rib.id,
label: rib.label ?? null,
bic: rib.bic ?? null,
iban: rib.iban ?? null,
}
}
/** Mappe les champs comptables du fournisseur (scalaires + IRI des referentiels). */
export function mapAccountingDraft(supplier: SupplierDetail): AccountingDraft {
return {
siren: supplier.siren ?? null,
accountNumber: supplier.accountNumber ?? null,
nTva: supplier.nTva ?? null,
tvaModeIri: iriOf(supplier.tvaMode),
paymentDelayIri: iriOf(supplier.paymentDelay),
paymentTypeIri: iriOf(supplier.paymentType),
bankIri: iriOf(supplier.bank),
}
}
/**
* Options de categories (value=IRI, label=nom, code) construites depuis l'embed.
* Source role-independante : evite de dependre de `GET /categories` (403 pour les
* roles metier non-admin), qui laisserait les libelles vides.
*/
export function categoryOptionsOf(categories: CategoryRead[] | undefined): CategorySelectOption[] {
return (categories ?? []).map(c => ({
value: c['@id'],
label: c.name ?? c.code ?? c['@id'],
code: c.code ?? '',
}))
}
/** Options de sites (value=IRI, label=nom) construites depuis l'embed d'une adresse. */
export function siteOptionsOf(sites: SiteRead[] | undefined): SelectOption[] {
return (sites ?? []).map(s => ({ value: s['@id'], label: s.name ?? s['@id'] }))
}
/** Options de contacts (value=IRI, label=nom complet ou email) depuis l'embed fournisseur. */
export function contactOptionsOf(contacts: ContactRead[] | undefined): SelectOption[] {
return (contacts ?? []).map(c => ({
value: c['@id'],
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? c['@id']),
}))
}
/**
* Liste a une seule option (ou vide) construite depuis un referentiel embarque
* (TvaMode / PaymentDelay / PaymentType / Bank) pour alimenter un MalioSelect en
* lecture seule. Le libelle vient de l'embed (`label` ou `name`), jamais d'un
* `GET` de referentiel l'affichage reste correct quel que soit le role.
*/
export function referentialOptionOf(relation: Relation): SelectOption[] {
if (!relation || typeof relation === 'string') {
return []
}
const label = (relation.label as string | undefined)
?? (relation.name as string | undefined)
?? relation['@id']
return [{ value: relation['@id'], label }]
}
/**
* Code metier d'un referentiel embarque (ex: PaymentType.code = 'LCR' / 'VIREMENT'),
* ou null si la relation est absente / serialisee en IRI nu. Type-safe : la branche
* chaine (IRI nu) et l'absence sont court-circuitees avant l'acces au code. Sert a
* conditionner l'affichage selon le type de reglement courant (ERP-121 : RIB masques
* hors-LCR en consultation).
*/
export function paymentTypeCodeOf(relation: Relation): string | null {
if (!relation || typeof relation === 'string') {
return null
}
return (relation.code as string | undefined) ?? null
}
/** Vue d'une adresse (brouillon + options de select propres a l'adresse). */
export function mapAddressView(address: AddressRead): AddressView {
return {
draft: mapAddressToDraft(address),
siteOptions: siteOptionsOf(address.sites),
categoryOptions: categoryOptionsOf(address.categories),
}
}
/**
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet
* `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta
* doit pouvoir ouvrir l'edition pour son onglet Comptabilite). Le readonly fin
* par onglet est gere sur l'ecran d'edition (96).
*/
export function canEditSupplier(canAny: (codes: string[]) => boolean): boolean {
return canAny(['commercial.suppliers.manage', 'commercial.suppliers.accounting.manage'])
}
/** Bouton « Archiver » : permission archive ET fournisseur encore actif. */
export function showArchiveAction(can: (code: string) => boolean, isArchived: boolean): boolean {
return can('commercial.suppliers.archive') && !isArchived
}
/** Bouton « Restaurer » : permission archive ET fournisseur deja archive. */
export function showRestoreAction(can: (code: string) => boolean, isArchived: boolean): boolean {
return can('commercial.suppliers.archive') && isArchived
}
/** Brouillon d'adresse vierge (reexport pour la page : 1 bloc vide si aucune adresse). */
export { emptyAddress }
@@ -1,250 +0,0 @@
/**
* Helpers purs des ecrans « Ajouter » / « Modifier » un fournisseur (M2
* Commercial) miroir de `clientEdit.ts` (M1). Deux responsabilites, toutes deux
* testables unitairement (cf. supplierEdit.spec.ts) :
* 1. Pre-remplissage : mapper le payload `GET /api/suppliers/{id}` (embed +
* scalaires) vers les brouillons « plats » edites par la page de modification.
* 2. Scoping STRICT des payloads PATCH (mode strict RG-2.16 / ERP-74) : 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,
blankEmptyRequired,
MAIN_REQUIRED_NON_NULLABLE_KEYS,
omitEmptyRequired,
RIB_REQUIRED_NON_NULLABLE_KEYS,
} from '~/modules/commercial/utils/supplierFormRules'
import { iriOf, type SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
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
}
/** Permissions de l'utilisateur courant pertinentes pour l'edition d'un fournisseur. */
export interface SupplierEditAbilities {
/** `commercial.suppliers.manage` : bloc principal + onglets metier. */
canManage: boolean
/** `commercial.suppliers.accounting.view` : visibilite de l'onglet Comptabilite. */
canAccountingView: boolean
/** `commercial.suppliers.accounting.manage` : edition de l'onglet Comptabilite. */
canAccountingManage: boolean
}
/** Editabilite resolue par zone d'onglet (deduite des permissions). */
export interface TabEditability {
/** Bloc principal + onglets Information / Contacts / Adresses editables. */
businessEditable: boolean
/** Onglet Comptabilite present (affiche). */
accountingVisible: boolean
/** Onglet Comptabilite editable. */
accountingEditable: boolean
}
// ── Pre-remplissage (GET detail -> brouillons) ──────────────────────────────
/** Mappe le detail fournisseur vers le brouillon du bloc principal. */
export function mapMainDraft(supplier: SupplierDetail): MainFormDraft {
return {
companyName: supplier.companyName ?? null,
categoryIris: (supplier.categories ?? []).map(c => c['@id']),
}
}
/** Mappe le detail fournisseur vers le brouillon de l'onglet Information. */
export function mapInformationDraft(supplier: SupplierDetail): InformationFormDraft {
return {
description: supplier.description ?? null,
competitors: supplier.competitors ?? null,
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
foundedAt: supplier.foundedAt ? supplier.foundedAt.slice(0, 10) : null,
employeesCount: supplier.employeesCount != null ? String(supplier.employeesCount) : null,
revenueAmount: supplier.revenueAmount ?? null,
profitAmount: supplier.profitAmount ?? null,
directorName: supplier.directorName ?? null,
// Volume previsionnel (entier, specifique fournisseur) en chaine pour la saisie.
volumeForecast: supplier.volumeForecast != null ? String(supplier.volumeForecast) : null,
}
}
/** Mappe les champs comptables du detail vers le brouillon de l'onglet (scalaires + IRI). */
export function mapAccountingFormDraft(supplier: SupplierDetail): AccountingFormDraft {
return {
siren: supplier.siren ?? null,
accountNumber: supplier.accountNumber ?? null,
nTva: supplier.nTva ?? null,
tvaModeIri: iriOf(supplier.tvaMode),
paymentDelayIri: iriOf(supplier.paymentDelay),
paymentTypeIri: iriOf(supplier.paymentType),
bankIri: iriOf(supplier.bank),
}
}
/**
* Resout l'editabilite par zone a partir des permissions (option 1 ERP-74,
* miroir UI du re-gating champ-par-champ du SupplierProcessor) :
* - bloc principal + Information/Contacts/Adresses : editables ssi `manage` ;
* - Comptabilite : visible ssi `accounting.view`, editable ssi `accounting.manage`.
*
* Produit le comportement attendu :
* - Admin : tout editable.
* - Bureau / Commerciale (manage, sans accounting) : metier editable, Compta masquee.
* - Compta (accounting seul, sans manage) : metier readonly, Compta editable.
*/
export function resolveTabEditability(abilities: SupplierEditAbilities): TabEditability {
return {
businessEditable: abilities.canManage,
accountingVisible: abilities.canAccountingView,
accountingEditable: abilities.canAccountingManage,
}
}
// ── Scoping strict des payloads PATCH/POST ──────────────────────────────────
/**
* Options de construction d'un payload d'ecriture.
* - `forUpdate: false` (defaut, CREATION/POST) : les champs requis vides sont OMIS
* -> 422 NotBlank a l'insert (le back ne reçoit pas la cle).
* - `forUpdate: true` (EDITION/PATCH d'une ligne existante) : les champs requis
* vides sont envoyes en `''` -> 422 NotBlank (sinon une cle omise laisse la valeur
* serveur inchangee, faux 200 cf. blankEmptyRequired).
*/
export interface BuildPayloadOptions {
forUpdate?: boolean
}
/** Selectionne le finaliseur des champs requis selon création (omit) vs édition (blank). */
function finalizeRequired<T extends Record<string, unknown>>(
payload: T,
requiredKeys: readonly string[],
options: BuildPayloadOptions,
): T {
return options.forUpdate
? blankEmptyRequired(payload, requiredKeys)
: omitEmptyRequired(payload, requiredKeys)
}
/**
* Payload du bloc principal groupe supplier:write:main UNIQUEMENT.
* companyName vide -> 422 NotBlank (omis a la creation, `''` en edition ERP-119).
*/
export function buildMainPayload(main: MainFormDraft, options: BuildPayloadOptions = {}): Record<string, unknown> {
return finalizeRequired({
companyName: main.companyName,
categories: main.categoryIris,
}, MAIN_REQUIRED_NON_NULLABLE_KEYS, options)
}
/** 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, options: BuildPayloadOptions = {}): Record<string, unknown> {
return finalizeRequired({
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, options)
}
/** Payload d'un RIB (sous-ressource supplier_rib). */
export function buildRibPayload(rib: SupplierRibFormDraft, options: BuildPayloadOptions = {}): Record<string, unknown> {
return finalizeRequired({
label: rib.label,
bic: rib.bic,
iban: rib.iban,
}, RIB_REQUIRED_NON_NULLABLE_KEYS, options)
}
@@ -1,244 +0,0 @@
/**
* 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
}
/**
* Variante PATCH (edition d'une ligne EXISTANTE) : remplace les cles requises
* laissees vides par une chaine vide `''` au lieu de les OMETTRE.
*
* Pourquoi pas `omitEmptyRequired` en edition : un PATCH a semantique merge une
* cle absente laisse la valeur serveur INCHANGEE. Vider un champ requis puis valider
* renverrait alors un 200 trompeur (l'ancienne valeur est conservee). En envoyant
* `''`, la propriete `?string` est bien deserialisee (pas de 400 de type, contrairement
* a `null` sur une colonne non-nullable), puis le Validator `NotBlank(trim)` la rejette
* -> 422 avec propertyPath, mappee inline sous le champ. Mute et retourne le payload.
*/
export function blankEmptyRequired<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 === '') {
(payload as Record<string, unknown>)[key] = ''
}
}
return payload
}
@@ -9,6 +9,7 @@
icon-name="mdi:tune" icon-name="mdi:tune"
icon-position="left" icon-position="left"
icon-size="24" icon-size="24"
button-class="w-[184px] justify-start gap-4 text-black"
@click="openFilters" @click="openFilters"
/> />
</template> </template>
@@ -29,7 +30,7 @@
> >
<template #cell-action="{ item }"> <template #cell-action="{ item }">
<span <span
class="inline-flex items-center rounded-full px-2 py-0.5 font-medium" class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
:class="actionBadgeClass(item.action as string)" :class="actionBadgeClass(item.action as string)"
> >
{{ t(`audit.action.${item.action}`) }} {{ t(`audit.action.${item.action}`) }}
@@ -37,14 +38,15 @@
</template> </template>
<template #cell-entityType="{ item }"> <template #cell-entityType="{ item }">
<span <span
class="text-xs"
:title="item.entityType as string" :title="item.entityType as string"
>{{ formatEntityType(item.entityType as string) }}</span> >{{ formatEntityType(item.entityType as string) }}</span>
</template> </template>
<template #cell-entityId="{ item }"> <template #cell-entityId="{ item }">
<span>{{ item.entityId }}</span> <span class="font-mono text-xs">{{ item.entityId }}</span>
</template> </template>
<template #cell-summary="{ item }"> <template #cell-summary="{ item }">
<span class="text-gray-600">{{ item.summary }}</span> <span class="text-xs text-gray-600">{{ item.summary }}</span>
</template> </template>
</MalioDataTable> </MalioDataTable>
+2 -3
View File
@@ -5,7 +5,6 @@
<template #actions> <template #actions>
<MalioButton <MalioButton
v-if="can('core.roles.manage')" v-if="can('core.roles.manage')"
variant="secondary"
:label="t('admin.roles.newRole')" :label="t('admin.roles.newRole')"
icon-name="mdi:add-bold" icon-name="mdi:add-bold"
icon-position="left" icon-position="left"
@@ -29,7 +28,7 @@
@update:per-page="setItemsPerPage" @update:per-page="setItemsPerPage"
> >
<template #cell-code="{ item }"> <template #cell-code="{ item }">
<span>{{ item.code }}</span> <span class="font-mono text-xs">{{ item.code }}</span>
</template> </template>
<template #cell-permissions="{ item }"> <template #cell-permissions="{ item }">
{{ item.permissions }} {{ item.permissions }}
@@ -37,7 +36,7 @@
<template #cell-system="{ item }"> <template #cell-system="{ item }">
<span <span
v-if="item.isSystem" v-if="item.isSystem"
class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 font-medium text-blue-800" class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800"
> >
{{ t('admin.roles.table.system') }} {{ t('admin.roles.table.system') }}
</span> </span>
+1 -1
View File
@@ -19,7 +19,7 @@
<template #cell-admin="{ item }"> <template #cell-admin="{ item }">
<span <span
v-if="item.admin" v-if="item.admin"
class="inline-flex items-center rounded-full bg-purple-100 px-2.5 py-0.5 font-medium text-purple-800" class="inline-flex items-center rounded-full bg-purple-100 px-2.5 py-0.5 text-xs font-medium text-purple-800"
> >
{{ t('admin.users.table.admin') }} {{ t('admin.users.table.admin') }}
</span> </span>
@@ -62,7 +62,7 @@
<MalioInputText <MalioInputText
v-model="form.color" v-model="form.color"
placeholder="#RRGGBB" placeholder="#RRGGBB"
input-class="w-full" input-class="w-full font-mono"
required required
/> />
<!-- pb-4 sur le wrapper : simule le slot message du <!-- pb-4 sur le wrapper : simule le slot message du
+2 -3
View File
@@ -5,7 +5,6 @@
<template #actions> <template #actions>
<MalioButton <MalioButton
v-if="can('sites.manage')" v-if="can('sites.manage')"
variant="secondary"
:label="t('admin.sites.newSite')" :label="t('admin.sites.newSite')"
icon-name="mdi:add-bold" icon-name="mdi:add-bold"
icon-position="left" icon-position="left"
@@ -34,11 +33,11 @@
:style="{ backgroundColor: item.color }" :style="{ backgroundColor: item.color }"
class="inline-block size-5 rounded-full border border-neutral-200" class="inline-block size-5 rounded-full border border-neutral-200"
/> />
<span>{{ item.color }}</span> <span class="font-mono text-xs">{{ item.color }}</span>
</span> </span>
</template> </template>
<template #cell-fullAddress="{ item }"> <template #cell-fullAddress="{ item }">
<span class="line-clamp-2 text-neutral-600"> <span class="line-clamp-2 text-xs text-neutral-600">
{{ item.fullAddress }} {{ item.fullAddress }}
</span> </span>
</template> </template>
+14 -14
View File
@@ -7,7 +7,7 @@
"name": "starseed-frontend", "name": "starseed-frontend",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@malio/layer-ui": "^1.7.8", "@malio/layer-ui": "^1.7.4",
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3", "@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
@@ -583,20 +583,20 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.11.0", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==", "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@emnapi/wasi-threads": "1.2.2", "@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.11.0", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -604,9 +604,9 @@
} }
}, },
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.2", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -1866,9 +1866,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@malio/layer-ui": { "node_modules/@malio/layer-ui": {
"version": "1.7.8", "version": "1.7.4",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.8/layer-ui-1.7.8.tgz", "resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.4/layer-ui-1.7.4.tgz",
"integrity": "sha512-gUMAZzBsPCfQUF3OQSjN/OFzjONvQZYfwqH0u5VUbxaqwBdX1hUGtjD4ym6RvZkyNsKulrxkncFZYTWCS+IdGA==", "integrity": "sha512-JNXwBelj5UQ35Qv5VmnassXKt8niX9jDXjM1vUSukJQiyeUXRxAiZr16QumVgBN9P9YGDyjXVKrwCHltTXvPtQ==",
"dependencies": { "dependencies": {
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
+1 -1
View File
@@ -17,7 +17,7 @@
"test:e2e:ui": "playwright test --ui" "test:e2e:ui": "playwright test --ui"
}, },
"dependencies": { "dependencies": {
"@malio/layer-ui": "^1.7.8", "@malio/layer-ui": "^1.7.4",
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3", "@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
@@ -23,7 +23,7 @@
</thead> </thead>
<tbody> <tbody>
<tr v-for="(diff, field) in updateDiff" :key="field" class="border-t border-gray-200"> <tr v-for="(diff, field) in updateDiff" :key="field" class="border-t border-gray-200">
<td class="px-2 py-1">{{ field }}</td> <td class="px-2 py-1 font-mono">{{ field }}</td>
<td class="px-2 py-1 text-red-700">{{ formatValue(diff.old) }}</td> <td class="px-2 py-1 text-red-700">{{ formatValue(diff.old) }}</td>
<td class="px-2 py-1 text-green-700">{{ formatValue(diff.new) }}</td> <td class="px-2 py-1 text-green-700">{{ formatValue(diff.new) }}</td>
</tr> </tr>
@@ -31,7 +31,7 @@
{ added: [ids], removed: [ids] } affiche + et - sur { added: [ids], removed: [ids] } affiche + et - sur
la meme ligne pour garder une colonne field unique. --> la meme ligne pour garder une colonne field unique. -->
<tr v-for="(diff, field) in collectionDiff" :key="`col-${field}`" class="border-t border-gray-200"> <tr v-for="(diff, field) in collectionDiff" :key="`col-${field}`" class="border-t border-gray-200">
<td class="px-2 py-1">{{ field }}</td> <td class="px-2 py-1 font-mono">{{ field }}</td>
<td class="px-2 py-1 text-red-700"> <td class="px-2 py-1 text-red-700">
<span v-if="diff.removed.length"> {{ diff.removed.join(', ') }}</span> <span v-if="diff.removed.length"> {{ diff.removed.join(', ') }}</span>
<span v-else class="text-gray-400"></span> <span v-else class="text-gray-400"></span>
@@ -47,7 +47,7 @@
<div v-else class="space-y-1"> <div v-else class="space-y-1">
<div v-for="(value, key) in entry.changes" :key="key" class="flex gap-2"> <div v-for="(value, key) in entry.changes" :key="key" class="flex gap-2">
<span class="text-xs text-gray-600">{{ key }}:</span> <span class="font-mono text-xs text-gray-600">{{ key }}:</span>
<span class="text-xs">{{ formatValue(value) }}</span> <span class="text-xs">{{ formatValue(value) }}</span>
</div> </div>
</div> </div>
+3 -8
View File
@@ -1,13 +1,8 @@
<template> <template>
<!-- Entete de page standard : source unique du style des titres. <!-- Entete de page standard : source unique du style des titres.
Slot par defaut = texte du titre, slot #actions = boutons a droite. Slot par defaut = texte du titre, slot #actions = boutons a droite. -->
Sticky en haut du <main> scrollable : reste visible au scroll. Fond blanc <div class="mb-[44px] flex items-center justify-between gap-4">
+ pt-[38px]/pb-[30px] (au lieu de marges) pour que le contenu defilant soit <h1 class="text-[32px] font-semibold text-primary-500">
masque sous l'entete (espaces haut ET bas compris) et que l'entete soit
collee sous le SiteSelector sans trou. pt = marge haute (38px),
pb = espace titre -> contenu (30px). z-20 < drawers/modales. -->
<div class="sticky top-0 z-20 flex items-center justify-between gap-4 bg-white pt-[38px] pb-[30px]">
<h1 class="text-[30px] font-semibold text-primary-500">
<slot/> <slot/>
</h1> </h1>
<div v-if="$slots.actions" class="shrink-0"> <div v-if="$slots.actions" class="shrink-0">
@@ -1,33 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest'
import { readHistoryTab } from '../historyTab'
const KEYS = ['information', 'contact', 'address', 'accounting']
describe('readHistoryTab', () => {
afterEach(() => {
window.history.replaceState(null, '')
})
it('retourne la cle d\'onglet quand elle est presente et valide', () => {
window.history.replaceState({ tab: 'address' }, '')
expect(readHistoryTab(KEYS)).toBe('address')
})
it('retourne null quand l\'onglet n\'est pas dans les cles valides (ex: role sans Comptabilite)', () => {
window.history.replaceState({ tab: 'accounting' }, '')
expect(readHistoryTab(['information', 'contact', 'address'])).toBeNull()
})
it('retourne null sans onglet dans l\'etat d\'historique (navigation directe / refresh)', () => {
window.history.replaceState(null, '')
expect(readHistoryTab(KEYS)).toBeNull()
window.history.replaceState({ foo: 'bar' }, '')
expect(readHistoryTab(KEYS)).toBeNull()
})
it('retourne null quand la valeur n\'est pas une chaine', () => {
window.history.replaceState({ tab: 42 }, '')
expect(readHistoryTab(KEYS)).toBeNull()
})
})
-22
View File
@@ -1,22 +0,0 @@
/**
* Onglet actif transmis d'une page a l'autre via l'etat d'historique
* (`history.state`), SANS le mettre dans l'URL. Sert a preserver l'onglet courant
* au passage consultation <-> edition d'un client (dans les deux sens).
*
* On reste donc fidele a la regle « etat d'UI local, pas dans l'URL » : l'onglet
* voyage dans l'entree d'historique de la navigation, l'URL ne change pas.
*/
/**
* Lit la cle d'onglet posee par la page precedente (`history.state.tab`) si elle
* fait partie des onglets valides pour l'utilisateur. Retourne `null` sinon :
* navigation directe / deep link, rechargement de page, ou onglet inexistant
* pour ce role (ex: Comptabilite sans la permission).
*/
export function readHistoryTab(validKeys: string[]): string | null {
if (typeof window === 'undefined') {
return null
}
const tab = (window.history.state as Record<string, unknown> | null)?.tab
return typeof tab === 'string' && validKeys.includes(tab) ? tab : null
}
+2 -3
View File
@@ -207,8 +207,7 @@ migration-migrate:
# orphelins du mapping ORM. Les index partiels (LOWER + WHERE) ne sont pas # orphelins du mapping ORM. Les index partiels (LOWER + WHERE) ne sont pas
# exprimables via les attributs Doctrine ORM (fonctionnel + partiel), donc # exprimables via les attributs Doctrine ORM (fonctionnel + partiel), donc
# ils disparaissent apres schema:update. On les recree par dbal:run-sql : # ils disparaissent apres schema:update. On les recree par dbal:run-sql :
# - `uq_category_name_active` (M0 Catalog) : unicite GLOBALE du nom parmi # - `uq_category_name_type_active` (M0 Catalog) : tests RG-1.07.
# les actifs (M:N categorie<->type), tests RG-1.07.
# - `uq_category_code` (Catalog ERP-78) : unicite du code categorie parmi # - `uq_category_code` (Catalog ERP-78) : unicite du code categorie parmi
# les actifs (slug du nom), pilote RG-1.03/1.29. # les actifs (slug du nom), pilote RG-1.03/1.29.
# - `uq_client_company_name_active` (M1 Commercial) : unicite nom societe # - `uq_client_company_name_active` (M1 Commercial) : unicite nom societe
@@ -227,7 +226,7 @@ test-db-setup:
$(SYMFONY_CONSOLE) --env=test --no-interaction doctrine:fixtures:load $(SYMFONY_CONSOLE) --env=test --no-interaction doctrine:fixtures:load
$(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions $(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions
$(SYMFONY_CONSOLE) --env=test --no-interaction app:seed-rbac $(SYMFONY_CONSOLE) --env=test --no-interaction app:seed-rbac
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_active ON category (LOWER(name)) WHERE deleted_at IS NULL" $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL"
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_code ON category (code) WHERE deleted_at IS NULL" $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_code ON category (code) WHERE deleted_at IS NULL"
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_client_company_name_active ON client (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL" $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_client_company_name_active ON client (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_company_name_active ON supplier (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL" $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_company_name_active ON supplier (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
+7 -7
View File
@@ -256,13 +256,13 @@ final class Version20260601000000 extends AbstractMigration
$this->comment('client', 'distributor_id', 'FK auto-referente vers un client porteur de la categorie DISTRIBUTEUR — exclusive avec broker_id (RG-1.03, chk_client_distrib_or_broker). FK -> client.id, ON DELETE SET NULL.'); $this->comment('client', 'distributor_id', 'FK auto-referente vers un client porteur de la categorie DISTRIBUTEUR — exclusive avec broker_id (RG-1.03, chk_client_distrib_or_broker). FK -> client.id, ON DELETE SET NULL.');
$this->comment('client', 'broker_id', 'FK auto-referente vers un client porteur de la categorie COURTIER — exclusive avec distributor_id (RG-1.03). FK -> client.id, ON DELETE SET NULL.'); $this->comment('client', 'broker_id', 'FK auto-referente vers un client porteur de la categorie COURTIER — exclusive avec distributor_id (RG-1.03). FK -> client.id, ON DELETE SET NULL.');
$this->comment('client', 'triage_service', 'Drapeau service triage active pour le client. Faux par defaut.'); $this->comment('client', 'triage_service', 'Drapeau service triage active pour le client. Faux par defaut.');
$this->comment('client', 'description', 'Onglet Information : description libre. Facultatif.'); $this->comment('client', 'description', 'Onglet Information : description libre. Obligatoire pour le role Commerciale (RG-1.04), optionnel sinon.');
$this->comment('client', 'competitors', 'Onglet Information : concurrents identifies (texte libre ≤ 255). Facultatif.'); $this->comment('client', 'competitors', 'Onglet Information : concurrents identifies (texte libre ≤ 255). Obligatoire role Commerciale (RG-1.04).');
$this->comment('client', 'founded_at', 'Onglet Information : date de creation de l entreprise. Facultatif.'); $this->comment('client', 'founded_at', 'Onglet Information : date de creation de l entreprise. Obligatoire role Commerciale (RG-1.04).');
$this->comment('client', 'employees_count', 'Onglet Information : effectif (entier >= 0). Facultatif.'); $this->comment('client', 'employees_count', 'Onglet Information : effectif (entier >= 0). Obligatoire role Commerciale (RG-1.04).');
$this->comment('client', 'revenue_amount', 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Facultatif.'); $this->comment('client', 'revenue_amount', 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).');
$this->comment('client', 'director_name', 'Onglet Information : nom du dirigeant. Facultatif.'); $this->comment('client', 'director_name', 'Onglet Information : nom du dirigeant. Obligatoire role Commerciale (RG-1.04).');
$this->comment('client', 'profit_amount', 'Onglet Information : resultat / benefice (NUMERIC 15,2). Facultatif.'); $this->comment('client', 'profit_amount', 'Onglet Information : resultat / benefice (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).');
$this->comment('client', 'siren', 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-1.15 supprimee, Q4).'); $this->comment('client', 'siren', 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-1.15 supprimee, Q4).');
$this->comment('client', 'account_number', 'Onglet Comptabilite : numero de compte comptable du client.'); $this->comment('client', 'account_number', 'Onglet Comptabilite : numero de compte comptable du client.');
$this->comment('client', 'tva_mode_id', 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id, ON DELETE RESTRICT.'); $this->comment('client', 'tva_mode_id', 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id, ON DELETE RESTRICT.');
+8 -8
View File
@@ -82,14 +82,14 @@ final class Version20260605130000 extends AbstractMigration
// Ordre inverse des dependances FK : jointures et sous-collections // Ordre inverse des dependances FK : jointures et sous-collections
// d'abord, puis supplier. Les referentiels comptables et le // d'abord, puis supplier. Les referentiels comptables et le
// CategoryType FOURNISSEUR ne sont pas touches (crees ailleurs). // CategoryType FOURNISSEUR ne sont pas touches (crees ailleurs).
$this->addSql('DROP TABLE IF EXISTS supplier_address_category'); $this->addSql('DROP TABLE supplier_address_category');
$this->addSql('DROP TABLE IF EXISTS supplier_address_contact'); $this->addSql('DROP TABLE supplier_address_contact');
$this->addSql('DROP TABLE IF EXISTS supplier_address_site'); $this->addSql('DROP TABLE supplier_address_site');
$this->addSql('DROP TABLE IF EXISTS supplier_rib'); $this->addSql('DROP TABLE supplier_rib');
$this->addSql('DROP TABLE IF EXISTS supplier_address'); $this->addSql('DROP TABLE supplier_address');
$this->addSql('DROP TABLE IF EXISTS supplier_contact'); $this->addSql('DROP TABLE supplier_contact');
$this->addSql('DROP TABLE IF EXISTS supplier_category'); $this->addSql('DROP TABLE supplier_category');
$this->addSql('DROP TABLE IF EXISTS supplier'); $this->addSql('DROP TABLE supplier');
} }
// ================================================================= // =================================================================
-149
View File
@@ -1,149 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Catalog Category multi-types : passage de la relation Category -> CategoryType
* de ManyToOne a ManyToMany.
*
* Ordre critique :
* 1. Creation de la table de jonction `category_category_type` (FK category ON
* DELETE CASCADE, FK category_type ON DELETE RESTRICT conserve le garde-fou
* « on ne supprime pas un type encore reference »).
* 2. Backfill : chaque categorie existante recoit une ligne de jonction vers son
* ancien `category_type_id` (avant de dropper la colonne).
* 3. Drop de l'index unique (LOWER(name), category_type_id), de l'index FK et de
* la colonne `category.category_type_id` (Postgres drope la FK dependante).
* 4. Nouvel index unique GLOBAL sur le nom : LOWER(name) WHERE deleted_at IS NULL
* (l'unicite n'est plus liee au type RG-1.07 reformulee).
*
* Sur base fraiche, les categories seedees CLIENT (Distributeur/Courtier/Secteur/
* Autre) et FOURNISSEUR (Negociant/Cooperative/...) n'ont aucun nom en collision
* -> l'index unique global passe sans conflit.
*
* Migration placee au namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) :
* Doctrine Migrations 3.x trie par FQCN puis version ; le namespace racine garantit
* l'ordre par timestamp apres les migrations d'init des tables.
*/
final class Version20260608120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Catalog : Category <-> CategoryType en ManyToMany (jonction category_category_type), unicite du nom globalisee.';
}
public function up(Schema $schema): void
{
// 1. Table de jonction.
$this->addSql(<<<'SQL'
CREATE TABLE category_category_type (
category_id INT NOT NULL,
category_type_id INT NOT NULL,
PRIMARY KEY (category_id, category_type_id),
CONSTRAINT fk_category_category_type_category
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE CASCADE,
CONSTRAINT fk_category_category_type_type
FOREIGN KEY (category_type_id) REFERENCES category_type (id) ON DELETE RESTRICT
)
SQL);
$this->addSql('CREATE INDEX idx_cat_cat_type_type ON category_category_type (category_type_id)');
$this->comment('category_category_type', '_table', 'Jointure M2M category <-> category_type (Catalog) — types portes par la categorie, au moins un obligatoire (RG-1.05).');
$this->comment('category_category_type', 'category_id', 'FK -> category.id, ON DELETE CASCADE — categorie portant le type.');
$this->comment('category_category_type', 'category_type_id', 'FK -> category_type.id, ON DELETE RESTRICT — type rattache (un type ne peut etre supprime tant qu il reste reference).');
// 2. Backfill depuis l'ancienne colonne ManyToOne (chaque categorie -> 1 type).
$this->addSql(<<<'SQL'
INSERT INTO category_category_type (category_id, category_type_id)
SELECT id, category_type_id FROM category
SQL);
// 3. Suppression de l'ancien modele : index unique par type, index FK, colonne.
$this->addSql('DROP INDEX uq_category_name_type_active');
$this->addSql('DROP INDEX idx_category_type_id');
// DROP COLUMN drope automatiquement la FK fk_category_type qui en depend.
$this->addSql('ALTER TABLE category DROP COLUMN category_type_id');
// 4. Unicite du nom desormais GLOBALE parmi les actifs (RG-1.07 reformulee).
$this->addSql(<<<'SQL'
CREATE UNIQUE INDEX uq_category_name_active
ON category (LOWER(name))
WHERE deleted_at IS NULL
SQL);
// Realignement de la doc SQL de `category` (le type n'est plus une colonne).
$this->comment('category', '_table', 'Categories — referentiel multi-types via la jonction category_category_type, soft-delete via deleted_at, unicite LOWER(name) GLOBALE parmi les actifs (uq_category_name_active).');
$this->comment('category', 'name', 'Libelle de la categorie (≤ 120 caracteres) — unique GLOBALEMENT parmi les actifs (RG-1.07, uq_category_name_active).');
}
public function down(Schema $schema): void
{
// Restauration best-effort de l'ancien modele ManyToOne (1 type par categorie).
$this->addSql('DROP INDEX IF EXISTS uq_category_name_active');
$this->addSql('ALTER TABLE category ADD COLUMN category_type_id INT DEFAULT NULL');
// Reprend le premier type de chaque categorie (l'ordre des types perdus
// au-dela du premier est best-effort : le modele cible n'en gardait qu'un).
$this->addSql(<<<'SQL'
UPDATE category c
SET category_type_id = (
SELECT cct.category_type_id
FROM category_category_type cct
WHERE cct.category_id = c.id
ORDER BY cct.category_type_id ASC
LIMIT 1
)
SQL);
// Categories sans aucun type (theorique) : on les rattache a defaut au
// premier type existant pour pouvoir reposer le NOT NULL.
$this->addSql(<<<'SQL'
UPDATE category
SET category_type_id = (SELECT id FROM category_type ORDER BY id ASC LIMIT 1)
WHERE category_type_id IS NULL
SQL);
$this->addSql('ALTER TABLE category ALTER COLUMN category_type_id SET NOT NULL');
$this->addSql(<<<'SQL'
ALTER TABLE category
ADD CONSTRAINT fk_category_type
FOREIGN KEY (category_type_id) REFERENCES category_type (id) ON DELETE RESTRICT
SQL);
$this->addSql('CREATE INDEX idx_category_type_id ON category (category_type_id)');
$this->addSql(<<<'SQL'
CREATE UNIQUE INDEX uq_category_name_type_active
ON category (LOWER(name), category_type_id)
WHERE deleted_at IS NULL
SQL);
$this->addSql('DROP TABLE category_category_type');
}
/**
* Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou `COMMENT ON COLUMN`
* en dollar-quoting Postgres ($_$...$_$) pour eviter tout echappement.
*/
private function comment(string $table, string $column, string $description): void
{
$quotedTable = '"'.str_replace('"', '""', $table).'"';
if ('_table' === $column) {
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
return;
}
$this->addSql(sprintf(
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
$quotedTable,
'"'.str_replace('"', '""', $column).'"',
$description,
));
}
}
-102
View File
@@ -1,102 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* ERP-116 Referentiel Pays (Country), 1re iteration : creation de la table
* `country` + seed des 7 pays (France, Allemagne, Belgique, Espagne, Italie,
* Royaume-Uni, Suisse). Devient la source unique du select pays, en
* remplacement de la liste codee en dur cote front.
*
* Perimetre minimal voulu : code ISO 3166-1 alpha-2 + libelle FR + ordre
* d'affichage UNIQUEMENT. Aucune longueur bancaire/fiscale (numero de compte,
* IBAN, TVA, BIC, SIREN) a ce stade iteration ulterieure du meme ticket.
*
* Pas de FK posee sur les adresses (client_address.country / supplier_address)
* a cette etape : ces colonnes restent des chaines libres (« France »...), donc
* aucune migration de donnees ni rupture de l'existant.
*
* Namespace racine `DoctrineMigrations` (regle ABSOLUE Starseed n°11) comme les
* migrations M1/M2 du module Commercial : pas de migrations_path modulaire
* configure pour Commercial, et le tri par timestamp reste garanti.
*
* Seed idempotent `ON CONFLICT (code) DO NOTHING` : la table peut deja porter
* des donnees en prod lors d'un rejeu. Chaque colonne porte un `COMMENT ON
* COLUMN` (regle ABSOLUE n°12, garde-fou ColumnsHaveSqlCommentTest) ; la table
* est aussi mirroree dans ColumnCommentsCatalog pour survivre au
* `schema:update --force` du setup de test.
*/
final class Version20260609100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-116 : table country (referentiel pays) + seed des 7 pays.';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE country (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
code VARCHAR(2) NOT NULL,
name VARCHAR(80) NOT NULL,
position INT DEFAULT 0 NOT NULL,
PRIMARY KEY (id)
)
SQL);
$this->addSql('CREATE UNIQUE INDEX uq_country_code ON country (code)');
$this->comment('country', '_table', 'Referentiel des pays selectionnables dans les adresses (clients/fournisseurs). Perimetre minimal : code ISO + libelle + ordre (pas de longueurs bancaires/fiscales).');
$this->comment('country', 'id', 'Identifiant interne auto-incremente.');
$this->comment('country', 'code', 'Code pays ISO 3166-1 alpha-2 (2 lettres MAJUSCULES, ex: FR) — unique (uq_country_code), fige a la creation.');
$this->comment('country', 'name', 'Libelle FR du pays (≤ 80 caracteres) — valeur stockee telle quelle dans les adresses (country en chaine libre a ce stade).');
$this->comment('country', 'position', 'Ordre d affichage croissant dans le selecteur pays (tri position ASC puis name ASC ; France en tete).');
// Seed initial. France en tete (position 10) puis ordre alphabetique.
// Table fraichement creee, mais ON CONFLICT pour rejouabilite en prod.
$this->addSql(<<<'SQL'
INSERT INTO country (code, name, position) VALUES
('FR', 'France', 10),
('DE', 'Allemagne', 20),
('BE', 'Belgique', 30),
('ES', 'Espagne', 40),
('IT', 'Italie', 50),
('GB', 'Royaume-Uni', 60),
('CH', 'Suisse', 70)
ON CONFLICT (code) DO NOTHING
SQL);
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE country');
}
/**
* Pose un `COMMENT ON TABLE` (colonne speciale `_table`) ou
* `COMMENT ON COLUMN`. Quoting defensif des identifiants + delimiteur $_$
* pour ne pas casser sur les apostrophes des descriptions.
*/
private function comment(string $table, string $column, string $description): void
{
$quotedTable = '"'.str_replace('"', '""', $table).'"';
if ('_table' === $column) {
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
return;
}
$this->addSql(sprintf(
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
$quotedTable,
'"'.str_replace('"', '""', $column).'"',
$description,
));
}
}
-81
View File
@@ -1,81 +0,0 @@
<?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
@@ -1,51 +0,0 @@
<?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,
));
}
}
+20 -54
View File
@@ -19,18 +19,14 @@ use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait; use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
/** /**
* Categorie : referentiel metier classifiant les futurs tiers (clients, * Categorie : referentiel metier classifiant les futurs tiers (clients,
* fournisseurs, prestataires). Porte un `name` libre et un ou plusieurs * fournisseurs, prestataires). Porte un `name` libre et un `categoryType`
* `categoryTypes` (ManyToMany vers le referentiel statique CategoryType, * (FK vers le referentiel statique CategoryType).
* table de jonction `category_category_type`). Une categorie peut appartenir
* a plusieurs types simultanement (>= 1 obligatoire, RG-1.05).
* *
* - Soft delete via `deletedAt` (pas de hard delete) : la liste exclut par * - Soft delete via `deletedAt` (pas de hard delete) : la liste exclut par
* defaut les categories supprimees (cf. CategoryProvider, ticket 0.3). * defaut les categories supprimees (cf. CategoryProvider, ticket 0.3).
@@ -85,11 +81,12 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: DoctrineCategoryRepository::class)] #[ORM\Entity(repositoryClass: DoctrineCategoryRepository::class)]
#[ORM\Table(name: 'category')] #[ORM\Table(name: 'category')]
// Index nommes pour matcher la migration (cf. Role/Permission/Site). Les index // Index nommes pour matcher la migration (cf. Role/Permission/Site). Les index
// uniques partiels `uq_category_name_active` (LOWER(name) WHERE deleted_at IS // uniques partiels `uq_category_name_type_active` (LOWER(name), category_type_id
// NULL — unicite GLOBALE du nom parmi les actifs) et `uq_category_code` (code // WHERE deleted_at IS NULL) et `uq_category_code` (code WHERE deleted_at IS NULL)
// WHERE deleted_at IS NULL) restent possedes par la seule migration : Doctrine // restent possedes par la seule migration : Doctrine ORM ne sait pas exprimer un
// ORM ne sait pas exprimer un index partiel via attribut. // index partiel via attribut.
#[ORM\Index(name: 'idx_category_deleted_at', columns: ['deleted_at'])] #[ORM\Index(name: 'idx_category_deleted_at', columns: ['deleted_at'])]
#[ORM\Index(name: 'idx_category_type_id', columns: ['category_type_id'])]
#[ORM\Index(name: 'idx_category_created_by', columns: ['created_by'])] #[ORM\Index(name: 'idx_category_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_category_updated_by', columns: ['updated_by'])] #[ORM\Index(name: 'idx_category_updated_by', columns: ['updated_by'])]
#[Auditable] #[Auditable]
@@ -129,21 +126,11 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt
#[Groups(['category:read'])] #[Groups(['category:read'])]
private ?string $code = null; private ?string $code = null;
/** #[ORM\ManyToOne(targetEntity: CategoryType::class)]
* Types de la categorie (>= 1 obligatoire, RG-1.05). ManyToMany vers le #[ORM\JoinColumn(name: 'category_type_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
* referentiel statique CategoryType via la jonction `category_category_type`. #[Assert\NotNull(message: 'Type de catégorie obligatoire.')]
* Cote inverse (category_type) en ON DELETE RESTRICT : un type ne peut etre
* supprime tant qu'il reste reference par une categorie.
*
* @var Collection<int, CategoryType>
*/
#[ORM\ManyToMany(targetEntity: CategoryType::class)]
#[ORM\JoinTable(name: 'category_category_type')]
#[ORM\JoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'category_type_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un type de catégorie.')]
#[Groups(['category:read', 'category:write'])] #[Groups(['category:read', 'category:write'])]
private Collection $categoryTypes; private ?CategoryType $categoryType = null;
/** /**
* Soft delete : null = active, valeur = supprimee logiquement le {date}. * Soft delete : null = active, valeur = supprimee logiquement le {date}.
@@ -154,11 +141,6 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt
#[Groups(['category:read'])] #[Groups(['category:read'])]
private ?DateTimeImmutable $deletedAt = null; private ?DateTimeImmutable $deletedAt = null;
public function __construct()
{
$this->categoryTypes = new ArrayCollection();
}
public function getId(): ?int public function getId(): ?int
{ {
return $this->id; return $this->id;
@@ -191,42 +173,26 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt
return $this; return $this;
} }
/** public function getCategoryType(): ?CategoryType
* @return Collection<int, CategoryType>
*/
public function getCategoryTypes(): Collection
{ {
return $this->categoryTypes; return $this->categoryType;
} }
public function addCategoryType(CategoryType $categoryType): static public function setCategoryType(?CategoryType $categoryType): static
{ {
if (!$this->categoryTypes->contains($categoryType)) { $this->categoryType = $categoryType;
$this->categoryTypes->add($categoryType);
}
return $this;
}
public function removeCategoryType(CategoryType $categoryType): static
{
$this->categoryTypes->removeElement($categoryType);
return $this; return $this;
} }
/** /**
* Implemente CategoryInterface : liste des codes de types rattaches a la * Implemente CategoryInterface : code du type rattache (ou null). Permet
* categorie. Permet aux modules tiers de filtrer/valider par type metier * aux modules tiers de filtrer/valider par type metier sans dependre de
* (ex: RG-2.10 « contient FOURNISSEUR ») sans dependre de Catalog. * Catalog.
*
* @return list<string>
*/ */
public function getCategoryTypeCodes(): array public function getCategoryTypeCode(): ?string
{ {
return array_values(array_filter( return $this->categoryType?->getCode();
$this->categoryTypes->map(static fn (CategoryType $t): ?string => $t->getCode())->toArray(),
));
} }
public function getDeletedAt(): ?DateTimeImmutable public function getDeletedAt(): ?DateTimeImmutable
@@ -23,26 +23,10 @@ interface CategoryRepositoryInterface
/** /**
* Construit un QueryBuilder de liste avec filtre soft-delete et tri par defaut. * Construit un QueryBuilder de liste avec filtre soft-delete et tri par defaut.
* - $includeDeleted = false : exclut les categories soft-deleted (RG-1.08) * - $includeDeleted = false : exclut les categories soft-deleted (RG-1.08)
* - $typeCode non null : ne garde que les categories PORTANT ce code de type * - $typeCode non null : ne garde que les categories dont le CategoryType
* (filtre `?typeCode=`, ex. FOURNISSEUR / CLIENT). Sert au multi-select * porte ce code (filtre `?typeCode=`, ex. FOURNISSEUR / CLIENT). Sert au
* Categorie du fournisseur (M2, RG-2.10). * multi-select Categorie du fournisseur (M2, RG-2.10).
* - $nameSearch non null : recherche partielle case-insensitive sur le nom
* (filtre `?name=` de la liste admin).
* - $typeIds non vide : ne garde que les categories portant AU MOINS UN des
* types (OR, filtre `?typeId[]=` de la liste admin).
* - Tri : name ASC (RG-1.10). * - Tri : name ASC (RG-1.10).
*
* Les categories etant en ManyToMany avec leurs types, la collection
* `categoryTypes` est eager-loadee (addSelect) pour eviter un N+1 a la
* serialisation, et `distinct` est applique des qu'un filtre type joint la
* table de jonction (evite les lignes dupliquees).
*
* @param list<int> $typeIds
*/ */
public function createListQueryBuilder( public function createListQueryBuilder(bool $includeDeleted = false, ?string $typeCode = null): QueryBuilder;
bool $includeDeleted = false,
?string $typeCode = null,
?string $nameSearch = null,
array $typeIds = [],
): QueryBuilder;
} }
@@ -22,8 +22,8 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
* via CategoryCodeGenerator ; puis delegation au persist_processor Doctrine * via CategoryCodeGenerator ; puis delegation au persist_processor Doctrine
* ORM. Le code est FIGE a la creation (jamais recalcule sur PATCH). Toute * ORM. Le code est FIGE a la creation (jamais recalcule sur PATCH). Toute
* UniqueConstraintViolationException remontee par Postgres (collision sur * UniqueConstraintViolationException remontee par Postgres (collision sur
* l'index partiel uq_category_name_active unicite GLOBALE du nom parmi les * l'index partiel uq_category_name_type_active) est traduite en HTTP 409 avec
* actifs) est traduite en HTTP 409 avec le message attendu par la spec (RG-1.07). * le message attendu par la spec (RG-1.07).
* - DELETE : soft delete (RG-1.12). On NE delegue PAS au remove_processor ; * - DELETE : soft delete (RG-1.12). On NE delegue PAS au remove_processor ;
* on pose deletedAt = now() puis on delegue au persist_processor pour que * on pose deletedAt = now() puis on delegue au persist_processor pour que
* le UPDATE Doctrine parte et que le TimestampableBlamableSubscriber mette * le UPDATE Doctrine parte et que le TimestampableBlamableSubscriber mette
@@ -78,12 +78,10 @@ final class CategoryProcessor implements ProcessorInterface
try { try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context); return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} catch (UniqueConstraintViolationException $e) { } catch (UniqueConstraintViolationException $e) {
// RG-1.07 : doublon de nom GLOBAL (LOWER(name)) parmi les non-soft-deleted // RG-1.07 : doublon (LOWER(name), category_type_id) parmi les non-soft-deleted.
// (uq_category_name_active). L'unicite n'est plus liee au type depuis le
// passage en ManyToMany.
throw new HttpException( throw new HttpException(
409, 409,
sprintf('Une catégorie nommée "%s" existe déjà.', $data->getName() ?? ''), sprintf('Une catégorie nommée "%s" existe déjà pour ce type.', $data->getName() ?? ''),
$e, $e,
); );
} }
@@ -40,12 +40,7 @@ final class CategoryProvider implements ProviderInterface
$includeDeleted = $this->readIncludeDeleted($context); $includeDeleted = $this->readIncludeDeleted($context);
if ($operation instanceof CollectionOperationInterface) { if ($operation instanceof CollectionOperationInterface) {
$qb = $this->repository->createListQueryBuilder( $qb = $this->repository->createListQueryBuilder($includeDeleted, $this->readTypeCode($context));
$includeDeleted,
$this->readTypeCode($context),
$this->readNameSearch($context),
$this->readTypeIds($context),
);
// Echappatoire ?pagination=false : retourne la collection complete sans Paginator. // Echappatoire ?pagination=false : retourne la collection complete sans Paginator.
// Utile pour les drawers Role/Permission/Site/CategoryType qui alimentent un <select>. // Utile pour les drawers Role/Permission/Site/CategoryType qui alimentent un <select>.
@@ -120,48 +115,4 @@ final class CategoryProvider implements ProviderInterface
return '' === $raw ? null : $raw; return '' === $raw ? null : $raw;
} }
/**
* Lit le filtre `?name=` (recherche partielle sur le nom, liste admin).
* Renvoie la valeur trimmee ou null si absente / vide.
*/
private function readNameSearch(array $context): ?string
{
$raw = $context['filters']['name'] ?? null;
if (!is_string($raw)) {
return null;
}
$raw = trim($raw);
return '' === $raw ? null : $raw;
}
/**
* Lit le filtre `?typeId[]=` (liste admin) : ids des types coches (OR).
* Tolere une valeur scalaire unique (`?typeId=3`) ou un tableau. Ignore
* les entrees non numeriques.
*
* @return list<int>
*/
private function readTypeIds(array $context): array
{
$raw = $context['filters']['typeId'] ?? null;
if (null === $raw) {
return [];
}
$values = is_array($raw) ? $raw : [$raw];
$ids = [];
foreach ($values as $value) {
if (is_int($value) || (is_string($value) && ctype_digit($value))) {
$ids[] = (int) $value;
}
}
return array_values(array_unique($ids));
}
} }
@@ -138,7 +138,7 @@ class CategoryFixtures extends Fixture implements DependentFixtureInterface
$category = new Category(); $category = new Category();
$category->setName($name); $category->setName($name);
$category->setCode($code); $category->setCode($code);
$category->addCategoryType($type); $category->setCategoryType($type);
$manager->persist($category); $manager->persist($category);
} }
} }
@@ -48,19 +48,9 @@ class DoctrineCategoryRepository extends ServiceEntityRepository implements Cate
return [] !== $qb->getQuery()->getResult(); return [] !== $qb->getQuery()->getResult();
} }
public function createListQueryBuilder( public function createListQueryBuilder(bool $includeDeleted = false, ?string $typeCode = null): QueryBuilder
bool $includeDeleted = false, {
?string $typeCode = null,
?string $nameSearch = null,
array $typeIds = [],
): QueryBuilder {
// Eager-load de la collection categoryTypes (ManyToMany) : embarquee a la
// serialisation -> on la fetch-joine pour eviter un N+1 par categorie. Le
// provider enveloppe la requete dans un Paginator(fetchJoinCollection: true),
// compatible avec ce fetch-join to-many.
$qb = $this->createQueryBuilder('c') $qb = $this->createQueryBuilder('c')
->leftJoin('c.categoryTypes', 'cte')
->addSelect('cte')
->orderBy('c.name', 'ASC') ->orderBy('c.name', 'ASC')
; ;
@@ -68,45 +58,16 @@ class DoctrineCategoryRepository extends ServiceEntityRepository implements Cate
$qb->andWhere('c.deletedAt IS NULL'); $qb->andWhere('c.deletedAt IS NULL');
} }
// Filtre `?typeCode=` : la categorie doit PORTER ce code de type (RG-2.10, // Filtre `?typeCode=` : jointure sur le CategoryType pour ne garder que
// multi-select fournisseur). Sous-requete EXISTS correlee pour ne PAS // les categories du type demande (ex. FOURNISSEUR). La jointure reste
// restreindre la collection eager-loadee `cte` (sinon les autres types de // compatible avec le Paginator ORM (fetchJoinCollection) du provider.
// la categorie disparaitraient du JSON) et eviter les lignes dupliquees.
if (null !== $typeCode) { if (null !== $typeCode) {
$sub = $this->getEntityManager()->createQueryBuilder() $qb->join('c.categoryType', 'ct')
->select('1') ->andWhere('ct.code = :typeCode')
->from(Category::class, 'c_tc')
->join('c_tc.categoryTypes', 'ct_tc')
->where('c_tc = c')
->andWhere('ct_tc.code = :typeCode')
;
$qb->andWhere($qb->expr()->exists($sub->getDQL()))
->setParameter('typeCode', $typeCode) ->setParameter('typeCode', $typeCode)
; ;
} }
// Filtre `?typeId[]=` (liste admin) : la categorie porte AU MOINS UN des
// types coches (OR). Meme strategie EXISTS correlee que `typeCode`.
if ([] !== $typeIds) {
$sub = $this->getEntityManager()->createQueryBuilder()
->select('1')
->from(Category::class, 'c_ti')
->join('c_ti.categoryTypes', 'ct_ti')
->where('c_ti = c')
->andWhere('ct_ti.id IN (:typeIds)')
;
$qb->andWhere($qb->expr()->exists($sub->getDQL()))
->setParameter('typeIds', $typeIds)
;
}
// Filtre `?name=` (liste admin) : recherche partielle case-insensitive.
if (null !== $nameSearch && '' !== $nameSearch) {
$qb->andWhere('LOWER(c.name) LIKE :nameSearch')
->setParameter('nameSearch', '%'.mb_strtolower($nameSearch).'%')
;
}
return $qb; return $qb;
} }
} }
@@ -16,9 +16,9 @@ use Symfony\Component\Validator\ConstraintViolationList;
* Type de reglement). La banque reste conditionnelle (RG-1.12) et les RIB aussi * Type de reglement). La banque reste conditionnelle (RG-1.12) et les RIB aussi
* (RG-1.13) : ils ne sont pas couverts ici. * (RG-1.13) : ils ne sont pas couverts ici.
* *
* Parti pris : colonnes nullable en base + validateur contextuel, plutot qu'un * Calque sur ClientInformationCompletenessValidator (RG-1.04) : colonnes nullable
* Assert\NotBlank sur l'entite (qui casserait le POST de l'onglet principal, * en base + validateur contextuel, plutot qu'un Assert\NotBlank sur l'entite (qui
* lequel n'envoie aucun champ comptable). * casserait le POST de l'onglet principal, lequel n'envoie aucun champ comptable).
* *
* Invoque par le ClientProcessor uniquement quand le payload porte les six champs * Invoque par le ClientProcessor uniquement quand le payload porte les six champs
* (= une validation d'onglet), jamais sur un PATCH ciblant un seul champ comptable. * (= une validation d'onglet), jamais sur un PATCH ciblant un seul champ comptable.
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Application\Validator;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Commercial\Domain\Entity\Client;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Validator metier RG-1.04 (durcie ERP-74) : 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 ClientProcessor des que l'utilisateur courant porte le role
* Commerciale (plus de condition d'intersection avec l'onglet Information).
* Pour les autres roles, ces champs restent optionnels le validator n'est
* pas appele.
*
* Leve une ValidationException (HTTP 422) listant chaque champ manquant, par
* coherence avec les violations Symfony rendues par API Platform.
*/
final class ClientInformationCompletenessValidator
{
public function validate(Client $client): void
{
// Map champ -> valeur courante de l'onglet Information.
$fields = [
'description' => $client->getDescription(),
'competitors' => $client->getCompetitors(),
'foundedAt' => $client->getFoundedAt(),
'employeesCount' => $client->getEmployeesCount(),
'revenueAmount' => $client->getRevenueAmount(),
'directorName' => $client->getDirectorName(),
'profitAmount' => $client->getProfitAmount(),
];
$violations = new ConstraintViolationList();
foreach ($fields as $property => $value) {
if ($this->isMissing($value)) {
$violations->add(new ConstraintViolation(
sprintf('Ce champ est obligatoire pour le role Commerciale (champ "%s").', $property),
null,
[],
$client,
$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") 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);
}
}
@@ -1,78 +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 (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);
}
}
@@ -0,0 +1,79 @@
<?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 (jumeau du ClientInformationCompletenessValidator M1) :
* 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(
sprintf('Ce champ est obligatoire pour le rôle Commerciale (champ "%s").', $property),
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);
}
}
+1 -52
View File
@@ -25,7 +25,6 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName; use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/** /**
* Client (M1 Commercial) entite racine du repertoire clients. Porte le * Client (M1 Commercial) entite racine du repertoire clients. Porte le
@@ -172,17 +171,6 @@ class Client implements TimestampableInterface, BlamableInterface
#[Groups(['client:read', 'client:write:main'])] #[Groups(['client:read', 'client:write:main'])]
private bool $triageService = false; 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 // RG : au moins une categorie (Count min 1). M2M vers Category via le contrat
// CategoryInterface (resolve_target_entities -> Category). // CategoryInterface (resolve_target_entities -> Category).
/** @var Collection<int, CategoryInterface> */ /** @var Collection<int, CategoryInterface> */
@@ -200,7 +188,7 @@ class Client implements TimestampableInterface, BlamableInterface
private ?string $description = null; private ?string $description = null;
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'La liste des concurrents ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 255, maxMessage: 'Ce champ ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client:read', 'client:write:information'])] #[Groups(['client:read', 'client:write:information'])]
private ?string $competitors = null; private ?string $competitors = null;
@@ -345,45 +333,6 @@ class Client implements TimestampableInterface, BlamableInterface
return $this; 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 public function isTriageService(): bool
{ {
return $this->triageService; return $this->triageService;
@@ -129,18 +129,6 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
#[Groups(['client_address:write'])] #[Groups(['client_address:write'])]
private bool $isBilling = false; 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'])] #[ORM\Column(length: 80, options: ['default' => 'France'])]
#[Assert\Length(max: 80, maxMessage: 'Le pays ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 80, maxMessage: 'Le pays ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client_address:read', 'client_address:write'])] #[Groups(['client_address:read', 'client_address:write'])]
@@ -178,15 +166,6 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
#[Groups(['client_address:read', 'client_address:write'])] #[Groups(['client_address:read', 'client_address:write'])]
private ?string $billingEmail = null; 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])] #[ORM\Column(options: ['default' => 0])]
#[Groups(['client_address:read', 'client_address:write'])] #[Groups(['client_address:read', 'client_address:write'])]
private int $position = 0; private int $position = 0;
@@ -244,48 +223,6 @@ 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 * RG-1.11 : l'email de facturation est obligatoire si l'adresse est de
* facturation, et interdit sinon. Mirror applicatif (422) du CHECK * facturation, et interdit sinon. Mirror applicatif (422) du CHECK
@@ -317,16 +254,6 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
->addViolation() ->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()
;
}
} }
/** /**
@@ -416,34 +343,6 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
return $this; 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 public function getCountry(): string
{ {
return $this->country; return $this->country;
@@ -516,18 +415,6 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
return $this; return $this;
} }
public function getBillingEmailSecondary(): ?string
{
return $this->billingEmailSecondary;
}
public function setBillingEmailSecondary(?string $billingEmailSecondary): static
{
$this->billingEmailSecondary = $billingEmailSecondary;
return $this;
}
public function getPosition(): int public function getPosition(): int
{ {
return $this->position; return $this->position;
@@ -31,8 +31,8 @@ use Symfony\Component\Validator\Constraints as Assert;
* comptable et la conformite, cf. spec § 2.5 / § 6.1). * comptable et la conformite, cf. spec § 2.5 / § 6.1).
* *
* Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony au M1 * Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony au M1
* (HP-M2-14 : pas de controle externe banque reelle), avec controle croise pays * (HP-M2-14 : pas de controle externe banque reelle). Timestampable/Blamable
* BIC/IBAN (ibanPropertyPath). Timestampable/Blamable standard. * standard.
* *
* Sous-ressource API (ERP-57, spec § 4.5) gating comptable renforce : * Sous-ressource API (ERP-57, spec § 4.5) gating comptable renforce :
* - POST /api/clients/{clientId}/ribs : creation rattachee au client parent * - POST /api/clients/{clientId}/ribs : creation rattachee au client parent
@@ -109,15 +109,9 @@ class ClientRib implements TimestampableInterface, BlamableInterface
// Bic/Iban bornent deja le format (et donc la longueur) : pas de Length // Bic/Iban bornent deja le format (et donc la longueur) : pas de Length
// redondant calee sur la colonne (whitelist du garde-fou ERP-107). // 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)] #[ORM\Column(length: 20)]
#[Assert\NotBlank(message: 'Le BIC est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank(message: 'Le BIC est obligatoire.', normalizer: 'trim')]
#[Assert\Bic( #[Assert\Bic(message: 'Le BIC n\'est pas valide.')]
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'])] #[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])]
private ?string $bic = null; private ?string $bic = null;
@@ -1,109 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineCountryRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Pays selectionnable dans les adresses (clients / fournisseurs) : referentiel
* statique seede par la migration (France, Allemagne, Belgique, Espagne, Italie,
* Royaume-Uni). Remplace la liste de pays jusqu'ici codee en dur cote front.
*
* Perimetre minimal (ticket ERP-116, 1re iteration) : code ISO + libelle + ordre
* d'affichage uniquement. AUCUNE longueur bancaire/fiscale (numero de compte,
* IBAN, TVA, BIC, SIREN) a ce stade ces colonnes feront l'objet d'une iteration
* ulterieure du meme ticket.
*
* Lecture seule : GetCollection + Get uniquement ; POST/PATCH/DELETE -> 405.
* Permission alignee sur Bank (referentiel d'adresse partage clients/fournisseurs).
* Pas de Timestampable/Blamable (referentiel statique whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED, comme Bank).
*/
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
normalizationContext: ['groups' => ['country:read']],
// Tri par defaut : position ASC (France en tete) puis name ASC.
order: ['position' => 'ASC', 'name' => 'ASC'],
// Toggle ?pagination=false pour alimenter le select (cf. Bank).
paginationClientEnabled: true,
),
new Get(
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
normalizationContext: ['groups' => ['country:read']],
),
],
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
)]
#[ORM\Entity(repositoryClass: DoctrineCountryRepository::class)]
#[ORM\Table(name: 'country')]
#[ORM\UniqueConstraint(name: 'uq_country_code', columns: ['code'])]
class Country
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['country:read'])]
private ?int $id = null;
#[ORM\Column(length: 2)]
#[Groups(['country:read'])]
private ?string $code = null;
#[ORM\Column(length: 80)]
#[Groups(['country:read'])]
private ?string $name = null;
#[ORM\Column(options: ['default' => 0])]
#[Groups(['country:read'])]
private int $position = 0;
public function getId(): ?int
{
return $this->id;
}
public function getCode(): ?string
{
return $this->code;
}
public function setCode(string $code): static
{
$this->code = $code;
return $this;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
}
@@ -135,9 +135,9 @@ class Supplier implements TimestampableInterface, BlamableInterface
use TimestampableBlamableTrait; use TimestampableBlamableTrait;
/** /**
* RG-2.10 : seules les categories PORTANT ce type sont autorisees sur le * RG-2.10 : seules les categories de ce type sont autorisees sur le
* fournisseur (entite principale). Miroir de SupplierAddress (ERP-88). * fournisseur (entite principale). Miroir de SupplierAddress (ERP-88).
* S'appuie sur CategoryInterface::getCategoryTypeCodes() (pas d'import du * S'appuie sur CategoryInterface::getCategoryTypeCode() (pas d'import du
* module Catalog regle ABSOLUE n°1). * module Catalog regle ABSOLUE n°1).
*/ */
private const string REQUIRED_CATEGORY_TYPE_CODE = 'FOURNISSEUR'; private const string REQUIRED_CATEGORY_TYPE_CODE = 'FOURNISSEUR';
@@ -181,7 +181,7 @@ class Supplier implements TimestampableInterface, BlamableInterface
private ?string $description = null; private ?string $description = null;
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'La liste des concurrents ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 255, maxMessage: 'Ce champ ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['supplier:read', 'supplier:write:information'])] #[Groups(['supplier:read', 'supplier:write:information'])]
private ?string $competitors = null; private ?string $competitors = null;
@@ -300,17 +300,16 @@ class Supplier implements TimestampableInterface, BlamableInterface
* FOURNISSEUR -> sinon 422 avec violation sur le champ `categories` * FOURNISSEUR -> sinon 422 avec violation sur le champ `categories`
* (propertyPath aligne ERP-101, message FR ERP-107). Miroir de * (propertyPath aligne ERP-101, message FR ERP-107). Miroir de
* SupplierAddress::validateCategoryType (ERP-88). S'appuie sur * SupplierAddress::validateCategoryType (ERP-88). S'appuie sur
* CategoryInterface::getCategoryTypeCodes() (multi-type la categorie est * CategoryInterface::getCategoryTypeCode() (pas d'import du module Catalog
* acceptee des qu'elle PORTE le type FOURNISSEUR ; pas d'import du module * regle ABSOLUE n°1). Joue avant la base via la validation API Platform, sur
* Catalog, regle ABSOLUE n°1). Joue avant la base via la validation API * POST (categories supplier:write:main) comme sur PATCH.
* Platform, sur POST (categories supplier:write:main) comme sur PATCH.
*/ */
#[Assert\Callback] #[Assert\Callback]
public function validateCategoryType(ExecutionContextInterface $context): void public function validateCategoryType(ExecutionContextInterface $context): void
{ {
foreach ($this->categories as $category) { foreach ($this->categories as $category) {
if ($category instanceof CategoryInterface if ($category instanceof CategoryInterface
&& !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) { && self::REQUIRED_CATEGORY_TYPE_CODE !== $category->getCategoryTypeCode()) {
$context->buildViolation('Type de catégorie non autorisé (FOURNISSEUR attendu).') $context->buildViolation('Type de catégorie non autorisé (FOURNISSEUR attendu).')
->atPath('categories') ->atPath('categories')
->addViolation() ->addViolation()
@@ -328,11 +327,8 @@ class Supplier implements TimestampableInterface, BlamableInterface
* chaque 422 porte un propertyPath exploitable par extractApiViolations * chaque 422 porte un propertyPath exploitable par extractApiViolations
* (mapping inline sous le champ, pas un toast convention ERP-101). * (mapping inline sous le champ, pas un toast convention ERP-101).
* - RG-2.07 : paymentType = VIREMENT impose une banque -> violation sur `bank`. * - RG-2.07 : paymentType = VIREMENT impose une banque -> violation sur `bank`.
* - RG-2.08 : paymentType = LCR impose au moins un RIB -> violation sur * - RG-2.08 : paymentType = LCR impose au moins un RIB -> violation sur `ribs`
* `paymentType` (miroir client : `ribs` n'a pas de champ de formulaire ou * (le 409 sur DELETE du dernier RIB en LCR est porte par ERP-88).
* 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 * 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 * n'expose que supplier:write:main), la contrainte ne mord en pratique que
@@ -352,7 +348,7 @@ class Supplier implements TimestampableInterface, BlamableInterface
if (self::PAYMENT_TYPE_LCR === $paymentCode && $this->ribs->isEmpty()) { if (self::PAYMENT_TYPE_LCR === $paymentCode && $this->ribs->isEmpty()) {
$context->buildViolation('Au moins un RIB est obligatoire pour le type de règlement LCR.') $context->buildViolation('Au moins un RIB est obligatoire pour le type de règlement LCR.')
->atPath('paymentType') ->atPath('ribs')
->addViolation() ->addViolation()
; ;
} }
@@ -108,9 +108,9 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface
public const array ADDRESS_TYPES = ['PROSPECT', 'DEPART', 'RENDU']; public const array ADDRESS_TYPES = ['PROSPECT', 'DEPART', 'RENDU'];
/** /**
* RG-2.10 : seules les categories PORTANT ce type sont autorisees sur une * RG-2.10 : seules les categories de ce type sont autorisees sur une adresse
* adresse fournisseur. S'appuie sur CategoryInterface::getCategoryTypeCodes() * fournisseur. S'appuie sur CategoryInterface::getCategoryTypeCode() (pas
* (pas d'import du module Catalog regle ABSOLUE n°1). * d'import du module Catalog regle ABSOLUE n°1).
*/ */
private const string REQUIRED_CATEGORY_TYPE_CODE = 'FOURNISSEUR'; private const string REQUIRED_CATEGORY_TYPE_CODE = 'FOURNISSEUR';
@@ -199,14 +199,12 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface
#[Groups(['supplier:item:read', 'supplier:write:addresses'])] #[Groups(['supplier:item:read', 'supplier:write:addresses'])]
private Collection $contacts; private Collection $contacts;
// RG-2.10 : au moins une categorie de type FOURNISSEUR par adresse (le type est // RG-2.10 : categories d'adresse de type FOURNISSEUR (controle au Processor).
// controle par validateCategoryType ; le minimum par Assert\Count, miroir sites).
/** @var Collection<int, CategoryInterface> */ /** @var Collection<int, CategoryInterface> */
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)] #[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
#[ORM\JoinTable(name: 'supplier_address_category')] #[ORM\JoinTable(name: 'supplier_address_category')]
#[ORM\JoinColumn(name: 'supplier_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')] #[ORM\JoinColumn(name: 'supplier_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')] #[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'])] #[Groups(['supplier:item:read', 'supplier:write:addresses'])]
private Collection $categories; private Collection $categories;
@@ -221,16 +219,15 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface
* RG-2.10 : toute categorie posee sur une adresse fournisseur doit etre de * RG-2.10 : toute categorie posee sur une adresse fournisseur doit etre de
* type FOURNISSEUR -> sinon 422 avec violation sur le champ `categories` * type FOURNISSEUR -> sinon 422 avec violation sur le champ `categories`
* (propertyPath aligne ERP-101, message FR ERP-107). S'appuie sur * (propertyPath aligne ERP-101, message FR ERP-107). S'appuie sur
* CategoryInterface::getCategoryTypeCodes() (multi-type la categorie est * CategoryInterface::getCategoryTypeCode() (pas d'import du module Catalog
* acceptee des qu'elle PORTE le type FOURNISSEUR ; pas d'import du module * regle ABSOLUE n°1). Joue avant la base via la validation API Platform.
* Catalog, regle ABSOLUE n°1). Joue avant la base via la validation API Platform.
*/ */
#[Assert\Callback] #[Assert\Callback]
public function validateCategoryType(ExecutionContextInterface $context): void public function validateCategoryType(ExecutionContextInterface $context): void
{ {
foreach ($this->categories as $category) { foreach ($this->categories as $category) {
if ($category instanceof CategoryInterface if ($category instanceof CategoryInterface
&& !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) { && self::REQUIRED_CATEGORY_TYPE_CODE !== $category->getCategoryTypeCode()) {
$context->buildViolation('Type de catégorie non autorisé (FOURNISSEUR attendu).') $context->buildViolation('Type de catégorie non autorisé (FOURNISSEUR attendu).')
->atPath('categories') ->atPath('categories')
->addViolation() ->addViolation()
@@ -44,8 +44,7 @@ use Symfony\Component\Validator\Constraints as Assert;
* Tout passe par le SupplierRibProcessor (RG-2.08 sur DELETE). * Tout passe par le SupplierRibProcessor (RG-2.08 sur DELETE).
* *
* Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony (pas de controle * Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony (pas de controle
* banque reelle), avec controle croise pays BIC/IBAN (ibanPropertyPath). Audite * banque reelle). Audite (#[Auditable]) + Timestampable / Blamable.
* (#[Auditable]) + Timestampable / Blamable.
*/ */
#[ApiResource( #[ApiResource(
operations: [ operations: [
@@ -106,15 +105,9 @@ class SupplierRib implements TimestampableInterface, BlamableInterface
// Bic/Iban bornent deja le format (et donc la longueur) : pas de Length // Bic/Iban bornent deja le format (et donc la longueur) : pas de Length
// redondant calee sur la colonne (auto-exempte du miroir ERP-107). // 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)] #[ORM\Column(length: 20)]
#[Assert\NotBlank(message: 'Le BIC est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank(message: 'Le BIC est obligatoire.', normalizer: 'trim')]
#[Assert\Bic( #[Assert\Bic(message: 'Le BIC n\'est pas valide.')]
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'])] #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
private ?string $bic = null; private ?string $bic = null;
@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Repository;
use App\Module\Commercial\Domain\Entity\Country;
interface CountryRepositoryInterface
{
public function findById(int $id): ?Country;
/**
* Retourne tous les pays tries position ASC puis name ASC.
*
* @return list<Country>
*/
public function findAllOrdered(): array;
}
@@ -94,6 +94,5 @@ final class ClientAddressProcessor implements ProcessorInterface
private function normalize(ClientAddress $address): void private function normalize(ClientAddress $address): void
{ {
$address->setBillingEmail($this->normalizer->normalizeEmail($address->getBillingEmail())); $address->setBillingEmail($this->normalizer->normalizeEmail($address->getBillingEmail()));
$address->setBillingEmailSecondary($this->normalizer->normalizeEmail($address->getBillingEmailSecondary()));
} }
} }
@@ -9,8 +9,11 @@ use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException; use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Commercial\Application\Service\ClientFieldNormalizer; use App\Module\Commercial\Application\Service\ClientFieldNormalizer;
use App\Module\Commercial\Application\Validator\ClientAccountingCompletenessValidator; use App\Module\Commercial\Application\Validator\ClientAccountingCompletenessValidator;
use App\Module\Commercial\Application\Validator\ClientInformationCompletenessValidator;
use App\Module\Commercial\Domain\Entity\Client; use App\Module\Commercial\Domain\Entity\Client;
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
use App\Shared\Domain\Contract\CategoryInterface; use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Security\BusinessRoles;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@@ -41,8 +44,8 @@ use Symfony\Component\Validator\ConstraintViolationList;
* 2. Normalisation serveur (RG-1.18 a 1.21) via ClientFieldNormalizer. * 2. Normalisation serveur (RG-1.18 a 1.21) via ClientFieldNormalizer.
* 3. Regles metier : RG-1.03 (distributor/broker * 3. Regles metier : RG-1.03 (distributor/broker
* exclusifs + type de categorie), RG-1.12 (Virement -> banque), * exclusifs + type de categorie), RG-1.12 (Virement -> banque),
* RG-1.13 (LCR -> >= 1 RIB). L'onglet Information est entierement facultatif * RG-1.13 (LCR -> >= 1 RIB), RG-1.04 (completude Information exigee sur POST
* (RG-1.04 retiree : plus d'obligation, y compris pour le role Commerciale). * et tout PATCH pour le role Commerciale).
* 4. Pose / retrait de archivedAt (RG-1.22 true=now, RG-1.23 false=null). * 4. Pose / retrait de archivedAt (RG-1.22 true=now, RG-1.23 false=null).
* 5. Persistance via le persist_processor Doctrine, avec traduction des * 5. Persistance via le persist_processor Doctrine, avec traduction des
* collisions d'unicite en 409 (RG-1.16 doublon de nom ; RG-1.23 conflit de * collisions d'unicite en 409 (RG-1.16 doublon de nom ; RG-1.23 conflit de
@@ -105,6 +108,7 @@ final class ClientProcessor implements ProcessorInterface
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor, private readonly ProcessorInterface $persistProcessor,
private readonly ClientFieldNormalizer $normalizer, private readonly ClientFieldNormalizer $normalizer,
private readonly ClientInformationCompletenessValidator $informationValidator,
private readonly ClientAccountingCompletenessValidator $accountingValidator, private readonly ClientAccountingCompletenessValidator $accountingValidator,
private readonly Security $security, private readonly Security $security,
private readonly RequestStack $requestStack, private readonly RequestStack $requestStack,
@@ -117,12 +121,6 @@ final class ClientProcessor implements ProcessorInterface
return $this->persistProcessor->process($data, $operation, $uriVariables, $context); return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} }
// Reinitialisation de la memoisation du payload en debut de traitement :
// le service est partage (stateful), on repart du corps de LA requete
// courante et on n'herite jamais des cles decodees d'une requete passee.
$this->decodedContent = null;
$this->decodedPayloadKeys = [];
$writableKeys = $this->writablePayloadKeys(); $writableKeys = $this->writablePayloadKeys();
$isArchiveRequest = $this->guardArchive($data, $writableKeys); $isArchiveRequest = $this->guardArchive($data, $writableKeys);
@@ -138,6 +136,7 @@ final class ClientProcessor implements ProcessorInterface
$this->validateDistributorBroker($data); $this->validateDistributorBroker($data);
$this->validateAccountingConsistency($data); $this->validateAccountingConsistency($data);
$this->validateAccountingCompleteness($data); $this->validateAccountingCompleteness($data);
$this->validateInformationCompleteness($data);
try { try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context); return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
@@ -506,9 +505,9 @@ final class ClientProcessor implements ProcessorInterface
* ce n'est pas une validation d'onglet (edition ponctuelle preservee). bank / * ce n'est pas une validation d'onglet (edition ponctuelle preservee). bank /
* RIB restent geres par validateAccountingConsistency (RG-1.12 / RG-1.13). * RIB restent geres par validateAccountingConsistency (RG-1.12 / RG-1.13).
* *
* Colonnes nullable en base + validateur contextuel : un Assert\NotBlank sur * Colonnes nullable en base + validateur contextuel (meme parti que RG-1.04) :
* l'entite casserait le POST de l'onglet principal, qui n'envoie aucun champ * un Assert\NotBlank sur l'entite casserait le POST de l'onglet principal, qui
* comptable. * n'envoie aucun champ comptable.
*/ */
private function validateAccountingCompleteness(Client $data): void private function validateAccountingCompleteness(Client $data): void
{ {
@@ -521,6 +520,21 @@ final class ClientProcessor implements ProcessorInterface
$this->accountingValidator->validate($data); $this->accountingValidator->validate($data);
} }
/**
* RG-1.04 (durcie ERP-74) : 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
* (plus de condition d'intersection avec INFORMATION_FIELDS). Garantit qu'un
* client cree/edite par une Commerciale ne reste jamais avec un onglet
* Information incomplet.
*/
private function validateInformationCompleteness(Client $data): void
{
if ($this->currentUserIsCommerciale()) {
$this->informationValidator->validate($data);
}
}
/** /**
* Vrai si au moins une categorie du client porte le code donne. S'appuie sur * Vrai si au moins une categorie du client porte le code donne. S'appuie sur
* CategoryInterface::getCode() (pas d'import de Category regle ABSOLUE n°1). * CategoryInterface::getCode() (pas d'import de Category regle ABSOLUE n°1).
@@ -536,12 +550,21 @@ final class ClientProcessor implements ProcessorInterface
return false; return false;
} }
private function currentUserIsCommerciale(): bool
{
$user = $this->security->getUser();
return $user instanceof BusinessRoleAwareInterface
&& $user->hasBusinessRole(BusinessRoles::COMMERCIALE);
}
/** /**
* Cles ecrivables effectivement presentes dans le payload : on retire les * Cles ecrivables effectivement presentes dans le payload : on retire les
* cles JSON-LD (@id, @context, @var...) et tout champ non rattache a un * cles JSON-LD (@id, @context, @var...) et tout champ non rattache a un
* groupe d'ecriture connu. C'est la base du 422 d'archivage (RG-1.22) * groupe d'ecriture connu. C'est la base du 422 d'archivage (RG-1.22) et du
* sans elles, un PATCH « representation complete » porteur de @id ferait * declenchement conditionnel de RG-1.04 sans elles, un PATCH
* croire a une modification multi-onglets. * « representation complete » porteur de @id ferait croire a une
* modification multi-onglets.
* *
* @return list<string> * @return list<string>
*/ */
@@ -7,8 +7,10 @@ namespace App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProcessorInterface;
use App\Module\Commercial\Application\Service\SupplierFieldNormalizer; use App\Module\Commercial\Application\Service\SupplierFieldNormalizer;
use App\Module\Commercial\Application\Validator\SupplierAccountingCompletenessValidator; use App\Module\Commercial\Application\Validator\SupplierInformationCompletenessValidator;
use App\Module\Commercial\Domain\Entity\Supplier; use App\Module\Commercial\Domain\Entity\Supplier;
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
use App\Shared\Domain\Security\BusinessRoles;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@@ -41,18 +43,20 @@ use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
* collisions d'unicite en 409 (RG-2.11 doublon de nom ; RG-2.15 conflit de * collisions d'unicite en 409 (RG-2.11 doublon de nom ; RG-2.15 conflit de
* restauration). * restauration).
* *
* Validators metier (ERP-89). Ce processor porte la completude Comptabilite : a * Validators metier (ERP-89). Decision figee : ce processor ne porte QUE
* la validation complete de l'onglet (les six scalaires obligatoires presents * RG-2.03 (completude Information exigee pour le role Commerciale detection du
* dans le payload), chacun doit etre renseigne. (RG-2.03 « Information obligatoire * role cote back, non exprimable en contrainte d'entite). Les RG inter-champs
* pour la Commerciale » a ete retiree, miroir client M1 l'onglet Information est * RG-2.07 (Virement -> banque), RG-2.08 (LCR -> >= 1 RIB) et RG-2.10 (categorie
* desormais entierement facultatif, quel que soit le role.) * de type FOURNISSEUR) sont portees par des Assert\Callback + ->atPath() sur
* * l'entite Supplier (jouees par API Platform AVANT ce processor), pour que
* Les RG inter-champs RG-2.07 (Virement -> banque), RG-2.08 (LCR -> >= 1 RIB) et * chaque 422 porte un propertyPath consommable par extractApiViolations
* 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). * (mapping inline, pas un toast convention ERP-101).
* *
* 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).
*
* @implements ProcessorInterface<Supplier, Supplier> * @implements ProcessorInterface<Supplier, Supplier>
*/ */
final class SupplierProcessor implements ProcessorInterface final class SupplierProcessor implements ProcessorInterface
@@ -74,14 +78,6 @@ final class SupplierProcessor implements ProcessorInterface
'paymentType', 'bank', '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). */ /** Champ d'archivage (groupe supplier:write:archive). */
private const string ARCHIVE_FIELD = 'isArchived'; private const string ARCHIVE_FIELD = 'isArchived';
@@ -106,7 +102,7 @@ final class SupplierProcessor implements ProcessorInterface
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor, private readonly ProcessorInterface $persistProcessor,
private readonly SupplierFieldNormalizer $normalizer, private readonly SupplierFieldNormalizer $normalizer,
private readonly SupplierAccountingCompletenessValidator $accountingValidator, private readonly SupplierInformationCompletenessValidator $informationValidator,
private readonly Security $security, private readonly Security $security,
private readonly RequestStack $requestStack, private readonly RequestStack $requestStack,
private readonly EntityManagerInterface $em, private readonly EntityManagerInterface $em,
@@ -118,12 +114,6 @@ final class SupplierProcessor implements ProcessorInterface
return $this->persistProcessor->process($data, $operation, $uriVariables, $context); return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} }
// Reinitialisation de la memoisation du payload en debut de traitement :
// le service est partage (stateful), on repart du corps de LA requete
// courante et on n'herite jamais des cles decodees d'une requete passee.
$this->decodedContent = null;
$this->decodedPayloadKeys = [];
$writableKeys = $this->writablePayloadKeys(); $writableKeys = $this->writablePayloadKeys();
$isArchiveRequest = $this->guardArchive($data, $writableKeys); $isArchiveRequest = $this->guardArchive($data, $writableKeys);
@@ -136,7 +126,7 @@ final class SupplierProcessor implements ProcessorInterface
// normalisees des deux cotes (l'etat persiste l'a deja ete). // normalisees des deux cotes (l'etat persiste l'a deja ete).
$this->guardManage($data); $this->guardManage($data);
$this->validateAccountingCompleteness($data); $this->validateInformationCompleteness($data);
try { try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context); return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
@@ -266,23 +256,35 @@ final class SupplierProcessor implements ProcessorInterface
} }
/** /**
* spec-front M2 § Onglet Comptabilite : a la validation COMPLETE de l'onglet * RG-2.03 : si l'utilisateur porte le role metier Commerciale, TOUS les
* (les six champs obligatoires presents dans le payload le front les envoie * champs de l'onglet Information sont obligatoires sur POST comme sur TOUT
* toujours ensemble), chacun doit etre renseigne, sinon 422 par champ. On ne * PATCH independamment des champs reellement envoyes. Garantit qu'un
* declenche pas sur un PATCH ciblant un sous-ensemble de champs comptables : * fournisseur cree/edite par une Commerciale ne reste jamais avec un onglet
* ce n'est pas une validation d'onglet (edition ponctuelle preservee). bank / * Information incomplet. Pour les autres roles, ces champs restent optionnels.
* RIB restent geres par validatePaymentTypeConsistency sur l'entite (RG-2.07 / *
* RG-2.08). Miroir du ClientProcessor (M1). * 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.
*/ */
private function validateAccountingCompleteness(Supplier $data): void private function validateInformationCompleteness(Supplier $data): void
{ {
// Declenche uniquement si TOUS les champs requis sont presents dans le if ($this->currentUserIsCommerciale()) {
// payload (= soumission d'onglet, pas un PATCH partiel cible). $this->informationValidator->validate($data);
if ([] !== array_diff(self::ACCOUNTING_REQUIRED_FIELDS, $this->payloadKeys())) { }
return;
} }
$this->accountingValidator->validate($data); /**
* 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);
} }
/** /**
@@ -57,11 +57,6 @@ final class SupplierExportController
#[IsGranted('commercial.suppliers.view')] #[IsGranted('commercial.suppliers.view')]
public function __invoke(Request $request): Response public function __invoke(Request $request): Response
{ {
// Memes filtres d'archivage que la vue liste (SupplierProvider) pour que
// l'export reflete exactement ce que l'utilisateur voit a l'ecran :
// - includeArchived : inclut les archives en plus des actifs ;
// - archivedOnly : restreint aux seules archives (prioritaire, cf.
// createListQueryBuilder).
$includeArchived = $this->readBool($request->query->get('includeArchived')); $includeArchived = $this->readBool($request->query->get('includeArchived'));
$archivedOnly = $this->readBool($request->query->get('archivedOnly')); $archivedOnly = $this->readBool($request->query->get('archivedOnly'));
$search = $request->query->getString('search') ?: null; $search = $request->query->getString('search') ?: null;
@@ -234,7 +234,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
$this->addAddress($services, ['Chatellerault'], '86100', 'Châtellerault', '15 rue du Conseil', isDelivery: true); $this->addAddress($services, ['Chatellerault'], '86100', 'Châtellerault', '15 rue du Conseil', isDelivery: true);
} }
// === Onglet Information complet (exemple de fiche renseignee) === // === Onglet Information complet (RG-1.04) ===
[$holding, $isNew] = $this->ensureClient( [$holding, $isNew] = $this->ensureClient(
$manager, $manager,
companyName: 'Holding Premium Invest', companyName: 'Holding Premium Invest',
@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\DataFixtures; namespace App\Module\Commercial\Infrastructure\DataFixtures;
use App\Module\Commercial\Domain\Entity\Bank; use App\Module\Commercial\Domain\Entity\Bank;
use App\Module\Commercial\Domain\Entity\Country;
use App\Module\Commercial\Domain\Entity\PaymentDelay; use App\Module\Commercial\Domain\Entity\PaymentDelay;
use App\Module\Commercial\Domain\Entity\PaymentType; use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Commercial\Domain\Entity\TvaMode; use App\Module\Commercial\Domain\Entity\TvaMode;
@@ -15,11 +14,10 @@ use Doctrine\Persistence\ObjectManager;
/** /**
* Fixtures du module Commercial : re-seed des 4 referentiels comptables * Fixtures du module Commercial : re-seed des 4 referentiels comptables
* (tva_mode, payment_delay, payment_type, bank) seedes par la migration M1 * (tva_mode, payment_delay, payment_type, bank) seedes par la migration M1
* (Version20260601000000) + du referentiel pays (country) seede par la * (Version20260601000000).
* migration ERP-116 (Version20260609100000).
* *
* Pourquoi cette fixture EN PLUS du seed de la migration : ces tables sont des * Pourquoi cette fixture EN PLUS du seed de la migration : depuis ERP-54 ces
* entites managees par l'ORM, donc le purger Doctrine les * 4 tables sont des entites managees par l'ORM, donc le purger Doctrine les
* vide avant chaque `doctrine:fixtures:load`. Sans cette fixture, les * vide avant chaque `doctrine:fixtures:load`. Sans cette fixture, les
* referentiels seedes par la migration disparaitraient apres `make db-reset` * referentiels seedes par la migration disparaitraient apres `make db-reset`
* (0 ligne en dev/test) cassant les FK Client -> referentiels et les tests * (0 ligne en dev/test) cassant les FK Client -> referentiels et les tests
@@ -61,54 +59,15 @@ class CommercialReferentialFixtures extends Fixture
], ],
]; ];
/**
* Referentiel pays (ERP-116) : code ISO alpha-2 => [name, position].
* Doit rester aligne sur le seed de la migration Version20260609100000.
* Traite a part car Country porte `name` (et non `label`).
*
* @var array<string, array{string, int}>
*/
private const COUNTRIES = [
'FR' => ['France', 10],
'DE' => ['Allemagne', 20],
'BE' => ['Belgique', 30],
'ES' => ['Espagne', 40],
'IT' => ['Italie', 50],
'GB' => ['Royaume-Uni', 60],
'CH' => ['Suisse', 70],
];
public function load(ObjectManager $manager): void public function load(ObjectManager $manager): void
{ {
foreach (self::REFERENTIALS as $entityClass => $rows) { foreach (self::REFERENTIALS as $entityClass => $rows) {
$this->seedReferential($manager, $entityClass, $rows); $this->seedReferential($manager, $entityClass, $rows);
} }
$this->seedCountries($manager);
$manager->flush(); $manager->flush();
} }
/**
* Upsert idempotent du referentiel pays (lookup par code). Distinct de
* seedReferential car Country utilise setName au lieu de setLabel.
*/
private function seedCountries(ObjectManager $manager): void
{
$existingByCode = [];
foreach ($manager->getRepository(Country::class)->findAll() as $country) {
$existingByCode[$country->getCode()] = $country;
}
foreach (self::COUNTRIES as $code => [$name, $position]) {
$country = $existingByCode[$code] ?? new Country();
$country->setCode($code);
$country->setName($name);
$country->setPosition($position);
$manager->persist($country);
}
}
/** /**
* Upsert idempotent d'un referentiel : indexe l'existant par code puis * Upsert idempotent d'un referentiel : indexe l'existant par code puis
* cree/met a jour chaque entree. Les 4 entites partagent le meme contrat * cree/met a jour chaque entree. Les 4 entites partagent le meme contrat
@@ -82,12 +82,6 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
*/ */
class SupplierFixtures extends Fixture implements DependentFixtureInterface class SupplierFixtures extends Fixture implements DependentFixtureInterface
{ {
/**
* Type de categorie exige pour un fournisseur et ses adresses (RG-2.10).
* Miroir de Supplier::REQUIRED_CATEGORY_TYPE_CODE (non importable regle n°1).
*/
private const string SUPPLIER_CATEGORY_TYPE_CODE = 'FOURNISSEUR';
/** Cache des categories resolues par nom (evite des requetes repetees). */ /** Cache des categories resolues par nom (evite des requetes repetees). */
private array $categoryCache = []; private array $categoryCache = [];
@@ -421,30 +415,21 @@ class SupplierFixtures extends Fixture implements DependentFixtureInterface
return $this->categoryCache[$name]; return $this->categoryCache[$name];
} }
// RG-2.10 : on garde la categorie des qu'elle PORTE le type FOURNISSEUR $category = $manager->getRepository(CategoryInterface::class)->findOneBy([
// (multi-type depuis le passage en ManyToMany). Le nom etant desormais
// unique GLOBALEMENT parmi les actifs, le lookup par `name` renvoie au
// plus une categorie, mais on conserve la verification du type pour
// ecarter un homonyme qui ne porterait pas FOURNISSEUR. Le filtre type
// est porte cote PHP (findBy ne sait pas filtrer la collection categoryTypes).
$candidates = $manager->getRepository(CategoryInterface::class)->findBy([
'name' => $name, 'name' => $name,
'deletedAt' => null, 'deletedAt' => null,
]); ]);
foreach ($candidates as $candidate) { if (!$category instanceof CategoryInterface) {
if ($candidate instanceof CategoryInterface
&& in_array(self::SUPPLIER_CATEGORY_TYPE_CODE, $candidate->getCategoryTypeCodes(), true)) {
return $this->categoryCache[$name] = $candidate;
}
}
throw new RuntimeException(sprintf( throw new RuntimeException(sprintf(
'Categorie FOURNISSEUR "%s" introuvable : CategoryFixtures doit tourner avant SupplierFixtures.', 'Categorie "%s" introuvable : CategoryFixtures doit tourner avant SupplierFixtures.',
$name, $name,
)); ));
} }
return $this->categoryCache[$name] = $category;
}
/** /**
* Resout un site par son nom via le contrat Shared SiteProviderInterface, * Resout un site par son nom via le contrat Shared SiteProviderInterface,
* sans importer le module Sites (regle n°1). Mise en cache par nom. * sans importer le module Sites (regle n°1). Mise en cache par nom.
@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\Doctrine;
use App\Module\Commercial\Domain\Entity\Country;
use App\Module\Commercial\Domain\Repository\CountryRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Country>
*/
class DoctrineCountryRepository extends ServiceEntityRepository implements CountryRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Country::class);
}
public function findById(int $id): ?Country
{
return $this->find($id);
}
public function findAllOrdered(): array
{
return $this->createQueryBuilder('c')
->orderBy('c.position', 'ASC')
->addOrderBy('c.name', 'ASC')
->getQuery()
->getResult()
;
}
}
@@ -36,8 +36,8 @@ final class RbacSeeder
{ {
/** /**
* Codes des roles metier (snake_case, regex Role respectee). `commerciale` * Codes des roles metier (snake_case, regex Role respectee). `commerciale`
* reference la constante Shared pour eviter tout drift : un seul litteral * reference la constante Shared deja consommee par le ClientProcessor
* pour ce code. * (RG-1.04) pour eviter tout drift : un seul litteral pour ce code.
*/ */
public const string ROLE_BUREAU = 'bureau'; public const string ROLE_BUREAU = 'bureau';
public const string ROLE_COMPTA = 'compta'; public const string ROLE_COMPTA = 'compta';
+2 -2
View File
@@ -344,8 +344,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Busines
/** /**
* Implemente BusinessRoleAwareInterface : vrai si l'un des roles RBAC * Implemente BusinessRoleAwareInterface : vrai si l'un des roles RBAC
* rattaches porte le code donne. Permet aux modules tiers de detecter un * rattaches porte le code donne. Permet aux modules tiers de detecter un
* role metier (ex: `commerciale`) sans importer cette classe. Comparaison * role metier (ex: `commerciale` pour RG-1.04 du M1 Clients) sans importer
* stricte sur Role::code. * cette classe. Comparaison stricte sur Role::code.
*/ */
public function hasBusinessRole(string $roleCode): bool public function hasBusinessRole(string $roleCode): bool
{ {

Some files were not shown because too many files have changed in this diff Show More