Compare commits

...

7 Commits

Author SHA1 Message Date
Matthieu 6ceef62056 feat(technique) : module Technique + taxonomie categories prestataires
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m13s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m27s
Cree le nouveau module Technique (pole distinct du Commercial) prerequis du
M3 repertoire prestataires :
- TechniqueModule (ID=technique, REQUIRED=false) + 5 permissions RBAC
  technique.providers.* (view / manage / accounting.view / accounting.manage
  / archive), declarees pour app:sync-permissions.
- Activation dans config/modules.php + layer front frontend/modules/technique/.
- Seed taxonomie : nouveau CategoryType PRESTATAIRE + 3 categories
  (Maintenance industrielle, Nettoyage, Transport) via migration idempotente
  (ON CONFLICT / NOT EXISTS, jonction M2M category_category_type) ET fixtures
  CategoryType/Category (survivent au purger db-reset).
- Tests : structure du module (5 permissions figees) + filtre
  GET /api/categories?typeCode=PRESTATAIRE.

Inclut la spec back/front M3 et le RETEX M1.
2026-06-12 09:23:08 +02:00
gitea-actions 7d8a633eee chore: bump version to v0.1.109
Auto Tag Develop / tag (push) Successful in 7s
2026-06-11 15:10:30 +00:00
tristan df9451a5f4 fix(commercial) : champ Fonction du contact sur 2 colonnes (ERP-147) (#88)
Auto Tag Develop / tag (push) Successful in 8s
ERP-147 — Le champ « Fonction » (jobTitle) du bloc contact passe sur 2 colonnes, côté Client (M1) et Fournisseur (M2).

## Changements
- `ClientContactBlock.vue` — champ Fonction enrobé dans `<div class="col-span-2">`
- `SupplierContactBlock.vue` — idem côté fournisseur

## Détail technique
Le wrapper `col-span-2` est nécessaire car `MalioInputText` (`inheritAttrs:false`) renvoie `class` sur son input interne et non sur la cellule de grille — même pattern que `ClientAddressBlock.vue`.

## Vérification
- `eslint` OK sur les 2 fichiers
- Rendu à valider visuellement sur les écrans Ajouter/Modifier client et fournisseur

Reviewed-on: #88
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-11 15:10:21 +00:00
gitea-actions cb12490ba0 chore: bump version to v0.1.108
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 43s
2026-06-11 10:05:53 +00:00
tristan a442d124a3 fix(commercial) : conserver le RIB au changement de type de règlement hors-LCR (ERP-121) (#86)
Auto Tag Develop / tag (push) Successful in 11s
## Contexte — ERP-121

Le passage d'un tiers de **LCR** vers **virement** (ou autre) supprimait ses RIB en base : au changement de type de règlement, le front marquait les `ClientRib` / `SupplierRib` existants pour suppression puis envoyait des `DELETE`. Le métier veut **conserver** le RIB (coordonnée bancaire du tiers, découplée du mode de règlement) pour un éventuel retour en LCR.

## Décisions métier (validées)

1. **Affichage hors-LCR** : RIB **totalement masqué**, ré-affiché au retour LCR — jamais supprimé en base.
2. **RGPD / IBAN** : conservation telle quelle, hors-scope de ce ticket.
3. **Données déjà perdues** : acceptable, le fix ne vaut que pour l'avenir.

## Modifications (100% frontend — clients **et** fournisseurs)

- `new.vue` / `[id]/edit.vue` : `onPaymentTypeChange` ne marque plus les RIB pour suppression et ne jette plus la saisie ; ils sont seulement masqués (`visibleRibs`) et réapparaissent au retour LCR.
- `submitAccounting` ne (re)soumet les RIB que **sous LCR** ; seules les suppressions **explicites** (corbeille d'un bloc) restent en `DELETE`.
- Consultation `[id]/index.vue` : RIB dormants masqués hors-LCR via le helper pur type-safe `paymentTypeCodeOf` (+ tests Vitest).

## Back

**Aucune modification** : la seule règle est `LCR → ≥1 RIB` (RG-1.13 / RG-2.08) ; rien n'interdit un RIB sur un tiers non-LCR. Le guard `Client/SupplierRibProcessor` (refus de supprimer le dernier RIB sous LCR) reste inchangé. **Pas de migration.**

## Vérifications

-  Vitest : **384/384** (`make nuxt-test`)
-  ESLint : clean sur les 10 fichiers
- ⏭️ PHPUnit non lancé : aucun fichier back modifié

Reviewed-on: #86
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-11 10:05:40 +00:00
gitea-actions 431d831c8b chore: bump version to v0.1.107
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 41s
2026-06-11 08:09:47 +00:00
matthieu 3f356f0679 feat(commercial) : referentiel pays (country) en base + branchement front (ERP-116) (#79)
Auto Tag Develop / tag (push) Successful in 9s
## Objectif (ERP-116, 1re iteration minimale)

Sortir la liste des pays du **code en dur** cote front et la poser en base comme **referentiel `country`**, source unique du select pays. **Perimetre volontairement minimal** : code ISO + libelle + ordre uniquement — **aucune longueur bancaire/fiscale** (numero de compte, IBAN, TVA, BIC, SIREN) a ce stade.

## Backend
- Entite `Country` (`code` ISO 3166-1 alpha-2 unique, `name`, `position`), calquee sur `Bank` : referentiel statique **lecture seule** (`GetCollection` + `Get`), gating `commercial.clients.view OR commercial.suppliers.view`.
- Migration `Version20260609100000` : table `country` + `COMMENT ON COLUMN` + seed des **6 pays** (France, Allemagne, Belgique, Espagne, Italie, Royaume-Uni), `ON CONFLICT DO NOTHING`.
- `CommercialReferentialFixtures` : re-seed des pays en dev/test.
- Garde-fous : ajout au `ColumnCommentsCatalog` + whitelist `EntitiesAreTimestampableBlamableTest`.

## Frontend
- `useClientReferentials` charge `/countries` (value = **nom** du pays : l'adresse stocke `country` en chaine libre, **pas de FK ni migration de donnees**).
- Les 3 listes `countryOptions` en dur (clients new / edit / consultation) sont supprimees ; la consultation derive ses options de l'embed.

## Hors-scope (iterations suivantes du ticket)
- Longueurs bancaires/fiscales par pays + validation associee.
- FK `country_id` sur les adresses + migration de donnees.

## Tests
- Back : suite complete verte (583), tests API dedies countries (200/seed/405/403/401).
- Front : Vitest vert (256), spec `useClientReferentials` mise a jour.
- Migration appliquee en dev + test.

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #79
2026-06-11 08:09:38 +00:00
35 changed files with 2564 additions and 195 deletions
+2
View File
@@ -5,10 +5,12 @@ use App\Module\Catalog\CatalogModule;
use App\Module\Commercial\CommercialModule;
use App\Module\Core\CoreModule;
use App\Module\Sites\SitesModule;
use App\Module\Technique\TechniqueModule;
return [
CoreModule::class,
CommercialModule::class,
SitesModule::class,
CatalogModule::class,
TechniqueModule::class,
];
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.106'
app.version: '0.1.109'
File diff suppressed because it is too large Load Diff
+339
View File
@@ -0,0 +1,339 @@
---
# === IDENTITÉ ===
module: M3
nom: "Répertoire prestataires"
ecran: repertoire-prestataires
owner_spec: Matthieu
backup_spec: Tristan
version: V0.2
date_redaction: 2026-06-11
# Historique :
# V0.2 (2026-06-11) — Restitution Markdown du docx « M3-reportoire-prestataires.docx » (04/06/2026).
# Alignement refonte-contact (comme M1/M2) : le contact principal inline du formulaire principal
# du PDF V0.1 (Nom contact / Prénom contact / Téléphone + / Email) est RETIRÉ — saisie via
# l'onglet Contacts uniquement (décision Matthieu, 11/06 : « oublie le contact inline, comme client »).
# RG-3.01 / RG-3.02 (contact inline + max 2 tél sur le formulaire principal) supprimées en conséquence.
# V0.1 (PDF) — version fonctionnelle plus ancienne, NON retenue (contact inline sur le formulaire principal).
# === LIENS ===
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-42090&p=f&m=dev"
regles_metier: [RG-3.03, RG-3.04, RG-3.05, RG-3.06, RG-3.07, RG-3.08, RG-3.09, RG-3.10, RG-3.11, RG-3.12, RG-3.13, RG-3.14, RG-3.15, RG-3.16, RG-3.17]
roles: [Admin, Bureau, Compta, Commerciale, Usine]
lien_spec_back: ./spec-back.md
# === VALIDATION CLIENT ===
client_validation_1:
statut: validee
date: 2026-05-22
version: V0
valide_par: "Matthieu (CP MALIO)"
client_validation_2:
statut: validee
date: 2026-06-01
version: V0.1
valide_par: "Matthieu (CP MALIO)"
client_validation_3:
statut: a_valider
date: 2026-06-04
version: V0.2
resume: "Module 3 — Répertoire prestataires. Pôle Technique (nouvelle section sidebar). Datatable + 3 écrans (Ajouter / Consulter / Modifier). Création par onglets : Contact / Adresse / Comptabilité (Rapports, Échanges = placeholders 'À venir'). PAS d'onglet Information. Sélecteur de site aussi sur le formulaire principal."
trace_archivee: "uploads/M3-reportoire-prestataires.docx (V0.2) + M3-reportoire-prestataires-V01.pdf (V0.1, obsolète)"
# === LIEN LESSTIME ===
lesstime_taskgroup_id: 29 # M3 — Répertoire prestataires (projet STARSEED #6)
lesstime_project_id: 6
statut_global: en_dev
---
# Module 3 — Répertoire prestataires (V0.2 front)
> **Origine** : spec fonctionnelle `M3-reportoire-prestataires.docx` (V0.2 du 04/06/2026 ; historique V0 22/05 → V0.1 01/06). Restitution Markdown pour intégration au workflow MALIO. Le contenu fonctionnel original n'est pas modifié, **sauf** l'alignement refonte-contact (cf. ci-dessous). Toute décision technique (back) vit dans [`spec-back.md`](./spec-back.md). Le M3 réutilise massivement le pattern et les composants posés au [M1 clients](../M1-clients/spec-front.md) et au [M2 fournisseurs](../M2-suppliers/spec-front.md).
> **⚠️ Alignement refonte-contact (décision Matthieu, 11/06/2026)** : le PDF V0.1 portait un **contact principal inline** sur le formulaire principal (Nom du contact / Prénom du contact / Téléphone + bouton + / Email) avec RG-3.01 (Nom OU Prénom) et RG-3.02 (max 2 téléphones). Ce contact inline est **retiré**, exactement comme l'a fait M1/M2 (refonte-contact). Les coordonnées du contact se saisissent **uniquement dans l'onglet Contacts**. **RG-3.01 et RG-3.02 sont donc supprimées du formulaire principal** ; la garantie « au moins un contact nommé » est portée par RG-3.04 + RG-3.12, et le « maximum 2 téléphones » s'applique aux blocs Contact.
> **⚠️ Décision d'architecture (à confirmer) — pôle « Technique »** : le docx place le répertoire prestataires dans un **Module « Technique »**. Confirmé par Matthieu (11/06) : c'est bien un **nouveau pôle Technique**, distinct du Commercial. Côté front cela se traduit par une **nouvelle section sidebar « Technique »** (route `/providers`). Côté back, voir [`spec-back.md § 2.1`](./spec-back.md) (nouveau module `Technique`, entités jumelles du fournisseur, référentiels comptables consommés en relation ORM partagée).
## But
Lister tous les prestataires de l'organisation et accéder rapidement à leurs fiches : consultation, création, modification, archivage. C'est la **porte d'entrée du pôle Technique**.
## Accès
- **Depuis** : menu principal → section **Technique** → entrée « Répertoire prestataires » (route `/providers`).
- **Rôles autorisés** (tableau « Rôles & permissions » du docx) :
| Rôle | Consultation | Création / Modification | Archivage |
|---|---|---|---|
| **Admin** | ✅ Tout | ✅ Tout | ✅ |
| **Bureau** | ✅ Tout | ✅ Tout sauf onglet Comptabilité | ❌ |
| **Compta** | ✅ Tout | ✅ Onglet Comptabilité uniquement | ❌ |
| **Commerciale** | ✅ Tout sauf Comptabilité | ✅ Tout sauf Comptabilité | ❌ |
| **Usine** | ✅ Son site uniquement | — | ❌ |
> **Notes** :
> - RBAC transposée sur `technique.providers.*` (cf. [`spec-back.md § 2.9 / § 5`](./spec-back.md)). Compta édite uniquement l'onglet Comptabilité d'un prestataire existant ; Compta ne peut pas **créer** un prestataire. **L'archivage est réservé à Admin**.
> - **Cloisonnement par site (décision 11/06 — DANS LE PÉRIMÈTRE M3)** : « Tout » vs « son site uniquement » n'est **pas porté par le rôle** mais par l'**utilisateur**. Chaque user a un site courant ; **par défaut il ne voit que les prestataires rattachés à son site**. Les profils qui doivent voir tous les sites (Admin, et par défaut Bureau / Compta / Commerciale) ont la permission `sites.bypass_scope` (Admin l'a automatiquement). **Usine** n'a pas le bypass → cloisonnée à son site. Filtrage **automatique côté back** (cf. [`spec-back.md § 2.13`](./spec-back.md)) — aucun filtre à coder côté front.
## Navigation
Page d'entrée du pôle **Technique** (route `/providers`). Titre : « **Répertoire prestataires** ».
- Affichage principal : un **datatable** listant tous les prestataires **actifs** (les archivés sont masqués par défaut — toggle/filtre dédié).
- **Clic sur une ligne** → écran **Consultation prestataire** (page dédiée).
- **Bouton « + Ajouter »** (haut droite) → écran **Ajouter un prestataire**.
- **Bouton « Filtrer »** (haut droite) → panneau de filtres (cf. ci-dessous).
- **Bouton « Exporter »** (haut droite) → télécharge un **XLSX** des prestataires **affichés** (cf. filtres actifs). Format dans [`spec-back.md § 4.6`](./spec-back.md).
### Panneau de filtres (bouton « Filtrer »)
Réutilise le pattern M1/M2. Filtres branchés sur les query params de `GET /api/providers` (cf. [`spec-back.md § 4.1`](./spec-back.md)) :
| Filtre | Composant | Query param back |
|---|---|---|
| **Recherche** (nom entreprise / contact / email) | `<MalioInputText>` | `?search=` |
| **Catégorie** | `<MalioSelectCheckbox>` (multi, type PRESTATAIRE) | `?categoryCode=` |
| **Site** | `<MalioSelectCheckbox>` (86 / 17 / 82) | `?siteId=` |
| **Inclure les archivés** | `<MalioCheckbox>` | `?includeArchived=true` |
- À l'application des filtres → `setFilters(...)` de `usePaginatedList` (retombe en **page 1**), qui relance `GET /api/providers`.
- **État 100 % local** (jamais dans l'URL — règle ABSOLUE n°6).
## Datatable du Répertoire
Composant : `<MalioDataTable>` branché sur `usePaginatedList<Provider>({ url: '/providers' })` (règle frontend obligatoire — pagination Hydra, état 100 % local). Colonnes (alignées M2) :
| Colonne | Source | Tri |
|---|---|---|
| **Nom** | `provider.companyName` | ASC par défaut |
| **Catégories** | `provider.categories[].name` (embarquées en liste — cohérence M1/M2 ; libellé = `name`, pas `label`) | Non |
| **Site** | `provider.sites[].name` (sites du prestataire — cf. note ci-dessous) | Non |
| **Dernière activité** | `provider.updatedAt` (format `JJ-MM-AAAA`) — exposé dans `provider:read` | Oui |
> **Source de la colonne « Site »** : le M3 porte un sélecteur de site **sur le formulaire principal** (RG-3.03) — donc `provider.sites[]` est une relation **directe** du prestataire (≠ M2 où les sites venaient de l'agrégat des adresses). La colonne liste affiche ces sites directs. Voir [`spec-back.md § 2.12`](./spec-back.md).
> **Clic sur une ligne** → écran Consultation. **Pagination** : standard Starseed 10 / 25 / 50 (défaut 10). Tri serveur `companyName ASC` par défaut.
## Écran « Ajouter un prestataire »
Création par **onglets successifs avec validation incrémentale** : pour passer à l'onglet suivant, il faut avoir validé l'onglet en cours. **Une fois un onglet validé, on passe automatiquement au suivant** ; les champs validés passent en lecture seule + bouton « Valider » désactivé (disabled). Cf. [`spec-back.md § 2.10`](./spec-back.md) (PATCH partiels par groupe de sérialisation).
**Accès** : bouton « + Ajouter » du Répertoire. **Rôles** : Admin, Bureau.
**Barre d'onglets en création (3 onglets)** : `Contact` · `Adresse` · `Comptabilité`. Les onglets `Rapports` et `Échanges` **n'apparaissent PAS dans le flux de création** — ils ne sont présents qu'en Consultation / Modification (placeholders « À venir »).
> **Différence majeure avec M2 : PAS d'onglet « Information ».** Le M3 n'a aucun champ Description / Concurrent / Date création / Salariés / CA / Dirigeant / Résultat / Volume. Le formulaire principal est minimal (3 champs).
> **Règle « placeholder par défaut » (convention MALIO)** : tout onglet ou écran que la spec ne détaille pas explicitement (ici **Rapports** et **Échanges**) est livré en **placeholder « À venir »** (frame vide, navigable, pas de validation ni d'API), à l'identique des autres modules (M1/M2). Aucun champ inventé hors spec.
### Formulaire principal (pré-onglets)
1er bloc à remplir. Sans validation, les onglets ne sont pas accessibles. Une fois validé → POST `/api/providers`, puis bascule sur l'onglet Contact ; les champs passent en readonly.
| Champ | Type composant | Obligatoire | Règle |
|---|---|---|---|
| **Nom du prestataire (Entreprise)** | `<MalioInputText>` | Oui | RG-3.11 (UPPERCASE serveur) ; RG-3.10 (unicité) |
| **Catégorie** | `<MalioSelectCheckbox>` (multi) | Oui | `Category` de **type PRESTATAIRE** via `GET /api/categories?typeCode=PRESTATAIRE` (RG-3.09). Libellé affiché = `category.name`. |
| **Sélecteur de site** | `<MalioSelectCheckbox>` (86 / 17 / 82) | Oui | RG-3.03 — ≥ 1 site. Les 3 cases = les 3 `Site` fixes ; libellés « 86/17/82 » = **préfixe du `postalCode`** (86100 / 17400 / 82400), pas un `Site.code` (qui n'existe pas). La sélection stocke des **IDs de Site** (M2M `provider_site`). |
**Action** : « Valider » (`<MalioButton>`) → POST `/api/providers` ([`spec-back.md § 4.3`](./spec-back.md)). Succès → onglet « Contact ».
### Onglet « Contact »
Saisir un ou plusieurs contacts. Au moins un bloc Contact valide est requis (RG-3.12). **(Refonte-contact : pas de pré-remplissage depuis le formulaire principal ; les coordonnées du contact se saisissent directement ici.)**
**Bloc Contact** :
| Champ | Type | Obligatoire | Règle |
|---|---|---|---|
| **Nom** | `<MalioInputText>` | Conditionnel | RG-3.04 + RG-3.11 (Capitalize) |
| **Prénom** | `<MalioInputText>` | Conditionnel | RG-3.04 + RG-3.11 (Capitalize) |
| **Fonction** | `<MalioInputText>` | Non | — |
| **Téléphone** (x1, +1 possible, **max 2**) | `<MalioInputText>` | Non | RG-3.11 (format) ; max 2 téléphones par contact |
| **Email** | `<MalioInputText>` type email | Non | RG-3.11 (lowercase) |
**RG-3.04 / RG-3.12** : un bloc Contact est valide dès qu'au moins 1 champ est rempli ; au moins 1 bloc Contact valide pour finaliser l'onglet — l'onglet Contact ne peut pas être validé vide.
**Actions** :
- « + Nouveau contact » : ajoute un bloc. **Désactivé tant que le bloc précédent n'a pas au moins 1 champ rempli** (RG-3.04).
- « Supprimer » (icône) : modal de confirmation, puis suppression du bloc.
- « Valider » → PATCH `/api/providers/{id}/contacts`.
### Onglet « Adresse »
Saisir une ou plusieurs adresses, rattachées à un ou plusieurs sites (86 / 17 / 82) et à des contacts.
**Bloc Adresse** :
| Champ | Type | Obligatoire | Règle |
|---|---|---|---|
| **Sélecteur de site** | `<MalioSelectCheckbox>` (86 / 17 / 82) | Oui | RG-3.05 — ≥ 1 site. Stocke des IDs de Site (M2M `provider_address_site`). |
| **Adresse** | `<MalioInputText>` (saisie assistée) | Oui | RG-3.06 — autocomplete BAN |
| **Adresse complémentaire** | `<MalioInputText>` | Non | — |
| **Code postal** | `<MalioInputText>` (saisie assistée) | Oui | RG-3.06 — déclenche autocomplete ville (BAN) |
| **Ville** | `<MalioSelect>` (saisie assistée) | Oui | RG-3.06 — alimentée par api-adresse.data.gouv.fr suivant le CP ; si plusieurs villes, choix dans le select |
| **Pays** | `<MalioSelect>` (préremplie « France ») | Oui | — |
| **Catégories** | `<MalioSelectCheckbox>` (multi) | Oui | Catégories de type PRESTATAIRE (RG-3.09) |
| **Contact** | `<MalioSelectCheckbox>` (multi) | Non | Liste = blocs Contact saisis dans l'onglet Contact |
> **Différence avec M2** : l'adresse prestataire n'a **PAS** de Type d'adresse (Prospect/Départ/Rendu), **PAS** de Bennes, **PAS** de Prestation de triage. C'est une adresse « simple » (site + adresse postale + catégories + contacts).
**Actions** :
- « + Nouvelle Adresse » : ajoute un bloc identique au premier.
- « Supprimer » (icône) : modal de confirmation puis suppression.
- « Valider » → PATCH `/api/providers/{id}/addresses`.
### Onglet « Comptabilité »
**Accessible aux rôles avec `technique.providers.accounting.view`** (Admin + Compta). Bureau et Commerciale ne voient pas l'onglet. **Compta peut éditer** cet onglet (`accounting.manage`). Compta ne peut pas créer un prestataire (pas de `manage` global).
**Champs comptables** :
| Champ | Type | Obligatoire | Règle |
|---|---|---|---|
| **SIREN** | `<MalioInputText>` (masque 9 chiffres) | Oui | 9 chiffres. **Pas d'unicité** (cf. [`spec-back.md § 2.6`](./spec-back.md)) |
| **Numéro de compte** | `<MalioInputText>` | Oui | — |
| **Mode de TVA** | `<MalioSelect>` | Oui | Liste depuis `/api/tva_modes` (référentiel partagé M1) |
| **N° de TVA** | `<MalioInputText>` | Oui | — |
| **Délai de règlement** | `<MalioSelect>` | Oui | Liste depuis `/api/payment_delays` |
| **Type de règlement** | `<MalioSelect>` | Oui | Liste depuis `/api/payment_types` |
| **Banque** | `<MalioSelect>` | Conditionnel | RG-3.07 — visible et obligatoire **si** Type de règlement = `VIREMENT`. Liste depuis `/api/banks` (SG / CIC / CA). |
**Bloc RIB** (0..n, présence obligatoire conditionnée par RG-3.08) :
| Champ | Type | Obligatoire | Règle |
|---|---|---|---|
| **Libellé** | `<MalioInputText>` | Oui (si LCR) | RG-3.08 |
| **BIC** | `<MalioInputText>` | Oui (si LCR) | RG-3.08 |
| **IBAN** | `<MalioInputText>` | Oui (si LCR) | RG-3.08 |
**Actions** :
- « + RIB » : ajoute un bloc.
- « Supprimer » (icône) : modal de confirmation.
- « Valider » → PATCH `/api/providers/{id}` (groupe `provider:write:accounting`) + sous-ressource RIBs.
## Écran « Consultation prestataire »
Tous les champs en **lecture seule**. La page s'ouvre par défaut sur l'onglet **Contacts**. Layout identique à l'écran Ajouter mais sans bouton « Valider », sans `+` pour ajouter des blocs.
- **Flèche retour** (gauche) → revient au Répertoire.
- **Bouton « Modifier »** (droite, visible si `technique.providers.manage`) → écran Modification.
- **Bouton « Archiver »** (droite, visible **uniquement Admin** via `technique.providers.archive`) → modal de confirmation, puis PATCH `/api/providers/{id}` `{ "isArchived": true }`.
> Un prestataire archivé peut être restauré (`isArchived: false`) — bouton « Restaurer » remplace « Archiver » dans la consultation d'un archivé.
### Onglets affichés en consultation
`Contacts` · `Adresse` · `Rapports` · `Échanges` · `Comptabilité`. Navigation **libre** entre onglets (pas de séquence forcée). `Rapports` et `Échanges` = placeholders « À venir ». `Comptabilité` selon permission.
- **Onglet Contacts** : un bloc par contact, 5 champs en lecture seule (Nom / Prénom / Fonction / Téléphone / Email).
- **Onglet Adresse** : un bloc par adresse, en lecture seule (Sélecteur de site / Adresse / Adresse complémentaire / Code postal / Ville / Pays / Catégorie / Contact).
- **Onglet Comptabilité** : bloc principal (champs comptables) + un bloc par RIB. Le champ **Banque** n'apparaît que si Type de règlement = Virement (RG-3.07).
## Écran « Modification prestataire »
Comportement identique à l'écran Ajouter (mêmes formulaires, mêmes RG-3.03 → RG-3.08) sauf :
- **Pas de formulaire principal** réaffiché (champs principaux édités via l'onglet correspondant / pré-remplis).
- Les champs sont **pré-remplis** avec les valeurs actuelles du prestataire.
- **Validation par onglet** : on peut modifier UN onglet sans toucher aux autres (PATCH partiel).
- Les onglets pour lesquels l'utilisateur n'a **pas** la permission `manage` (ou `accounting.manage`) restent en **lecture seule** (pas de bouton Valider, pas d'icône suppression).
- **Accès** : Admin, Bureau (Compta pour l'onglet Comptabilité uniquement).
## Composants UI à utiliser (`@malio/layer-ui`)
- **Datatable** : `<MalioDataTable>` (+ `usePaginatedList`)
- **Input texte** : `<MalioInputText>`
- **Select simple** : `<MalioSelect>` (Pays, Ville, référentiels comptables)
- **Select multi (cases à cocher)** : `<MalioSelectCheckbox>` (Catégorie, Sites, Contacts rattachés)
- **Bouton** : `<MalioButton>`, `<MalioButtonIcon>`
- **Toasts** : standards via `useApi()`
- **Validation par champ** : `useFormErrors` (mapping 422 inline — règle frontend obligatoire)
**Exceptions autorisées** (commenter `// TODO migrer quand Malio couvre`) :
- Modal de confirmation : `<MalioModal>` ou wrapper partagé dans `frontend/shared/` (réutiliser celui du M1/M2).
## Composables & appels API
- `usePaginatedList<Provider>({ url: '/providers' })` — liste paginée (obligatoire). La liste consomme `categories[]` (libellé = `name`) et `sites[]` (libellé = `name`, pas de `code`) **embarqués** + `updatedAt` (cf. [`spec-back.md § 2.12 / § 4.0`](./spec-back.md)).
- `useProvider(id)` — charge le détail via `GET /api/providers/{id}`, qui **embarque** `contacts`, `addresses` (avec `sites` / `categories` / `contacts` imbriqués) et, si permission, `ribs` + scalaires compta. Écrans Consultation et Modification peuplés depuis cette seule réponse (RETEX M1 §2 : embed borné, pas de N+1). **DoD avant intégration** : vérifier que le JSON réel contient ces blocs (cf. [`spec-back.md § 4.0.bis`](./spec-back.md)).
- `useProviderForm()` — workflow par onglet (POST principal + PATCH partiels par groupe), miroir de `useSupplierForm()`.
- `useAddressAutocomplete()`**réutilisé du M1/M2** (BAN), pas de réécriture.
- `usePermissions()` — masque l'onglet Comptabilité et le bouton Archiver.
- Tous les appels passent par `useApi()` (jamais `$fetch` direct — règle ABSOLUE n°4).
- Filter `formatPhoneFR()`**réutilisé** pour l'affichage `XX XX XX XX XX`.
## Règles de formatage et normalisation
Le serveur normalise systématiquement (RG-3.11 — cf. [`spec-back.md`](./spec-back.md)) :
| Champ | Normalisation serveur | Affichage front |
|---|---|---|
| Nom prestataire (`companyName`) | UPPERCASE intégral | UPPERCASE |
| Nom + Prénom contact | Capitalize | identique |
| Téléphones (blocs `ProviderContact`) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` (filter Vue) |
| Email | lowercase intégral | identique |
> Le front **ne normalise pas** : il envoie la valeur saisie, le serveur normalise et renvoie la valeur normalisée que l'UI affiche.
## API adresse postale
Code postal + Ville + Adresse branchés sur **api-adresse.data.gouv.fr** (BAN) via le composable `useAddressAutocomplete()` **déjà créé au M1/M2** (réutilisé tel quel) :
- À la saisie du CP (5 chiffres) : `GET https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville (RG-3.06 : si plusieurs villes, choix dans le select).
- À la saisie d'adresse : `?q={addr}&postcode={cp}&type=housenumber` → suggestions.
- Cas dégradé (timeout / offline) : Ville en `<MalioInputText>` libre + toast d'avertissement.
## Différences notables avec le M2 (fournisseurs)
| Zone | M2 fournisseurs | M3 prestataires |
|---|---|---|
| Onglet Information | 8 champs (Description … Volume) | **Absent** (aucun champ Information) |
| Sélecteur de site sur formulaire principal | Non (sites uniquement via adresses) | **Oui** (RG-3.03 — relation directe `provider.sites`) |
| Type d'adresse | Radio Prospect / Départ / Rendu (RG-2.09) | **Absent** |
| Bennes / Prestation de triage (adresse) | Présents | **Absents** |
| Onglet Transport | Placeholder | **Absent** |
| Onglet Statistiques | Placeholder | **Absent** |
| Onglets « À venir » | Transport / Stats / Rapports / Échanges | **Rapports / Échanges** uniquement |
| Catégories | type `FOURNISSEUR` | **nouveau type `PRESTATAIRE`** |
| Pôle / module | Commercial | **Technique** (nouvelle section sidebar + module back) |
| Cloisonnement par site | aucun | **Visibilité par site, pilotée par l'utilisateur** (bypass via `sites.bypass_scope`) — § 2.13 |
## Points résolus côté back
| # | Zone d'ombre | Résolution (cf. `spec-back.md`) |
|---|---|---|
| 1 | Catégorie multi-select | M2M `provider_category`, `Category` de type **PRESTATAIRE** (RG-3.09) |
| 2 | Site sur le formulaire principal | M2M `provider_site` (≥ 1 — RG-3.03), distinct de `provider_address_site` (RG-3.05) |
| 3 | Onglet Comptabilité : qui édite ? | Admin + Compta (`accounting.manage`) ; Bureau/Commerciale ne le voient pas |
| 4 | Workflow par onglet | Sauvegarde incrémentale (POST principal + PATCH partiels) — pas d'état « draft » |
| 5 | Onglets « À venir » | Placeholder minimal « À venir » (Rapports / Échanges) |
| 6 | Archive vs delete | Flag `is_archived` séparé de `deleted_at` ; archivage Admin seul ; soft delete = HP |
| 7 | Unicité métier | Nom de prestataire uniquement (à valider — § 2.6). SIREN/email non uniques |
| 8 | Référentiels comptables | Réutilisés M1/M2 (zéro duplication) ; relation ORM partagée |
| 9 | API code postal | BAN via `useAddressAutocomplete()` du M1/M2 (RG-3.06) |
| 10 | Format export | XLSX uniquement (CSV = HP) |
| 11 | Cloisonnement par site (Usine « son site ») | Filtre back automatique par `currentSite` + bypass `sites.bypass_scope` (§ 2.13 / RG-3.17) |
---
## 📦 Tickets Lesstime
**TaskGroup Lesstime** : **#29 — M3 — Répertoire prestataires** (projet `ERP / Starseed`, projectId=6) — créé le 11/06/2026, 16 tickets `ERP-131``ERP-146`, statut « Prêt à dev », assignés à **Tristan**.
| # | Ticket | Réf | Tag |
|---|---|---|---|
| 1.1 | Créer module Technique + taxonomie PRESTATAIRE | ERP-131 | Backend |
| 1.2 | Migrer le schéma BDD M3 (provider + sous-collections) | ERP-132 | Backend |
| 1.3 | Créer entités + repositories Provider* | ERP-133 | Backend |
| 1.4 | ProviderProvider + ProviderProcessor + cloisonnement site | ERP-134 | Backend |
| 1.5 | Sous-ressources Contacts / Adresses / RIBs | ERP-135 | Backend |
| 1.6 | Valider les RG métier server-side (RG-3.03→3.09) | ERP-136 | Backend |
| 1.7 | Export XLSX des prestataires | ERP-137 | Backend |
| 1.8 | RBAC technique.providers.* (3 sources) | ERP-138 | Backend |
| 1.9 | PHPUnit RG-3.x + capture contrat JSON | ERP-139 | Backend |
| 1.10 | Page Répertoire (/providers) | ERP-140 | Frontend |
| 1.11 | Page Ajouter (/providers/new) + formulaire principal | ERP-141 | Frontend |
| 1.12 | Onglet Contact | ERP-142 | Frontend |
| 1.13 | Onglet Adresse (autocomplete BAN) | ERP-143 | Frontend |
| 1.14 | Onglet Comptabilité + RIB | ERP-144 | Frontend |
| 1.15 | Pages Consultation + Modification | ERP-145 | Frontend |
| 1.16 | i18n + sidebar Technique + libellés audit | ERP-146 | Frontend |
> Détail back complet → voir [`spec-back.md § Tickets Lesstime`](./spec-back.md#-tickets-lesstime-à-découper).
+80
View File
@@ -0,0 +1,80 @@
# RETEX M1 (Clients) → à appliquer pour M2 (Fournisseurs)
> But : éviter de reproduire en M2 les erreurs de **contrat de sérialisation** qui ont bloqué M1.
> ~80 % des frictions M1 venaient du contrat API (sérialisation / groupes / sous-ressources), **pas** du métier.
> À lire AVANT de rédiger `spec-back.md` et `spec-front.md` du M2, et à garder ouvert pendant la rédaction.
---
## 0. TL;DR (les 3 erreurs à ne jamais refaire)
1. **Affirmer qu'un champ est « embarqué » sans vérifier les 3 maillons de sérialisation.** En M1 : `Category.code` annoncé dans `client:read`, détail annoncé embarquant contacts/adresses/ribs → **faux dans le code**. Résultat : colonnes liste vides, onglets détail impossibles à peupler.
2. **Livrer des sous-ressources en POST-only** (pas de `GetCollection`, pas d'embed) → le front ne peut pas lister les enfants de l'agrégat.
3. **Écrire la spec/les tickets sur une intention, pas sur le contrat réel.** Le docblock `Client` décrivait un embed jamais implémenté.
---
## 1. Contrat de sérialisation : les 3 maillons obligatoires
Pour **chaque champ affiché** (liste OU détail), la spec back doit prouver les trois maillons. Si un seul manque → le champ sort en quasi-IRI (`@id`/`@type` seulement) ou pas du tout.
| Maillon | Question | Exemple M1 raté |
|---|---|---|
| (a) Groupe sur la **propriété** | `#[Groups([...])]` contient-il un read-group ? | `Supplier::$addresses` sans groupe → jamais sérialisé |
| (b) Groupe dans le **`normalizationContext` de l'opération** | l'opération (`GetCollection`/`Get`) liste-t-elle ce groupe ? | `GetCollection` en `['client:read','default:read']` |
| (c) Read-group de l'**entité imbriquée** dans le contexte parent | pour embarquer les champs d'une relation (catégorie, site…), le contexte parent inclut-il `category:read` / `site:read` ? | `Category.code``category:read`, absent du contexte client → pas de `code` |
**Règle de rédaction** : dans `spec-back.md`, faire un tableau « champ → groupe propriété → groupe(s) à ajouter au contexte de chaque opération » pour la liste ET le détail. Inclure explicitement les **relations imbriquées** (ex. catégories d'une adresse, sites d'une adresse).
## 2. Collections enfant d'un agrégat : décider embed vs GetCollection, et câbler en ENTIER
Décision à acter dès la spec back pour chaque sous-collection (contacts, adresses, RIB, lignes…) :
- **Embed dans le détail (recommandé pour un agrégat DDD)** : poser `#[Groups(['<root>:item:read'])]` sur la propriété + ajouter au `normalizationContext` du `Get` racine les read-groups des entités enfant **et** de leurs relations imbriquées. 1 requête, cohérent avec un composable `useX(id)`. Réservé aux ensembles **bornés** (ne viole pas la règle n°13 : elle vise les collections exposées, pas un embed borné d'item).
- **GetCollection sous-ressource** : `/<root>/{id}/children` paginé. À réserver aux collections potentiellement volumineuses. Si choisi, **créer l'opération** (pas seulement POST).
❌ Anti-pattern M1 : sous-ressources avec `POST` + `Get` unitaire seulement → **aucun moyen de lister** (ids non découvrables). Interdit.
## 3. Vérifier le contrat sur l'API RÉELLE avant d'écrire les tickets front
Le blocage M1 (codes/sites/sous-collections) aurait été vu en 5 min. À mettre dans la **definition of done de la spec back** :
> Créer un enregistrement de test, appeler `GET /api/<resource>` (liste) ET `GET /api/<resource>/{id}` (détail), **coller la réponse JSON réelle** dans la spec. Toute donnée affichée par le front doit apparaître dans ce JSON collé.
## 4. La spec décrit le RÉEL, pas l'intention
- Bannir les « devrait être embarqué », « est exposé » non vérifiés. Décrire ce qui existe (ou ce qui sera livré dans le ticket, en le marquant clairement « à livrer »).
- Si un docblock/commentaire existant contredit le code, le **corriger**, pas le recopier.
## 5. Réutiliser les acquis M1 (ne pas réinventer)
- **Taxonomie ERP-78** : si M2 catégorise les fournisseurs, repartir du modèle **type unique + `code` stable** (slug MAJUSCULE auto-généré, NOT NULL, figé, **lecture seule** `category:read`), filtrage métier via `?categoryCode=`. Réutiliser le contrat partagé `CategoryInterface` (pas d'import inter-module).
- **Front** : `usePaginatedList` (listes), composants `Malio*`, `useApi()`, `formatPhoneFR`, blocs réutilisables (Contact/Adresse), pattern de blocs dynamiques + modal de confirmation.
- **Archive** : flag `is_archived` **distinct** de `deleted_at` (soft delete). Restauration → gérer le 409 homonyme.
- **Normalisation = serveur** (UPPERCASE nom société, Capitalize noms, lowercase email, téléphone en chiffres). Le front envoie la saisie, réaffiche la valeur normalisée renvoyée. À documenter dans la spec.
- **Gating fin + mode strict PATCH** : PATCH par groupe de sérialisation ; tout champ hors-permission dans le payload = **403 sur l'intégralité** (pas de filtrage silencieux). Spécifier la matrice rôle × onglet.
## 6. Règles ABSOLUES transverses à rappeler dans la spec M2
- **Pagination obligatoire** (règle n°13) sur toute `GetCollection` ; échappatoire `?pagination=false` réservée aux selects de référentiels bornés.
- **`COMMENT ON COLUMN`** (règle n°12) sur chaque colonne créée/modifiée (sinon `make test` casse). Helper standard pour les colonnes Timestampable/Blamable.
- **Timestampable + Blamable** sur toute nouvelle entité métier (4 colonnes + trait) ; garde-fou archi.
- **`#[Auditable]`** sur les entités métier ; **`#[AuditIgnore]`** sur les champs sensibles (équivalents BIC/IBAN/secret).
- **`declare(strict_types=1);`** partout ; commentaires FR, code EN.
- **Routes front à plat** (pas de préfixe module), état tableau **jamais** dans l'URL.
- **3 miroirs RBAC** à toucher ensemble : `config/sidebar.php`, `frontend/tests/e2e/_fixtures/personas.ts`, `SeedE2ECommand.php`.
- **Communication inter-module** uniquement via `Shared/Domain/Contract/` ou domain events — jamais d'import direct.
## 7. Fixtures & seed dès le départ
M1 a subi un aller-retour (ERP-68) faute de fixtures alignées. Pour M2 : prévoir dès la spec un seed de fournisseurs démo **couvrant tous les cas des règles métier** (relations, catégories codées, archivés, cas comptables) + comptes de rôles démo, pour vérifier le gating et le golden path sans bricolage.
## 8. Mini-checklist de relecture de la spec M2 (avant de la déclarer prête)
- [ ] Chaque champ affiché (liste + détail) a ses 3 maillons de sérialisation documentés (propriété, contexte opération, relations imbriquées).
- [ ] Chaque sous-collection a une décision **embed vs GetCollection** explicite et **complètement câblée** (pas de POST-only).
- [ ] Réponses JSON réelles (liste + détail) collées dans la spec back.
- [ ] Matrice RBAC rôle × écran × onglet + mode strict PATCH spécifiés.
- [ ] Pagination, COMMENT ON COLUMN, Timestampable/Blamable, Audit, routes à plat : rappelés.
- [ ] Réutilisations M1 identifiées (taxonomie code, usePaginatedList, blocs, archive, normalisation).
- [ ] Seed/fixtures démo planifiés.
@@ -26,13 +26,18 @@
:error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)"
/>
<MalioInputText
:model-value="model.jobTitle"
:label="t('commercial.clients.form.contact.jobTitle')"
:readonly="readonly"
:error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', 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.clients.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.clients.form.contact.email')"
@@ -25,13 +25,18 @@
:error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)"
/>
<MalioInputText
:model-value="model.jobTitle"
:label="t('commercial.suppliers.form.contact.jobTitle')"
:readonly="readonly"
:error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)"
/>
<!-- 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')"
@@ -30,6 +30,10 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
if (url === '/sites') {
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({
member: [{ '@id': '/api/x/1', code: 'X', label: 'Libelle X' }],
})
@@ -44,6 +48,8 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: '86' }])
expect(refs.tvaModes.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.
expect(refs.categories.value).toEqual([])
@@ -3,7 +3,7 @@ import { ref } from 'vue'
/**
* Charge les referentiels (listes courtes) alimentant les selects de l'ecran
* « Ajouter un client » : categories, sites, modes de TVA, delais et types de
* reglement, banques, et les listes distributeurs / courtiers.
* reglement, banques, pays, et les listes distributeurs / courtiers.
*
* Toutes les collections sont recuperees en entier via l'echappatoire prevue
* `?pagination=false` (referentiels de quelques dizaines d'entrees max), avec
@@ -57,6 +57,11 @@ interface ClientMember extends HydraMember {
companyName: string
}
interface CountryMember extends HydraMember {
code: string
name: string
}
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
export function useClientReferentials() {
@@ -68,6 +73,7 @@ export function useClientReferentials() {
const paymentDelays = ref<RefOption[]>([])
const paymentTypes = ref<PaymentTypeOption[]>([])
const banks = ref<RefOption[]>([])
const countries = ref<RefOption[]>([])
const distributors = ref<ClientOption[]>([])
const brokers = ref<ClientOption[]>([])
@@ -116,6 +122,12 @@ export function useClientReferentials() {
.then((types) => { paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code })) }),
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.
fetchAll<CountryMember>('/countries')
.then((list) => { countries.value = list.map(c => ({ value: c.name, label: c.name })) }),
])
}
@@ -144,6 +156,7 @@ export function useClientReferentials() {
paymentDelays,
paymentTypes,
banks,
countries,
distributors,
brokers,
loadCommon,
@@ -51,6 +51,11 @@ interface ReferentialMember extends HydraMember {
label: string
}
interface CountryMember extends HydraMember {
code: string
name: string
}
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
export function useSupplierReferentials() {
@@ -62,6 +67,7 @@ export function useSupplierReferentials() {
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>(
@@ -103,6 +109,13 @@ export function useSupplierReferentials() {
.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 })) }),
])
}
@@ -113,6 +126,7 @@ export function useSupplierReferentials() {
paymentDelays,
paymentTypes,
banks,
countries,
loadCommon,
}
}
@@ -553,10 +553,21 @@ const contactOptions = computed<RefOption[]>(() =>
})),
)
const countryOptions: RefOption[] = [
{ value: 'France', label: 'France' },
{ value: 'Espagne', label: 'Espagne' },
]
// Pays : referentiel `country` charge via l'API (ERP-116), en remplacement de
// l'ancienne liste codee en dur. Valeur = nom du pays (l'adresse stocke
// `country` en chaine libre, donc value === label). On merge la valeur deja
// stockee sur chaque adresse (embed) — comme les autres selects de cet ecran —
// pour ne pas vider le select si `/countries` echoue (resilience ERP-102) ou si
// un pays historique n'appartient pas au referentiel.
const embedCountryOptions = computed<RefOption[]>(() =>
mergeOptions([], (client.value?.addresses ?? [])
.map(a => a.country)
.filter((c): c is string => !!c)
.map(c => ({ value: c, label: c }))),
)
const countryOptions = computed<RefOption[]>(() =>
mergeOptions(referentials.countries.value, embedCountryOptions.value),
)
const relationOptions = computed<RefOption[]>(() => [
{ value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') },
@@ -901,17 +912,16 @@ const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
function onPaymentTypeChange(value: string | number | null): void {
accounting.paymentTypeIri = value === null ? null : String(value)
if (!isBankRequired.value) accounting.bankIri = null
// Les RIB n'ont de sens que pour une LCR (RG-1.13) : on amorce un bloc vide
// quand LCR est choisi, sinon on vide la liste — les RIB deja persistes sont
// marques pour suppression serveur au prochain enregistrement.
// 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 {
for (const rib of ribs.value) {
if (rib.id != null) removedRibIds.value.push(rib.id)
}
ribs.value = []
// Hors-LCR : on nettoie seulement les erreurs inline (plus affichees).
ribErrors.value = []
}
}
@@ -940,50 +950,58 @@ function askRemoveRib(index: number): void {
/**
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
* PATCH des scalaires (groupe client:write:accounting, exige accounting.manage cote
* back) PUIS DELETE des RIB retires. Les RIB crees d'abord : le back valide RG-1.13
* (LCR => au moins un RIB persiste) sur le PATCH scalaires ; les suppressions en
* dernier (le guard back n'autorise la suppression du dernier RIB qu'une fois quitte
* LCR). Aucun champ main/information dans le payload (mode strict RG-1.28 : sinon
* 403 sur tout le payload).
* back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le back
* 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).
*/
async function submitAccounting(): Promise<void> {
if (accountingReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
accountingErrors.clearErrors()
try {
// 1) POST/PATCH des RIB d'abord (erreurs inline par ligne, tous les blocs
// tentes). Le back exige >=1 RIB persiste pour valider une LCR a l'etape 2.
// 1) POST/PATCH des RIB d'abord — UNIQUEMENT sous LCR (erreurs inline par
// ligne, tous les blocs tentes). Le back exige >=1 RIB persiste pour valider
// une LCR a l'etape 2. Hors-LCR (ERP-121), les RIB sont des coordonnees
// dormantes : rien d'editable n'est affiche, on ne les re-soumet pas.
// On ne saute une amorce neuve vide QUE s'il reste un autre RIB soumettable :
// 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 sans propertyPath).
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 }>(
`/clients/${clientId}/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 => showError(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
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 }>(
`/clients/${clientId}/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 => showError(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 {
@@ -994,8 +1012,9 @@ async function submitAccounting(): Promise<void> {
return
}
// 3) DELETE des RIB retires : APRES le PATCH scalaires (si on quitte LCR, le
// guard back n'autorise la suppression du dernier RIB qu'une fois le type change).
// 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 })
}
@@ -280,7 +280,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useClient } from '~/modules/commercial/composables/useClient'
import { buildClientFormTabKeys } from '~/modules/commercial/utils/clientFormRules'
import { buildClientFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/clientFormRules'
import { readHistoryTab } from '~/shared/utils/historyTab'
import {
canEditClient,
@@ -290,6 +290,7 @@ import {
mapAddressView,
mapContactToDraft,
mapRibToDraft,
paymentTypeCodeOf,
referentialOptionOf,
relationOf,
showArchiveAction,
@@ -355,9 +356,16 @@ const addressViews = computed(() => {
return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }]
})
// Exception au placeholder ci-dessus : on n'affiche AUCUN bloc RIB quand le
// client n'en a pas (un RIB n'existe que pour un reglement LCR — RG-1.13). Pas
// de bloc vierge fantome en consultation.
const ribs = computed(() => (client.value?.ribs ?? []).map(mapRibToDraft))
// client n'en a pas. Pas de bloc vierge fantome en consultation.
// ERP-121 : un client 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 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).
const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as ClientDetail)))
@@ -384,10 +392,18 @@ const relationOptions = computed<SelectOption[]>(() => [
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') },
])
const countryOptions: SelectOption[] = [
{ value: 'France', label: 'France' },
{ value: 'Espagne', label: 'Espagne' },
]
// Pays (ERP-116) : options construites depuis l'EMBED des adresses (jamais via
// GET /countries, sur le meme principe que les autres selects de consultation
// — en 403 pour les roles metier non-admin). Valeur = nom du pays stocke tel
// quel dans l'adresse, donc value === label ; suffit a afficher le libelle en
// lecture seule.
const countryOptions = computed<SelectOption[]>(() =>
[...new Set(
(client.value?.addresses ?? [])
.map(a => a.country)
.filter((c): c is string => !!c),
)].map(c => ({ value: c, label: c })),
)
// Selects comptables : libelle issu de l'embed (option unique ou vide).
const tvaModeOptions = computed(() => referentialOptionOf(client.value?.tvaMode))
@@ -778,11 +778,17 @@ const contactOptions = computed<RefOption[]>(() =>
})),
)
// Pays disponibles (France preselectionnee par defaut sur chaque adresse).
const countryOptions: RefOption[] = [
{ value: 'France', label: 'France' },
{ value: 'Espagne', label: 'Espagne' },
]
// Pays disponibles : referentiel `country` charge via l'API (ERP-116), en
// remplacement de l'ancienne liste codee en dur. France reste preselectionnee
// par defaut sur chaque adresse (cf. valeur initiale du draft d'adresse) : on
// garantit donc sa presence en fallback si `/countries` echoue (resilience
// ERP-102), pour ne pas afficher un select vide sur une valeur deja soumise.
const countryOptions = computed<RefOption[]>(() => {
const list = referentials.countries.value
return list.some(c => c.value === 'France')
? list
: [{ value: 'France', label: 'France' }, ...list]
})
// « + Adresse » desactive tant que la derniere adresse n'est pas valide.
const canAddAddress = computed(() => {
@@ -875,13 +881,14 @@ 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-1.12).
if (!isBankRequired.value) accounting.bankIri = null
// Les RIB n'ont de sens que pour une LCR (RG-1.13) : on amorce un bloc vide
// quand LCR est choisi, on vide la liste sinon (pas de RIB fantome soumis).
// 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 {
ribs.value = []
ribErrors.value = []
}
}
@@ -918,36 +925,41 @@ async function submitAccounting(): Promise<void> {
tabSubmitting.value = true
accountingErrors.clearErrors()
try {
// 1) POST/PATCH des RIB d'abord (erreurs inline par ligne, tous les blocs
// tentes). Le back exige >=1 RIB persiste pour valider une LCR a l'etape 2.
// 1) POST/PATCH des RIB d'abord — UNIQUEMENT sous LCR (erreurs inline par
// 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.
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
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 {
@@ -515,10 +515,19 @@ const contactOptions = computed<RefOption[]>(() =>
})),
)
const countryOptions: RefOption[] = [
{ value: 'France', label: 'France' },
{ value: 'Espagne', label: 'Espagne' },
]
// 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)))
@@ -792,17 +801,16 @@ 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
// Les RIB n'ont de sens que pour une LCR (RG-2.08) : on amorce un bloc vide
// quand LCR est choisi, sinon on vide la liste — les RIB deja persistes sont
// marques pour suppression serveur au prochain enregistrement.
// 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 {
for (const rib of ribs.value) {
if (rib.id != null) removedRibIds.value.push(rib.id)
}
ribs.value = []
// Hors-LCR : on nettoie seulement les erreurs inline (plus affichees).
ribErrors.value = []
}
}
@@ -831,44 +839,53 @@ function askRemoveRib(index: number): void {
/**
* 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 retires. Les RIB crees d'abord : le back valide
* RG-2.08 (LCR => au moins un RIB persiste) sur le PATCH scalaires. Aucun champ
* main/information dans le payload (mode strict RG-2.16 : sinon 403 sur tout le payload).
* 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 (erreurs inline par ligne, tous les blocs
// tentes). On ne saute une amorce neuve vide QUE s'il reste un autre RIB
// 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).
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
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 {
@@ -879,8 +896,9 @@ async function submitAccounting(): Promise<void> {
return
}
// 3) DELETE des RIB retires : APRES le PATCH scalaires (si on quitte LCR, le
// guard back n'autorise la suppression du dernier RIB qu'une fois le type change).
// 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 })
}
@@ -263,7 +263,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useSupplier } from '~/modules/commercial/composables/useSupplier'
import { buildSupplierFormTabKeys } from '~/modules/commercial/utils/supplierFormRules'
import { buildSupplierFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/supplierFormRules'
import { readHistoryTab } from '~/shared/utils/historyTab'
import {
canEditSupplier,
@@ -274,6 +274,7 @@ import {
mapAddressView,
mapContactToDraft,
mapRibToDraft,
paymentTypeCodeOf,
referentialOptionOf,
showArchiveAction,
showRestoreAction,
@@ -338,8 +339,15 @@ const addressViews = computed(() => {
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 (un RIB n'existe que pour un reglement LCR — RG-2.08).
const ribs = computed(() => (supplier.value?.ribs ?? []).map(mapRibToDraft))
// 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)))
@@ -361,10 +369,16 @@ const allSiteOptions = computed<SelectOption[]>(() =>
})),
)
const countryOptions: SelectOption[] = [
{ value: 'France', label: 'France' },
{ value: 'Espagne', label: 'Espagne' },
]
// 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))
@@ -646,11 +646,15 @@ const contactOptions = computed<RefOption[]>(() =>
})),
)
// Pays disponibles (France preselectionnee par defaut sur chaque adresse).
const countryOptions: RefOption[] = [
{ value: 'France', label: 'France' },
{ value: 'Espagne', label: 'Espagne' },
]
// 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(() => {
@@ -741,13 +745,14 @@ function onPaymentTypeChange(value: string | number | null): void {
accounting.paymentTypeIri = value === null ? null : String(value)
// La banque n'a de sens que pour un virement : on la vide sinon (RG-2.07).
if (!isBankRequired.value) accounting.bankIri = null
// Les RIB n'ont de sens que pour une LCR (RG-2.08) : amorce un bloc vide quand
// LCR est choisi, vide la liste sinon (pas de RIB fantome soumis).
// 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 {
ribs.value = []
ribErrors.value = []
}
}
@@ -782,31 +787,36 @@ async function submitAccounting(): Promise<void> {
tabSubmitting.value = true
accountingErrors.clearErrors()
try {
// 1) POST/PATCH des RIB d'abord (erreurs inline par ligne). 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.
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
// 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 {
@@ -9,6 +9,7 @@ import {
mapAddressView,
mapContactToDraft,
mapRibToDraft,
paymentTypeCodeOf,
referentialOptionOf,
relationOf,
showArchiveAction,
@@ -233,3 +234,17 @@ describe('showArchiveAction / showRestoreAction', () => {
expect(showRestoreAction(can([]), true)).toBe(false)
})
})
describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', () => {
it('retourne le code metier quand le type de reglement est embarque', () => {
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1', code: 'LCR' })).toBe('LCR')
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/2', code: 'VIREMENT' })).toBe('VIREMENT')
})
it('retourne null pour un IRI nu, un objet sans code, ou une relation absente', () => {
expect(paymentTypeCodeOf('/api/payment_types/1')).toBeNull()
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1' })).toBeNull()
expect(paymentTypeCodeOf(null)).toBeNull()
expect(paymentTypeCodeOf(undefined)).toBeNull()
})
})
@@ -9,6 +9,7 @@ import {
mapAddressView,
mapContactToDraft,
mapRibToDraft,
paymentTypeCodeOf,
referentialOptionOf,
showArchiveAction,
showRestoreAction,
@@ -222,3 +223,17 @@ describe('showArchiveAction / showRestoreAction', () => {
expect(showRestoreAction(can([]), true)).toBe(false)
})
})
describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', () => {
it('retourne le code metier quand le type de reglement est embarque', () => {
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1', code: 'LCR' })).toBe('LCR')
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/2', code: 'VIREMENT' })).toBe('VIREMENT')
})
it('retourne null pour un IRI nu, un objet sans code, ou une relation absente', () => {
expect(paymentTypeCodeOf('/api/payment_types/1')).toBeNull()
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1' })).toBeNull()
expect(paymentTypeCodeOf(null)).toBeNull()
expect(paymentTypeCodeOf(undefined)).toBeNull()
})
})
@@ -293,6 +293,21 @@ export function referentialOptionOf(relation: Relation): SelectOption[] {
return [{ value: relation['@id'], label }]
}
/**
* 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 {
@@ -268,6 +268,21 @@ export function referentialOptionOf(relation: Relation): SelectOption[] {
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 {
@@ -0,0 +1 @@
export default defineNuxtConfig({})
+102
View File
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* ERP-116 — Referentiel Pays (Country), 1re iteration : creation de la table
* `country` + seed des 7 pays (France, Allemagne, Belgique, Espagne, Italie,
* Royaume-Uni, Suisse). Devient la source unique du select pays, en
* remplacement de la liste codee en dur cote front.
*
* Perimetre minimal voulu : code ISO 3166-1 alpha-2 + libelle FR + ordre
* d'affichage UNIQUEMENT. Aucune longueur bancaire/fiscale (numero de compte,
* IBAN, TVA, BIC, SIREN) a ce stade — iteration ulterieure du meme ticket.
*
* Pas de FK posee sur les adresses (client_address.country / supplier_address)
* a cette etape : ces colonnes restent des chaines libres (« France »...), donc
* aucune migration de donnees ni rupture de l'existant.
*
* Namespace racine `DoctrineMigrations` (regle ABSOLUE Starseed n°11) comme les
* migrations M1/M2 du module Commercial : pas de migrations_path modulaire
* configure pour Commercial, et le tri par timestamp reste garanti.
*
* Seed idempotent `ON CONFLICT (code) DO NOTHING` : la table peut deja porter
* des donnees en prod lors d'un rejeu. Chaque colonne porte un `COMMENT ON
* COLUMN` (regle ABSOLUE n°12, garde-fou ColumnsHaveSqlCommentTest) ; la table
* est aussi mirroree dans ColumnCommentsCatalog pour survivre au
* `schema:update --force` du setup de test.
*/
final class Version20260609100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-116 : table country (referentiel pays) + seed des 7 pays.';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE country (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
code VARCHAR(2) NOT NULL,
name VARCHAR(80) NOT NULL,
position INT DEFAULT 0 NOT NULL,
PRIMARY KEY (id)
)
SQL);
$this->addSql('CREATE UNIQUE INDEX uq_country_code ON country (code)');
$this->comment('country', '_table', 'Referentiel des pays selectionnables dans les adresses (clients/fournisseurs). Perimetre minimal : code ISO + libelle + ordre (pas de longueurs bancaires/fiscales).');
$this->comment('country', 'id', 'Identifiant interne auto-incremente.');
$this->comment('country', 'code', 'Code pays ISO 3166-1 alpha-2 (2 lettres MAJUSCULES, ex: FR) — unique (uq_country_code), fige a la creation.');
$this->comment('country', 'name', 'Libelle FR du pays (≤ 80 caracteres) — valeur stockee telle quelle dans les adresses (country en chaine libre a ce stade).');
$this->comment('country', 'position', 'Ordre d affichage croissant dans le selecteur pays (tri position ASC puis name ASC ; France en tete).');
// Seed initial. France en tete (position 10) puis ordre alphabetique.
// Table fraichement creee, mais ON CONFLICT pour rejouabilite en prod.
$this->addSql(<<<'SQL'
INSERT INTO country (code, name, position) VALUES
('FR', 'France', 10),
('DE', 'Allemagne', 20),
('BE', 'Belgique', 30),
('ES', 'Espagne', 40),
('IT', 'Italie', 50),
('GB', 'Royaume-Uni', 60),
('CH', 'Suisse', 70)
ON CONFLICT (code) DO NOTHING
SQL);
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE country');
}
/**
* Pose un `COMMENT ON TABLE` (colonne speciale `_table`) ou
* `COMMENT ON COLUMN`. Quoting defensif des identifiants + delimiteur $_$
* pour ne pas casser sur les apostrophes des descriptions.
*/
private function comment(string $table, string $column, string $description): void
{
$quotedTable = '"'.str_replace('"', '""', $table).'"';
if ('_table' === $column) {
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
return;
}
$this->addSql(sprintf(
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
$quotedTable,
'"'.str_replace('"', '""', $column).'"',
$description,
));
}
}
+121
View File
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* M3 (ticket 1.1) — Taxonomie PRESTATAIRE (module Catalog, prerequis du module Technique).
*
* Contexte : le M3 (repertoire prestataires) a besoin d'une taxonomie distincte
* des types CLIENT (M1) et FOURNISSEUR (M2). Decision Matthieu (11/06) : types
* distincts CLIENT / FOURNISSEUR / PRESTATAIRE, chacun avec sa taxonomie. Le
* multi-select « Categorie » du prestataire (formulaire principal + adresse)
* ne reference que des `Category` rattachees au type PRESTATAIRE (RG-3.09).
*
* Cette migration :
* 1. cree le `category_type` PRESTATAIRE (code PRESTATAIRE, label « Prestataire ») ;
* 2. seede 3 `Category` de demonstration rattachees a ce type via la jonction
* ManyToMany `category_category_type` (modele courant depuis Version20260608120000 ;
* la colonne ManyToOne `category.category_type_id` n'existe plus).
*
* Aucune colonne creee/modifiee -> pas de `COMMENT ON COLUMN` (regle ABSOLUE n°12) :
* la migration ne fait que des INSERT de donnees de reference.
*
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire :
* avec plusieurs migrations_paths, Doctrine Migrations 3.x trie par FQCN
* alphabetique -> une migration `App\Module\...` passerait avant les
* `DoctrineMigrations\...` sur base vide, donc avant la creation des tables
* `category` / `category_type` / `category_category_type`. Le namespace racine
* garantit l'ordre par timestamp.
*
* Idempotence : `INSERT ... ON CONFLICT (code) DO NOTHING` pour le type,
* `INSERT ... SELECT ... WHERE NOT EXISTS` pour chaque categorie et chaque ligne
* de jonction (aligne sur le pattern ERP-84 / Version20260605120000). En prod la
* table `category` est vide (aucune fixture metier). En dev/test, le purger
* Doctrine vide `category` / `category_type` avant les fixtures qui reproduisent
* le meme etat final (CategoryTypeFixtures / CategoryFixtures etendus a PRESTATAIRE).
*/
final class Version20260612080000 extends AbstractMigration
{
/**
* Categories de demonstration du type PRESTATAIRE : nom => code stable. Le
* code est la cle metier (slug MAJUSCULE du nom, miroir du
* CategoryCodeGenerator) et reste unique parmi les actifs (uq_category_code,
* partage avec les codes CLIENT / FOURNISSEUR — aucune collision ici). Le nom
* est unique GLOBALEMENT parmi les actifs (uq_category_name_active) : les
* libelles ci-dessous n'entrent en collision avec aucune categorie seedee.
*/
private const array PROVIDER_CATEGORIES = [
'Maintenance industrielle' => 'MAINTENANCE_INDUSTRIELLE',
'Nettoyage' => 'NETTOYAGE',
'Transport' => 'TRANSPORT',
];
public function getDescription(): string
{
return 'M3 1.1 : cree le CategoryType PRESTATAIRE + seed des categories prestataires (Maintenance industrielle, Nettoyage, Transport).';
}
public function up(Schema $schema): void
{
// 1. Type PRESTATAIRE (idempotent via l'index unique uq_category_type_code).
$this->addSql(<<<'SQL'
INSERT INTO category_type (code, label) VALUES ('PRESTATAIRE', 'Prestataire')
ON CONFLICT (code) DO NOTHING
SQL);
foreach (self::PROVIDER_CATEGORIES as $name => $code) {
// 2a. Categorie sous PRESTATAIRE (si le code est libre parmi les
// actifs). created_at/updated_at NOT NULL -> NOW() ; le blame
// reste null (seed hors contexte HTTP, libelle « Systeme » cote front).
$this->addSql(<<<'SQL'
INSERT INTO category (name, code, created_at, updated_at)
SELECT :name, :code, NOW(), NOW()
WHERE NOT EXISTS (
SELECT 1 FROM category c WHERE c.code = :code AND c.deleted_at IS NULL
)
SQL, ['name' => $name, 'code' => $code]);
// 2b. Jonction M2M categorie <-> type PRESTATAIRE (modele courant).
$this->addSql(<<<'SQL'
INSERT INTO category_category_type (category_id, category_type_id)
SELECT c.id, ct.id
FROM category c
CROSS JOIN category_type ct
WHERE c.code = :code AND c.deleted_at IS NULL
AND ct.code = 'PRESTATAIRE'
AND NOT EXISTS (
SELECT 1 FROM category_category_type cct
WHERE cct.category_id = c.id AND cct.category_type_id = ct.id
)
SQL, ['code' => $code]);
}
}
public function down(Schema $schema): void
{
// Best-effort : on retire d'abord les categories seedees (par code) — la FK
// category_category_type est ON DELETE CASCADE cote category, donc les
// lignes de jonction partent avec —, puis le type s'il n'est plus reference.
$this->addSql(
'DELETE FROM category WHERE code IN (:codes) '
."AND id IN (SELECT category_id FROM category_category_type cct "
."JOIN category_type ct ON ct.id = cct.category_type_id WHERE ct.code = 'PRESTATAIRE')",
['codes' => array_values(self::PROVIDER_CATEGORIES)],
['codes' => ArrayParameterType::STRING],
);
$this->addSql(<<<'SQL'
DELETE FROM category_type
WHERE code = 'PRESTATAIRE'
AND NOT EXISTS (
SELECT 1 FROM category_category_type cct WHERE cct.category_type_id = category_type.id
)
SQL);
}
}
@@ -17,7 +17,9 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
* Fixtures dev/test du module Catalog : categories de demonstration rattachees
* a leur CategoryType. Le type CLIENT porte ~11 categories clients (refonte
* taxonomie ERP-78) ; le type FOURNISSEUR porte les categories fournisseurs
* (ERP-84 : Negociant, Cooperative...). Chaque categorie porte un `code` stable.
* (ERP-84 : Negociant, Cooperative...) ; le type PRESTATAIRE porte les categories
* prestataires (M3 1.1 : Maintenance industrielle, Nettoyage, Transport). Chaque
* categorie porte un `code` stable.
* Alimente le repertoire clients (ClientFixtures, module Commercial) avec des
* donnees realistes couvrant RG-1.03 (codes DISTRIBUTEUR / COURTIER) et RG-1.29
* (codes interdits sur adresse), et le multi-select Categorie fournisseur (M2).
@@ -71,6 +73,11 @@ class CategoryFixtures extends Fixture implements DependentFixtureInterface
'Grossiste' => 'GROSSISTE',
'Importateur' => 'IMPORTATEUR',
],
'PRESTATAIRE' => [
'Maintenance industrielle' => 'MAINTENANCE_INDUSTRIELLE',
'Nettoyage' => 'NETTOYAGE',
'Transport' => 'TRANSPORT',
],
];
public function __construct(
@@ -21,6 +21,10 @@ use Doctrine\Persistence\ObjectManager;
* taxonomie distincte des fournisseurs (Negociant, Cooperative...). Mirroir de
* la migration Version20260605120000.
*
* M3 1.1 : ajout du type PRESTATAIRE (code PRESTATAIRE, label « Prestataire »),
* taxonomie distincte des prestataires (Maintenance industrielle, Nettoyage,
* Transport). Mirroir de la migration Version20260612080000.
*
* Pourquoi une fixture EN PLUS du seed de la migration : `category_type` est une
* entite managee par l ORM, donc le purger Doctrine la vide avant chaque
* `doctrine:fixtures:load`. Sans cette fixture, le type CLIENT seede par la
@@ -36,12 +40,13 @@ class CategoryTypeFixtures extends Fixture
{
/**
* Source unique des types : code technique => libelle FR. Doit rester aligne
* sur le seed des migrations Version20260602100000 (CLIENT) et
* Version20260605120000 (FOURNISSEUR).
* sur le seed des migrations Version20260602100000 (CLIENT),
* Version20260605120000 (FOURNISSEUR) et Version20260612080000 (PRESTATAIRE).
*/
private const TYPES = [
'CLIENT' => 'Client',
'FOURNISSEUR' => 'Fournisseur',
'PRESTATAIRE' => 'Prestataire',
];
public function __construct(
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineCountryRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Pays selectionnable dans les adresses (clients / fournisseurs) : referentiel
* statique seede par la migration (France, Allemagne, Belgique, Espagne, Italie,
* Royaume-Uni). Remplace la liste de pays jusqu'ici codee en dur cote front.
*
* Perimetre minimal (ticket ERP-116, 1re iteration) : code ISO + libelle + ordre
* d'affichage uniquement. AUCUNE longueur bancaire/fiscale (numero de compte,
* IBAN, TVA, BIC, SIREN) a ce stade — ces colonnes feront l'objet d'une iteration
* ulterieure du meme ticket.
*
* Lecture seule : GetCollection + Get uniquement ; POST/PATCH/DELETE -> 405.
* Permission alignee sur Bank (referentiel d'adresse partage clients/fournisseurs).
* Pas de Timestampable/Blamable (referentiel statique whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED, comme Bank).
*/
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
normalizationContext: ['groups' => ['country:read']],
// Tri par defaut : position ASC (France en tete) puis name ASC.
order: ['position' => 'ASC', 'name' => 'ASC'],
// Toggle ?pagination=false pour alimenter le select (cf. Bank).
paginationClientEnabled: true,
),
new Get(
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
normalizationContext: ['groups' => ['country:read']],
),
],
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
)]
#[ORM\Entity(repositoryClass: DoctrineCountryRepository::class)]
#[ORM\Table(name: 'country')]
#[ORM\UniqueConstraint(name: 'uq_country_code', columns: ['code'])]
class Country
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['country:read'])]
private ?int $id = null;
#[ORM\Column(length: 2)]
#[Groups(['country:read'])]
private ?string $code = null;
#[ORM\Column(length: 80)]
#[Groups(['country:read'])]
private ?string $name = null;
#[ORM\Column(options: ['default' => 0])]
#[Groups(['country:read'])]
private int $position = 0;
public function getId(): ?int
{
return $this->id;
}
public function getCode(): ?string
{
return $this->code;
}
public function setCode(string $code): static
{
$this->code = $code;
return $this;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Repository;
use App\Module\Commercial\Domain\Entity\Country;
interface CountryRepositoryInterface
{
public function findById(int $id): ?Country;
/**
* Retourne tous les pays tries position ASC puis name ASC.
*
* @return list<Country>
*/
public function findAllOrdered(): array;
}
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\DataFixtures;
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\PaymentType;
use App\Module\Commercial\Domain\Entity\TvaMode;
@@ -14,10 +15,11 @@ use Doctrine\Persistence\ObjectManager;
/**
* Fixtures du module Commercial : re-seed des 4 referentiels comptables
* (tva_mode, payment_delay, payment_type, bank) seedes par la migration M1
* (Version20260601000000).
* (Version20260601000000) + du referentiel pays (country) seede par la
* migration ERP-116 (Version20260609100000).
*
* Pourquoi cette fixture EN PLUS du seed de la migration : depuis ERP-54 ces
* 4 tables sont des entites managees par l'ORM, donc le purger Doctrine les
* Pourquoi cette fixture EN PLUS du seed de la migration : ces tables sont des
* entites managees par l'ORM, donc le purger Doctrine les
* vide avant chaque `doctrine:fixtures:load`. Sans cette fixture, les
* referentiels seedes par la migration disparaitraient apres `make db-reset`
* (0 ligne en dev/test) — cassant les FK Client -> referentiels et les tests
@@ -59,15 +61,54 @@ class CommercialReferentialFixtures extends Fixture
],
];
/**
* Referentiel pays (ERP-116) : code ISO alpha-2 => [name, position].
* Doit rester aligne sur le seed de la migration Version20260609100000.
* Traite a part car Country porte `name` (et non `label`).
*
* @var array<string, array{string, int}>
*/
private const COUNTRIES = [
'FR' => ['France', 10],
'DE' => ['Allemagne', 20],
'BE' => ['Belgique', 30],
'ES' => ['Espagne', 40],
'IT' => ['Italie', 50],
'GB' => ['Royaume-Uni', 60],
'CH' => ['Suisse', 70],
];
public function load(ObjectManager $manager): void
{
foreach (self::REFERENTIALS as $entityClass => $rows) {
$this->seedReferential($manager, $entityClass, $rows);
}
$this->seedCountries($manager);
$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
* cree/met a jour chaque entree. Les 4 entites partagent le meme contrat
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\Doctrine;
use App\Module\Commercial\Domain\Entity\Country;
use App\Module\Commercial\Domain\Repository\CountryRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Country>
*/
class DoctrineCountryRepository extends ServiceEntityRepository implements CountryRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Country::class);
}
public function findById(int $id): ?Country
{
return $this->find($id);
}
public function findAllOrdered(): array
{
return $this->createQueryBuilder('c')
->orderBy('c.position', 'ASC')
->addOrderBy('c.name', 'ASC')
->getQuery()
->getResult()
;
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique;
/**
* Module Technique (M3) — pole distinct du Commercial qui porte le repertoire
* prestataires (entites Provider* livrees par les tickets suivants du M3).
*
* Decision Matthieu (11/06/2026) : le repertoire prestataires vit dans un
* module a part entiere « Technique » (et non sous Commercial), conformement au
* docx source. Ce module est activable/desactivable comme les autres
* (cf. config/modules.php), non requis au boot.
*
* Au ticket 1.1, le module ne porte encore aucune entite : il declare seulement
* son identite et son jeu de permissions (cf. spec-back M3 § 2.1 + § 5.1). Le
* cablage de la section sidebar « Technique » et l'attribution des permissions
* aux roles interviennent avec l'ecran prestataires (tickets ulterieurs).
*/
final class TechniqueModule
{
public const string ID = 'technique';
public const string LABEL = 'Technique';
public const bool REQUIRED = false;
/**
* Liste declarative des permissions RBAC exposees par le module Technique.
*
* Consommee par la commande `app:sync-permissions` (SyncPermissionsCommand)
* qui se charge d'upserter ces entrees dans la table `permission`, de
* reactiver les codes precedemment marques orphelins et de marquer comme
* orphelins ceux qui ont disparu du code source.
*
* La cle `module` est auto-injectee par le sync command a partir de
* `self::ID`, il est donc inutile de la repeter dans chaque entree.
*
* Convention de nommage des codes : `module.resource[.sub].action` en
* snake_case, le prefixe module devant correspondre exactement a
* `self::ID` (verifie par la commande de synchronisation).
*
* Granularite alignee sur Commercial (les prestataires sont le jumeau des
* fournisseurs) : view + manage, plus deux permissions dediees a l'onglet
* Comptabilite et une a l'archivage (cf. spec-back M3 § 2.9 + § 5.1).
*
* @return array<int, array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'technique.providers.view', 'label' => 'Voir les prestataires'],
['code' => 'technique.providers.manage', 'label' => 'Créer / modifier les prestataires (hors onglet Comptabilité)'],
['code' => 'technique.providers.accounting.view', 'label' => 'Voir l\'onglet Comptabilité d\'un prestataire'],
['code' => 'technique.providers.accounting.manage', 'label' => 'Modifier l\'onglet Comptabilité d\'un prestataire'],
['code' => 'technique.providers.archive', 'label' => 'Archiver / restaurer un prestataire'],
];
}
}
@@ -170,6 +170,14 @@ final class ColumnCommentsCatalog
'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).',
],
'country' => [
'_table' => 'Referentiel des pays selectionnables dans les adresses (clients/fournisseurs). Perimetre minimal : code ISO + libelle + ordre (pas de longueurs bancaires/fiscales).',
'id' => 'Identifiant interne auto-incremente.',
'code' => 'Code pays ISO 3166-1 alpha-2 (2 lettres MAJUSCULES, ex: FR) — unique (uq_country_code), fige a la creation.',
'name' => 'Libelle FR du pays (≤ 80 caracteres) — valeur stockee telle quelle dans les adresses (country en chaine libre a ce stade).',
'position' => 'Ordre d affichage croissant dans le selecteur pays (tri position ASC puis name ASC ; France en tete).',
],
'client' => [
'_table' => 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).',
'id' => 'Identifiant interne auto-incremente.',
@@ -6,6 +6,7 @@ namespace App\Tests\Architecture;
use App\Module\Catalog\Domain\Entity\CategoryType;
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\PaymentType;
use App\Module\Commercial\Domain\Entity\TvaMode;
@@ -58,6 +59,8 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase
* CommercialReferentialFixtures, lecture seule au M1 (HP-M2-2). Pas de
* tracabilite user-driven, meme justification que CategoryType. Cf.
* spec-back M1 § 2.6 + § 3.5.
* - Country (ERP-116) : referentiel statique des pays (id/code/name/position),
* seede par migration, lecture seule. Meme justification que Bank.
*
* Les futurs referentiels statiques s'ajoutent ici avec une justification.
*/
@@ -71,6 +74,7 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase
PaymentDelay::class,
PaymentType::class,
Bank::class,
Country::class,
];
public function testAllBusinessEntitiesImplementBothInterfaces(): void
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
use App\Module\Catalog\Domain\Entity\CategoryType;
/**
* Tests du seed de la taxonomie PRESTATAIRE (M3 1.1) cote API.
*
* Le multi-select « Categorie » du prestataire (formulaire principal + adresse)
* consomme `GET /api/categories?typeCode=PRESTATAIRE`. Ce test prouve que :
* - le filtre `?typeCode=PRESTATAIRE` ne renvoie QUE les categories du type
* PRESTATAIRE (aucune fuite de categorie d'un autre type) ;
* - chaque membre renvoye porte bien le type PRESTATAIRE dans `categoryTypes`.
*
* NB : la base de test est purgee de toute categorie / type entre chaque test
* (cf. AbstractCatalogApiTestCase::cleanupCatalogTestData), donc le type et les
* categories PRESTATAIRE sont materialises ici (et non lus depuis le seed de la
* migration / fixture, qui ne survit pas a la purge). On valide ainsi le contrat
* du filtre sur le code reel `PRESTATAIRE`. La presence du seed apres un
* `make db-reset` reel est, elle, verifiee par l'idempotence des fixtures.
*
* @internal
*/
final class CategoryPrestataireSeedTest extends AbstractCatalogApiTestCase
{
/**
* Categories de demonstration seedees par la migration / fixture PRESTATAIRE.
*/
private const array PROVIDER_CATEGORIES = [
'Maintenance industrielle',
'Nettoyage',
'Transport',
];
public function testTypeCodePrestataireReturnsOnlyProviderCategories(): void
{
$providerType = $this->getOrCreatePrestataireType();
foreach (self::PROVIDER_CATEGORIES as $name) {
$this->createCategory($name, $providerType);
}
// Bruit : un type + une categorie d'un autre type ne doivent PAS fuiter.
$noiseType = $this->createCategoryType('TEST_FOURNISSEUR', 'Test Fournisseur');
$this->createCategory(self::TEST_CATEGORY_PREFIX.'noise', $noiseType);
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/categories?typeCode=PRESTATAIRE&pagination=false');
self::assertSame(200, $response->getStatusCode());
$members = $response->toArray()['member'];
$names = array_map(static fn (array $m): string => $m['name'], $members);
sort($names);
$expected = self::PROVIDER_CATEGORIES;
sort($expected);
self::assertSame(
$expected,
$names,
'Le filtre ?typeCode=PRESTATAIRE doit ne renvoyer QUE les categories du type PRESTATAIRE.',
);
// Chaque categorie remontee doit PORTER le type PRESTATAIRE.
foreach ($members as $member) {
self::assertContains('PRESTATAIRE', array_column($member['categoryTypes'], 'code'));
}
}
public function testTypeCodePrestataireKeepsHydraPagination(): void
{
$providerType = $this->getOrCreatePrestataireType();
$this->createCategory('Maintenance industrielle', $providerType);
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/categories?typeCode=PRESTATAIRE');
self::assertSame(200, $response->getStatusCode());
$data = $response->toArray();
self::assertArrayHasKey('totalItems', $data, 'Le filtre ne doit pas casser la pagination Hydra.');
self::assertArrayHasKey('member', $data);
foreach ($data['member'] as $member) {
self::assertContains('PRESTATAIRE', array_column($member['categoryTypes'], 'code'));
}
}
/**
* Recupere le type PRESTATAIRE reel, ou le cree s'il est absent. Le code
* `PRESTATAIRE` est seede par CategoryTypeFixtures (present en debut de suite),
* mais le cleanup purge tous les `category_type` entre les tests : selon
* l'ordre d'execution, le type peut donc exister ou non. Le get-or-create rend
* le test robuste sans dependre du seed ni le dupliquer.
*/
private function getOrCreatePrestataireType(): CategoryType
{
$em = $this->getEm();
$existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => 'PRESTATAIRE']);
if ($existing instanceof CategoryType) {
return $existing;
}
return $this->createCategoryType('PRESTATAIRE', 'Prestataire');
}
}
@@ -241,6 +241,72 @@ final class ReferentialApiTest extends AbstractCommercialApiTestCase
self::assertResponseStatusCodeSame(401);
}
/**
* Referentiel pays (ERP-116) — teste a part des 4 referentiels comptables
* car il expose `name` (et non `label`). Memes garanties : 200 + seed des 7
* pays, France en tete (position ASC), lecture seule (405), gating (403/401).
*/
public function testCountriesCollectionReturns200WithSeed(): void
{
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/countries?pagination=false', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
$members = $response->toArray()['member'];
$codes = array_map(static fn (array $m): string => $m['code'], $members);
foreach (['FR', 'DE', 'BE', 'ES', 'IT', 'GB', 'CH'] as $expected) {
self::assertContains($expected, $codes, '/api/countries doit exposer le pays seede '.$expected);
}
// Le DTO de lecture expose id / code / name / position.
$first = $members[0];
self::assertArrayHasKey('id', $first);
self::assertArrayHasKey('name', $first);
self::assertArrayHasKey('position', $first);
// Tri par defaut position ASC : France (position 10) en tete.
self::assertSame('FR', $first['code'], 'France (FR) doit etre en tete (position 10, tri position ASC).');
}
public function testCountriesGetItemReturns200(): void
{
$client = $this->createAdminClient();
$first = $client->request('GET', '/api/countries?pagination=false', ['headers' => ['Accept' => self::LD]])
->toArray()['member'][0]
;
$client->request('GET', '/api/countries/'.$first['id'], ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
}
public function testCountriesPostReturns405(): void
{
$client = $this->createAdminClient();
$client->request('POST', '/api/countries', [
'headers' => ['Content-Type' => self::LD],
'json' => ['code' => 'XX', 'name' => 'Pays X', 'position' => 1],
]);
self::assertResponseStatusCodeSame(405);
}
public function testCountriesForbiddenWithoutPermission(): void
{
$creds = $this->createUserWithPermission('core.users.view');
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$client->request('GET', '/api/countries', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(403);
}
public function testCountriesUnauthorizedWhenAnonymous(): void
{
$client = self::createClient();
$client->request('GET', '/api/countries', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(401);
}
/**
* @return iterable<string, array{string, list<string>}>
*/
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique;
use App\Module\Technique\TechniqueModule;
use PHPUnit\Framework\TestCase;
/**
* Tests structurels du module Technique (M3) : identite et contrat
* `permissions()`.
*
* @internal
*/
final class TechniqueModuleTest extends TestCase
{
public function testModuleIdentity(): void
{
self::assertSame('technique', TechniqueModule::ID);
self::assertSame('Technique', TechniqueModule::LABEL);
self::assertFalse(TechniqueModule::REQUIRED);
}
public function testPermissionsSetContainsExactlyFiveCodes(): void
{
// Garde-fou : le jeu de permissions du module est fige par ce test. Si
// quelqu'un ajoute / retire une permission sans ajuster la spec (§ 5.1)
// ni la matrice RBAC, le test casse explicitement.
$codes = array_column(TechniqueModule::permissions(), 'code');
sort($codes);
self::assertSame(
[
'technique.providers.accounting.manage',
'technique.providers.accounting.view',
'technique.providers.archive',
'technique.providers.manage',
'technique.providers.view',
],
$codes,
);
}
public function testEveryPermissionCodeIsPrefixedByModuleId(): void
{
// Convention de nommage `module.resource[.sub].action` : le prefixe doit
// correspondre exactement a l'ID du module (verifie aussi par
// app:sync-permissions).
foreach (TechniqueModule::permissions() as $permission) {
self::assertStringStartsWith(
TechniqueModule::ID.'.',
$permission['code'],
'Chaque code de permission doit etre prefixe par l\'ID du module.',
);
self::assertNotSame('', $permission['label'], 'Chaque permission doit porter un label.');
}
}
}