Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4ac772d1f | |||
| d97b9ce6d0 | |||
| b36520d3b1 | |||
| a340d8139a | |||
| 7d8a633eee | |||
| df9451a5f4 | |||
| cb12490ba0 | |||
| a442d124a3 |
@@ -79,6 +79,7 @@ Regles :
|
|||||||
- **Toujours `{ toast: false }`** sur l'appel API qui veut un mapping inline (sinon le toast natif d'`useApi` masque le fin).
|
- **Toujours `{ toast: false }`** sur l'appel API qui veut un mapping inline (sinon le toast natif d'`useApi` masque le fin).
|
||||||
- **Cas metier specifique** (ex: 409 doublon) : `setError('champ', message)` + toast explicite **avant** de deleguer le reste a `handleApiError`. Cf. `useCategoryForm` (doublon RG-1.07).
|
- **Cas metier specifique** (ex: 409 doublon) : `setError('champ', message)` + toast explicite **avant** de deleguer le reste a `handleApiError`. Cf. `useCategoryForm` (doublon RG-1.07).
|
||||||
- **Collections** (listes de sous-entites sauvees par un appel par ligne) : une erreur PAR LIGNE via un tableau `ref<Record<string, string>[]>` aligne sur l'index, peuple par `mapViolationsToRecord(error.response._data)` (util pur de `shared/utils/api.ts`). Le composant de ligne expose une prop `:errors` (`Record<string, string>`) bindee sur le `:error` de chaque champ. Cf. `ClientContactBlock` / `ClientAddressBlock` et les submits de `clients/new.vue` / `clients/[id]/edit.vue`.
|
- **Collections** (listes de sous-entites sauvees par un appel par ligne) : une erreur PAR LIGNE via un tableau `ref<Record<string, string>[]>` aligne sur l'index, peuple par `mapViolationsToRecord(error.response._data)` (util pur de `shared/utils/api.ts`). Le composant de ligne expose une prop `:errors` (`Record<string, string>`) bindee sur le `:error` de chaque champ. Cf. `ClientContactBlock` / `ClientAddressBlock` et les submits de `clients/new.vue` / `clients/[id]/edit.vue`.
|
||||||
|
- **Message back technique → surcharge i18n par code** : la plupart des contraintes back portent un message FR explicite (affiche tel quel). Mais une 422 peut porter un message TECHNIQUE non montrable (ex. erreur de type API Platform sur une date non parsable : « Cette valeur doit être de type DateTimeImmutable|null. », voire en anglais selon la negociation). On le surcharge **cote front** via le `code` de violation (UUID Symfony fige, robuste — pas un match sur le texte) : table `VIOLATION_MESSAGE_I18N` + `resolveViolationMessage` dans `shared/utils/api.ts`, appliquee par `useFormErrors`. Ajouter un cas = une entree `code -> cle i18n`. Cas reference : date invalide (MalioDate forwarde la saisie brute via `@update:rawValue`, le back renvoie 422 sur `foundedAt` grace a `collectDenormalizationErrors`, le front affiche `errors.validation.invalidDate`).
|
||||||
|
|
||||||
**Interdit** : se contenter d'un toast global sur une 422 quand le back identifie les champs fautifs (`propertyPath`). Reimplementer un mapping `if/else` par champ a la main au lieu d'`useFormErrors` / `mapViolationsToRecord`.
|
**Interdit** : se contenter d'un toast global sur une 422 quand le back identifie les champs fautifs (`propertyPath`). Reimplementer un mapping `if/else` par champ a la main au lieu d'`useFormErrors` / `mapViolationsToRecord`.
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ use App\Module\Catalog\CatalogModule;
|
|||||||
use App\Module\Commercial\CommercialModule;
|
use App\Module\Commercial\CommercialModule;
|
||||||
use App\Module\Core\CoreModule;
|
use App\Module\Core\CoreModule;
|
||||||
use App\Module\Sites\SitesModule;
|
use App\Module\Sites\SitesModule;
|
||||||
|
use App\Module\Technique\TechniqueModule;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
CoreModule::class,
|
CoreModule::class,
|
||||||
CommercialModule::class,
|
CommercialModule::class,
|
||||||
SitesModule::class,
|
SitesModule::class,
|
||||||
CatalogModule::class,
|
CatalogModule::class,
|
||||||
|
TechniqueModule::class,
|
||||||
];
|
];
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.107'
|
app.version: '0.1.111'
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -386,7 +386,10 @@
|
|||||||
},
|
},
|
||||||
"title": "Erreur",
|
"title": "Erreur",
|
||||||
"generic": "Une erreur est survenue.",
|
"generic": "Une erreur est survenue.",
|
||||||
"unknown": "Erreur inconnue."
|
"unknown": "Erreur inconnue.",
|
||||||
|
"validation": {
|
||||||
|
"invalidDate": "Date invalide"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"sites": {
|
"sites": {
|
||||||
"selector": {
|
"selector": {
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ import {
|
|||||||
addressTypeFromFlags,
|
addressTypeFromFlags,
|
||||||
isBillingEmailRequired,
|
isBillingEmailRequired,
|
||||||
type AddressType,
|
type AddressType,
|
||||||
} from '~/modules/commercial/utils/clientFormRules'
|
} from '~/modules/commercial/utils/forms/clientFormRules'
|
||||||
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
||||||
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials'
|
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials'
|
||||||
import type { AddressFormDraft } from '~/modules/commercial/types/clientForm'
|
import type { AddressFormDraft } from '~/modules/commercial/types/clientForm'
|
||||||
|
|||||||
@@ -26,13 +26,18 @@
|
|||||||
:error="errors?.firstName"
|
:error="errors?.firstName"
|
||||||
@update:model-value="(v: string) => update('firstName', v)"
|
@update:model-value="(v: string) => update('firstName', v)"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
|
||||||
:model-value="model.jobTitle"
|
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
|
||||||
:label="t('commercial.clients.form.contact.jobTitle')"
|
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
||||||
:readonly="readonly"
|
<div class="col-span-2">
|
||||||
:error="errors?.jobTitle"
|
<MalioInputText
|
||||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
:model-value="model.jobTitle"
|
||||||
/>
|
:label="t('commercial.clients.form.contact.jobTitle')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:error="errors?.jobTitle"
|
||||||
|
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<MalioInputEmail
|
<MalioInputEmail
|
||||||
:model-value="model.email"
|
:model-value="model.email"
|
||||||
:label="t('commercial.clients.form.contact.email')"
|
:label="t('commercial.clients.form.contact.email')"
|
||||||
|
|||||||
@@ -25,13 +25,18 @@
|
|||||||
:error="errors?.firstName"
|
:error="errors?.firstName"
|
||||||
@update:model-value="(v: string) => update('firstName', v)"
|
@update:model-value="(v: string) => update('firstName', v)"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
|
||||||
:model-value="model.jobTitle"
|
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
|
||||||
:label="t('commercial.suppliers.form.contact.jobTitle')"
|
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
||||||
:readonly="readonly"
|
<div class="col-span-2">
|
||||||
:error="errors?.jobTitle"
|
<MalioInputText
|
||||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
:model-value="model.jobTitle"
|
||||||
/>
|
:label="t('commercial.suppliers.form.contact.jobTitle')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:error="errors?.jobTitle"
|
||||||
|
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<MalioInputEmail
|
<MalioInputEmail
|
||||||
:model-value="model.email"
|
:model-value="model.email"
|
||||||
:label="t('commercial.suppliers.form.contact.email')"
|
:label="t('commercial.suppliers.form.contact.email')"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import type { ClientDetail } from '~/modules/commercial/utils/clientConsultation'
|
import type { ClientDetail } from '~/modules/commercial/utils/forms/clientConsultation'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chargement et actions d'archivage d'un client unique (ecran « Consultation
|
* Chargement et actions d'archivage d'un client unique (ecran « Consultation
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import type { SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
|
import type { SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chargement et actions d'archivage d'un fournisseur unique (ecran « Consultation
|
* Chargement et actions d'archivage d'un fournisseur unique (ecran « Consultation
|
||||||
|
|||||||
@@ -116,6 +116,7 @@
|
|||||||
:readonly="businessReadonly"
|
:readonly="businessReadonly"
|
||||||
:editable="true"
|
:editable="true"
|
||||||
:error="informationErrors.errors.foundedAt"
|
:error="informationErrors.errors.foundedAt"
|
||||||
|
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="information.employeesCount"
|
v-model="information.employeesCount"
|
||||||
@@ -401,7 +402,7 @@ import {
|
|||||||
mapAddressToDraft,
|
mapAddressToDraft,
|
||||||
mapRibToDraft,
|
mapRibToDraft,
|
||||||
type ClientDetail,
|
type ClientDetail,
|
||||||
} from '~/modules/commercial/utils/clientConsultation'
|
} from '~/modules/commercial/utils/forms/clientConsultation'
|
||||||
import {
|
import {
|
||||||
buildAccountingPayload,
|
buildAccountingPayload,
|
||||||
buildAddressPayload,
|
buildAddressPayload,
|
||||||
@@ -417,7 +418,7 @@ import {
|
|||||||
type ClientEditAbilities,
|
type ClientEditAbilities,
|
||||||
type InformationFormDraft,
|
type InformationFormDraft,
|
||||||
type MainFormDraft,
|
type MainFormDraft,
|
||||||
} from '~/modules/commercial/utils/clientEdit'
|
} from '~/modules/commercial/utils/forms/clientEdit'
|
||||||
import {
|
import {
|
||||||
buildClientFormTabKeys,
|
buildClientFormTabKeys,
|
||||||
isAddressValid,
|
isAddressValid,
|
||||||
@@ -429,7 +430,7 @@ import {
|
|||||||
isRibComplete,
|
isRibComplete,
|
||||||
isRibRequiredForPaymentType,
|
isRibRequiredForPaymentType,
|
||||||
showsRelationAndTriageFields,
|
showsRelationAndTriageFields,
|
||||||
} from '~/modules/commercial/utils/clientFormRules'
|
} from '~/modules/commercial/utils/forms/clientFormRules'
|
||||||
import {
|
import {
|
||||||
emptyAddress,
|
emptyAddress,
|
||||||
emptyContact,
|
emptyContact,
|
||||||
@@ -912,17 +913,16 @@ const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
|
|||||||
function onPaymentTypeChange(value: string | number | null): void {
|
function onPaymentTypeChange(value: string | number | null): void {
|
||||||
accounting.paymentTypeIri = value === null ? null : String(value)
|
accounting.paymentTypeIri = value === null ? null : String(value)
|
||||||
if (!isBankRequired.value) accounting.bankIri = null
|
if (!isBankRequired.value) accounting.bankIri = null
|
||||||
// Les RIB n'ont de sens que pour une LCR (RG-1.13) : on amorce un bloc vide
|
// ERP-121 : un RIB est une coordonnee bancaire du client, decouplee du mode de
|
||||||
// quand LCR est choisi, sinon on vide la liste — les RIB deja persistes sont
|
// reglement. Au passage hors-LCR on ne SUPPRIME plus les RIB existants : ils
|
||||||
// marques pour suppression serveur au prochain enregistrement.
|
// restent en base, simplement masques a l'ecran (visibleRibs = []), et
|
||||||
|
// reapparaissent tels quels si l'on repasse en LCR. Seule la corbeille d'un
|
||||||
|
// bloc (askRemoveRib) retire reellement un RIB.
|
||||||
if (isRibRequired.value) {
|
if (isRibRequired.value) {
|
||||||
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
for (const rib of ribs.value) {
|
// Hors-LCR : on nettoie seulement les erreurs inline (plus affichees).
|
||||||
if (rib.id != null) removedRibIds.value.push(rib.id)
|
|
||||||
}
|
|
||||||
ribs.value = []
|
|
||||||
ribErrors.value = []
|
ribErrors.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -951,50 +951,58 @@ function askRemoveRib(index: number): void {
|
|||||||
/**
|
/**
|
||||||
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
|
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
|
||||||
* PATCH des scalaires (groupe client:write:accounting, exige accounting.manage cote
|
* PATCH des scalaires (groupe client:write:accounting, exige accounting.manage cote
|
||||||
* back) PUIS DELETE des RIB retires. Les RIB crees d'abord : le back valide RG-1.13
|
* back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le back
|
||||||
* (LCR => au moins un RIB persiste) sur le PATCH scalaires ; les suppressions en
|
* valide RG-1.13 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
|
||||||
* dernier (le guard back n'autorise la suppression du dernier RIB qu'une fois quitte
|
*
|
||||||
* LCR). Aucun champ main/information dans le payload (mode strict RG-1.28 : sinon
|
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
|
||||||
* 403 sur tout le payload).
|
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
|
||||||
|
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES
|
||||||
|
* (corbeille d'un bloc, toujours sous LCR), plus l'auto-suppression au changement
|
||||||
|
* de type de reglement. Aucun champ main/information dans le payload (mode strict
|
||||||
|
* RG-1.28 : sinon 403 sur tout le payload).
|
||||||
*/
|
*/
|
||||||
async function submitAccounting(): Promise<void> {
|
async function submitAccounting(): Promise<void> {
|
||||||
if (accountingReadonly.value || tabSubmitting.value) return
|
if (accountingReadonly.value || tabSubmitting.value) return
|
||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
accountingErrors.clearErrors()
|
accountingErrors.clearErrors()
|
||||||
try {
|
try {
|
||||||
// 1) POST/PATCH des RIB d'abord (erreurs inline par ligne, tous les blocs
|
// 1) POST/PATCH des RIB d'abord — UNIQUEMENT sous LCR (erreurs inline par
|
||||||
// tentes). Le back exige >=1 RIB persiste pour valider une LCR a l'etape 2.
|
// ligne, tous les blocs tentes). Le back exige >=1 RIB persiste pour valider
|
||||||
|
// une LCR a l'etape 2. Hors-LCR (ERP-121), les RIB sont des coordonnees
|
||||||
|
// dormantes : rien d'editable n'est affiche, on ne les re-soumet pas.
|
||||||
// On ne saute une amorce neuve vide QUE s'il reste un autre RIB soumettable :
|
// On ne saute une amorce neuve vide QUE s'il reste un autre RIB soumettable :
|
||||||
// sinon (ex. l'unique RIB existant supprime, remplace par un bloc vide), on la
|
// sinon (ex. l'unique RIB existant supprime, remplace par un bloc vide), on la
|
||||||
// soumet pour declencher la 422 NotBlank inline plutot que de laisser le DELETE
|
// soumet pour declencher la 422 NotBlank inline plutot que de laisser le DELETE
|
||||||
// echouer en « dernier RIB d'une LCR » (message plat sans propertyPath).
|
// echouer en « dernier RIB d'une LCR » (message plat sans propertyPath).
|
||||||
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
|
if (isRibRequired.value) {
|
||||||
const ribHasError = await submitRows(
|
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
|
||||||
ribs.value,
|
const ribHasError = await submitRows(
|
||||||
ribErrors,
|
ribs.value,
|
||||||
async (rib) => {
|
ribErrors,
|
||||||
// Edition d'un RIB existant : champ requis vide envoye en `''` (NotBlank
|
async (rib) => {
|
||||||
// 422) au lieu d'etre omis (sinon le PATCH garderait l'ancienne valeur).
|
// Edition d'un RIB existant : champ requis vide envoye en `''` (NotBlank
|
||||||
const body = buildRibPayload(rib, { forUpdate: rib.id !== null })
|
// 422) au lieu d'etre omis (sinon le PATCH garderait l'ancienne valeur).
|
||||||
if (rib.id === null) {
|
const body = buildRibPayload(rib, { forUpdate: rib.id !== null })
|
||||||
const created = await api.post<{ id: number }>(
|
if (rib.id === null) {
|
||||||
`/clients/${clientId}/ribs`,
|
const created = await api.post<{ id: number }>(
|
||||||
body,
|
`/clients/${clientId}/ribs`,
|
||||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
body,
|
||||||
)
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
rib.id = created.id
|
)
|
||||||
}
|
rib.id = created.id
|
||||||
else {
|
}
|
||||||
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
|
else {
|
||||||
}
|
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
|
||||||
},
|
}
|
||||||
error => showError(error),
|
},
|
||||||
// On ne saute une amorce neuve (id null) totalement vide que si un autre RIB
|
error => showError(error),
|
||||||
// est soumettable. Un RIB existant vide est toujours soumis -> 422 NotBlank
|
// On ne saute une amorce neuve (id null) totalement vide que si un autre RIB
|
||||||
// inline (sinon la modif serait perdue en silence avec un faux toast succes).
|
// est soumettable. Un RIB existant vide est toujours soumis -> 422 NotBlank
|
||||||
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
|
// inline (sinon la modif serait perdue en silence avec un faux toast succes).
|
||||||
)
|
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
|
||||||
if (ribHasError) return
|
)
|
||||||
|
if (ribHasError) return
|
||||||
|
}
|
||||||
|
|
||||||
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
|
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
|
||||||
try {
|
try {
|
||||||
@@ -1005,8 +1013,9 @@ async function submitAccounting(): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) DELETE des RIB retires : APRES le PATCH scalaires (si on quitte LCR, le
|
// 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
|
||||||
// guard back n'autorise la suppression du dernier RIB qu'une fois le type change).
|
// PATCH scalaires (le guard back refuse la suppression du dernier RIB d'une
|
||||||
|
// LCR). ERP-121 : plus aucune suppression automatique au passage hors-LCR.
|
||||||
for (const id of removedRibIds.value) {
|
for (const id of removedRibIds.value) {
|
||||||
await api.delete(`/client_ribs/${id}`, {}, { toast: false })
|
await api.delete(`/client_ribs/${id}`, {}, { toast: false })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -280,7 +280,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useClient } from '~/modules/commercial/composables/useClient'
|
import { useClient } from '~/modules/commercial/composables/useClient'
|
||||||
import { buildClientFormTabKeys } from '~/modules/commercial/utils/clientFormRules'
|
import { buildClientFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/clientFormRules'
|
||||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||||
import {
|
import {
|
||||||
canEditClient,
|
canEditClient,
|
||||||
@@ -290,13 +290,14 @@ import {
|
|||||||
mapAddressView,
|
mapAddressView,
|
||||||
mapContactToDraft,
|
mapContactToDraft,
|
||||||
mapRibToDraft,
|
mapRibToDraft,
|
||||||
|
paymentTypeCodeOf,
|
||||||
referentialOptionOf,
|
referentialOptionOf,
|
||||||
relationOf,
|
relationOf,
|
||||||
showArchiveAction,
|
showArchiveAction,
|
||||||
showRestoreAction,
|
showRestoreAction,
|
||||||
type ClientDetail,
|
type ClientDetail,
|
||||||
type SelectOption,
|
type SelectOption,
|
||||||
} from '~/modules/commercial/utils/clientConsultation'
|
} from '~/modules/commercial/utils/forms/clientConsultation'
|
||||||
import { emptyAddress, emptyContact } from '~/modules/commercial/types/clientForm'
|
import { emptyAddress, emptyContact } from '~/modules/commercial/types/clientForm'
|
||||||
|
|
||||||
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
|
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
|
||||||
@@ -355,9 +356,16 @@ const addressViews = computed(() => {
|
|||||||
return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }]
|
return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }]
|
||||||
})
|
})
|
||||||
// Exception au placeholder ci-dessus : on n'affiche AUCUN bloc RIB quand le
|
// Exception au placeholder ci-dessus : on n'affiche AUCUN bloc RIB quand le
|
||||||
// client n'en a pas (un RIB n'existe que pour un reglement LCR — RG-1.13). Pas
|
// client n'en a pas. Pas de bloc vierge fantome en consultation.
|
||||||
// de bloc vierge fantome en consultation.
|
// ERP-121 : un client peut desormais conserver des RIB « dormants » apres etre
|
||||||
const ribs = computed(() => (client.value?.ribs ?? []).map(mapRibToDraft))
|
// repasse hors-LCR (on ne les supprime plus). En consultation, decision metier =
|
||||||
|
// on les masque TOTALEMENT : on n'affiche les RIB que si le type de reglement
|
||||||
|
// courant est LCR (le `code` est embarque sous client:read:accounting).
|
||||||
|
const ribs = computed(() =>
|
||||||
|
isRibRequiredForPaymentType(paymentTypeCodeOf(client.value?.paymentType))
|
||||||
|
? (client.value?.ribs ?? []).map(mapRibToDraft)
|
||||||
|
: [],
|
||||||
|
)
|
||||||
// Draft comptable (tout null si l'utilisateur n'a pas accounting.view).
|
// Draft comptable (tout null si l'utilisateur n'a pas accounting.view).
|
||||||
const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as ClientDetail)))
|
const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as ClientDetail)))
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,7 @@
|
|||||||
:readonly="isValidated('information')"
|
:readonly="isValidated('information')"
|
||||||
:editable="true"
|
:editable="true"
|
||||||
:error="informationErrors.errors.foundedAt"
|
:error="informationErrors.errors.foundedAt"
|
||||||
|
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="information.employeesCount"
|
v-model="information.employeesCount"
|
||||||
@@ -401,12 +402,12 @@ import {
|
|||||||
isRibRequiredForPaymentType,
|
isRibRequiredForPaymentType,
|
||||||
lastFillableTabKey,
|
lastFillableTabKey,
|
||||||
showsRelationAndTriageFields,
|
showsRelationAndTriageFields,
|
||||||
} from '~/modules/commercial/utils/clientFormRules'
|
} from '~/modules/commercial/utils/forms/clientFormRules'
|
||||||
import {
|
import {
|
||||||
buildAddressPayload,
|
buildAddressPayload,
|
||||||
buildMainPayload,
|
buildMainPayload,
|
||||||
buildRibPayload,
|
buildRibPayload,
|
||||||
} from '~/modules/commercial/utils/clientEdit'
|
} from '~/modules/commercial/utils/forms/clientEdit'
|
||||||
import {
|
import {
|
||||||
emptyAddress,
|
emptyAddress,
|
||||||
emptyContact,
|
emptyContact,
|
||||||
@@ -651,6 +652,8 @@ const information = reactive({
|
|||||||
description: null as string | null,
|
description: null as string | null,
|
||||||
competitors: null as string | null,
|
competitors: null as string | null,
|
||||||
foundedAt: null as string | null,
|
foundedAt: null as string | null,
|
||||||
|
// Saisie brute invalide remontee par MalioDate (cf. foundedAtRaw, MUI-44).
|
||||||
|
foundedAtRaw: '',
|
||||||
employeesCount: null as string | null,
|
employeesCount: null as string | null,
|
||||||
revenueAmount: null as string | null,
|
revenueAmount: null as string | null,
|
||||||
profitAmount: null as string | null,
|
profitAmount: null as string | null,
|
||||||
@@ -666,7 +669,8 @@ async function submitInformation(): Promise<void> {
|
|||||||
await api.patch(`/clients/${clientId.value}`, {
|
await api.patch(`/clients/${clientId.value}`, {
|
||||||
description: information.description || null,
|
description: information.description || null,
|
||||||
competitors: information.competitors || null,
|
competitors: information.competitors || null,
|
||||||
foundedAt: information.foundedAt || null,
|
// Saisie invalide prioritaire -> 422 back sur foundedAt (cf. foundedAtRaw).
|
||||||
|
foundedAt: information.foundedAtRaw || information.foundedAt || null,
|
||||||
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
|
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
|
||||||
revenueAmount: information.revenueAmount || null,
|
revenueAmount: information.revenueAmount || null,
|
||||||
profitAmount: information.profitAmount || null,
|
profitAmount: information.profitAmount || null,
|
||||||
@@ -881,13 +885,14 @@ function onPaymentTypeChange(value: string | number | null): void {
|
|||||||
accounting.paymentTypeIri = value === null ? null : String(value)
|
accounting.paymentTypeIri = value === null ? null : String(value)
|
||||||
// La banque n'a de sens que pour un virement : on la vide sinon (RG-1.12).
|
// La banque n'a de sens que pour un virement : on la vide sinon (RG-1.12).
|
||||||
if (!isBankRequired.value) accounting.bankIri = null
|
if (!isBankRequired.value) accounting.bankIri = null
|
||||||
// Les RIB n'ont de sens que pour une LCR (RG-1.13) : on amorce un bloc vide
|
// ERP-121 : on ne jette plus la saisie RIB au passage hors-LCR. Les blocs sont
|
||||||
// quand LCR est choisi, on vide la liste sinon (pas de RIB fantome soumis).
|
// masques (visibleRibs = []) mais conserves, et reapparaissent si l'on repasse
|
||||||
|
// en LCR. Ils ne sont persistes qu'a la validation SOUS LCR (cf. submitAccounting),
|
||||||
|
// donc une saisie abandonnee hors-LCR ne cree aucun RIB orphelin.
|
||||||
if (isRibRequired.value) {
|
if (isRibRequired.value) {
|
||||||
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
ribs.value = []
|
|
||||||
ribErrors.value = []
|
ribErrors.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -924,36 +929,41 @@ async function submitAccounting(): Promise<void> {
|
|||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
accountingErrors.clearErrors()
|
accountingErrors.clearErrors()
|
||||||
try {
|
try {
|
||||||
// 1) POST/PATCH des RIB d'abord (erreurs inline par ligne, tous les blocs
|
// 1) POST/PATCH des RIB d'abord — UNIQUEMENT sous LCR (erreurs inline par
|
||||||
// tentes). Le back exige >=1 RIB persiste pour valider une LCR a l'etape 2.
|
// ligne, tous les blocs tentes). Le back exige >=1 RIB persiste pour valider
|
||||||
|
// une LCR a l'etape 2. Hors-LCR (ERP-121), une saisie RIB eventuellement
|
||||||
|
// restee dans le brouillon est masquee et n'est PAS persistee (pas de RIB
|
||||||
|
// orphelin sur un client en virement).
|
||||||
// On ne saute une amorce neuve vide QUE s'il reste un autre RIB soumettable :
|
// On ne saute une amorce neuve vide QUE s'il reste un autre RIB soumettable :
|
||||||
// sinon (LCR sans aucun RIB rempli) on la soumet -> 422 NotBlank inline.
|
// sinon (LCR sans aucun RIB rempli) on la soumet -> 422 NotBlank inline.
|
||||||
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
|
if (isRibRequired.value) {
|
||||||
const ribHasError = await submitRows(
|
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
|
||||||
ribs.value,
|
const ribHasError = await submitRows(
|
||||||
ribErrors,
|
ribs.value,
|
||||||
async (rib) => {
|
ribErrors,
|
||||||
// Payload partage avec l'edition (buildRibPayload, ERP-119).
|
async (rib) => {
|
||||||
const body = buildRibPayload(rib)
|
// Payload partage avec l'edition (buildRibPayload, ERP-119).
|
||||||
if (rib.id === null) {
|
const body = buildRibPayload(rib)
|
||||||
const created = await api.post<{ id: number }>(
|
if (rib.id === null) {
|
||||||
`/clients/${clientId.value}/ribs`,
|
const created = await api.post<{ id: number }>(
|
||||||
body,
|
`/clients/${clientId.value}/ribs`,
|
||||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
body,
|
||||||
)
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
rib.id = created.id
|
)
|
||||||
}
|
rib.id = created.id
|
||||||
else {
|
}
|
||||||
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
|
else {
|
||||||
}
|
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
|
||||||
},
|
}
|
||||||
error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
|
},
|
||||||
// On ne saute une amorce neuve (id null) totalement vide que si un autre RIB
|
error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
|
||||||
// est soumettable. Un RIB existant vide est toujours soumis -> 422 NotBlank
|
// On ne saute une amorce neuve (id null) totalement vide que si un autre RIB
|
||||||
// inline (sinon la modif serait perdue en silence avec un faux toast succes).
|
// est soumettable. Un RIB existant vide est toujours soumis -> 422 NotBlank
|
||||||
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
|
// inline (sinon la modif serait perdue en silence avec un faux toast succes).
|
||||||
)
|
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
|
||||||
if (ribHasError) return
|
)
|
||||||
|
if (ribHasError) return
|
||||||
|
}
|
||||||
|
|
||||||
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
|
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -77,6 +77,7 @@
|
|||||||
:readonly="businessReadonly"
|
:readonly="businessReadonly"
|
||||||
:editable="true"
|
:editable="true"
|
||||||
:error="informationErrors.errors.foundedAt"
|
:error="informationErrors.errors.foundedAt"
|
||||||
|
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="information.employeesCount"
|
v-model="information.employeesCount"
|
||||||
@@ -370,7 +371,7 @@ import {
|
|||||||
mapAddressToDraft,
|
mapAddressToDraft,
|
||||||
mapRibToDraft,
|
mapRibToDraft,
|
||||||
type SupplierDetail,
|
type SupplierDetail,
|
||||||
} from '~/modules/commercial/utils/supplierConsultation'
|
} from '~/modules/commercial/utils/forms/supplierConsultation'
|
||||||
import {
|
import {
|
||||||
buildAccountingPayload,
|
buildAccountingPayload,
|
||||||
buildAddressPayload,
|
buildAddressPayload,
|
||||||
@@ -386,7 +387,7 @@ import {
|
|||||||
type InformationFormDraft,
|
type InformationFormDraft,
|
||||||
type MainFormDraft,
|
type MainFormDraft,
|
||||||
type SupplierEditAbilities,
|
type SupplierEditAbilities,
|
||||||
} from '~/modules/commercial/utils/supplierEdit'
|
} from '~/modules/commercial/utils/forms/supplierEdit'
|
||||||
import {
|
import {
|
||||||
buildSupplierFormTabKeys,
|
buildSupplierFormTabKeys,
|
||||||
isAddressValid,
|
isAddressValid,
|
||||||
@@ -396,7 +397,7 @@ import {
|
|||||||
isRibBlank,
|
isRibBlank,
|
||||||
isRibComplete,
|
isRibComplete,
|
||||||
isRibRequiredForPaymentType,
|
isRibRequiredForPaymentType,
|
||||||
} from '~/modules/commercial/utils/supplierFormRules'
|
} from '~/modules/commercial/utils/forms/supplierFormRules'
|
||||||
import {
|
import {
|
||||||
emptyAddress,
|
emptyAddress,
|
||||||
emptyContact,
|
emptyContact,
|
||||||
@@ -801,17 +802,16 @@ const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
|
|||||||
function onPaymentTypeChange(value: string | number | null): void {
|
function onPaymentTypeChange(value: string | number | null): void {
|
||||||
accounting.paymentTypeIri = value === null ? null : String(value)
|
accounting.paymentTypeIri = value === null ? null : String(value)
|
||||||
if (!isBankRequired.value) accounting.bankIri = null
|
if (!isBankRequired.value) accounting.bankIri = null
|
||||||
// Les RIB n'ont de sens que pour une LCR (RG-2.08) : on amorce un bloc vide
|
// ERP-121 : un RIB est une coordonnee bancaire du fournisseur, decouplee du mode
|
||||||
// quand LCR est choisi, sinon on vide la liste — les RIB deja persistes sont
|
// de reglement. Au passage hors-LCR on ne SUPPRIME plus les RIB existants : ils
|
||||||
// marques pour suppression serveur au prochain enregistrement.
|
// restent en base, simplement masques a l'ecran (visibleRibs = []), et
|
||||||
|
// reapparaissent tels quels si l'on repasse en LCR. Seule la corbeille d'un
|
||||||
|
// bloc (askRemoveRib) retire reellement un RIB.
|
||||||
if (isRibRequired.value) {
|
if (isRibRequired.value) {
|
||||||
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
for (const rib of ribs.value) {
|
// Hors-LCR : on nettoie seulement les erreurs inline (plus affichees).
|
||||||
if (rib.id != null) removedRibIds.value.push(rib.id)
|
|
||||||
}
|
|
||||||
ribs.value = []
|
|
||||||
ribErrors.value = []
|
ribErrors.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -840,44 +840,53 @@ function askRemoveRib(index: number): void {
|
|||||||
/**
|
/**
|
||||||
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
|
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
|
||||||
* PATCH des scalaires (groupe supplier:write:accounting, exige accounting.manage
|
* PATCH des scalaires (groupe supplier:write:accounting, exige accounting.manage
|
||||||
* cote back) PUIS DELETE des RIB retires. Les RIB crees d'abord : le back valide
|
* cote back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le
|
||||||
* RG-2.08 (LCR => au moins un RIB persiste) sur le PATCH scalaires. Aucun champ
|
* back valide RG-2.08 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
|
||||||
* main/information dans le payload (mode strict RG-2.16 : sinon 403 sur tout le payload).
|
*
|
||||||
|
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
|
||||||
|
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
|
||||||
|
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES
|
||||||
|
* (corbeille d'un bloc, toujours sous LCR). Aucun champ main/information dans le
|
||||||
|
* payload (mode strict RG-2.16 : sinon 403 sur tout le payload).
|
||||||
*/
|
*/
|
||||||
async function submitAccounting(): Promise<void> {
|
async function submitAccounting(): Promise<void> {
|
||||||
if (accountingReadonly.value || tabSubmitting.value) return
|
if (accountingReadonly.value || tabSubmitting.value) return
|
||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
accountingErrors.clearErrors()
|
accountingErrors.clearErrors()
|
||||||
try {
|
try {
|
||||||
// 1) POST/PATCH des RIB d'abord (erreurs inline par ligne, tous les blocs
|
// 1) POST/PATCH des RIB d'abord — UNIQUEMENT sous LCR (erreurs inline par
|
||||||
// tentes). On ne saute une amorce neuve vide QUE s'il reste un autre RIB
|
// ligne, tous les blocs tentes). Hors-LCR (ERP-121), les RIB sont des
|
||||||
|
// coordonnees dormantes : rien d'editable n'est affiche, on ne les re-soumet
|
||||||
|
// pas. On ne saute une amorce neuve vide QUE s'il reste un autre RIB
|
||||||
// soumettable : sinon (ex. l'unique RIB existant supprime, remplace par un
|
// soumettable : sinon (ex. l'unique RIB existant supprime, remplace par un
|
||||||
// bloc vide), on la soumet pour declencher la 422 NotBlank inline plutot que
|
// bloc vide), on la soumet pour declencher la 422 NotBlank inline plutot que
|
||||||
// de laisser le DELETE echouer en « dernier RIB d'une LCR » (message plat).
|
// de laisser le DELETE echouer en « dernier RIB d'une LCR » (message plat).
|
||||||
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
|
if (isRibRequired.value) {
|
||||||
const ribHasError = await submitRows(
|
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
|
||||||
ribs.value,
|
const ribHasError = await submitRows(
|
||||||
ribErrors,
|
ribs.value,
|
||||||
async (rib) => {
|
ribErrors,
|
||||||
// Edition d'un RIB existant : champ requis vide envoye en `''` (NotBlank
|
async (rib) => {
|
||||||
// 422) au lieu d'etre omis (sinon le PATCH garderait l'ancienne valeur).
|
// Edition d'un RIB existant : champ requis vide envoye en `''` (NotBlank
|
||||||
const body = buildRibPayload(rib, { forUpdate: rib.id !== null })
|
// 422) au lieu d'etre omis (sinon le PATCH garderait l'ancienne valeur).
|
||||||
if (rib.id === null) {
|
const body = buildRibPayload(rib, { forUpdate: rib.id !== null })
|
||||||
const created = await api.post<{ id: number }>(
|
if (rib.id === null) {
|
||||||
`/suppliers/${supplierId}/ribs`,
|
const created = await api.post<{ id: number }>(
|
||||||
body,
|
`/suppliers/${supplierId}/ribs`,
|
||||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
body,
|
||||||
)
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
rib.id = created.id
|
)
|
||||||
}
|
rib.id = created.id
|
||||||
else {
|
}
|
||||||
await api.patch(`/supplier_ribs/${rib.id}`, body, { toast: false })
|
else {
|
||||||
}
|
await api.patch(`/supplier_ribs/${rib.id}`, body, { toast: false })
|
||||||
},
|
}
|
||||||
error => showError(error),
|
},
|
||||||
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
|
error => showError(error),
|
||||||
)
|
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
|
||||||
if (ribHasError) return
|
)
|
||||||
|
if (ribHasError) return
|
||||||
|
}
|
||||||
|
|
||||||
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
|
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
|
||||||
try {
|
try {
|
||||||
@@ -888,8 +897,9 @@ async function submitAccounting(): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) DELETE des RIB retires : APRES le PATCH scalaires (si on quitte LCR, le
|
// 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
|
||||||
// guard back n'autorise la suppression du dernier RIB qu'une fois le type change).
|
// PATCH scalaires (le guard back refuse la suppression du dernier RIB d'une
|
||||||
|
// LCR). ERP-121 : plus aucune suppression automatique au passage hors-LCR.
|
||||||
for (const id of removedRibIds.value) {
|
for (const id of removedRibIds.value) {
|
||||||
await api.delete(`/supplier_ribs/${id}`, {}, { toast: false })
|
await api.delete(`/supplier_ribs/${id}`, {}, { toast: false })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -263,7 +263,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useSupplier } from '~/modules/commercial/composables/useSupplier'
|
import { useSupplier } from '~/modules/commercial/composables/useSupplier'
|
||||||
import { buildSupplierFormTabKeys } from '~/modules/commercial/utils/supplierFormRules'
|
import { buildSupplierFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/supplierFormRules'
|
||||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||||
import {
|
import {
|
||||||
canEditSupplier,
|
canEditSupplier,
|
||||||
@@ -274,12 +274,13 @@ import {
|
|||||||
mapAddressView,
|
mapAddressView,
|
||||||
mapContactToDraft,
|
mapContactToDraft,
|
||||||
mapRibToDraft,
|
mapRibToDraft,
|
||||||
|
paymentTypeCodeOf,
|
||||||
referentialOptionOf,
|
referentialOptionOf,
|
||||||
showArchiveAction,
|
showArchiveAction,
|
||||||
showRestoreAction,
|
showRestoreAction,
|
||||||
type SelectOption,
|
type SelectOption,
|
||||||
type SupplierDetail,
|
type SupplierDetail,
|
||||||
} from '~/modules/commercial/utils/supplierConsultation'
|
} from '~/modules/commercial/utils/forms/supplierConsultation'
|
||||||
import { emptyContact } from '~/modules/commercial/types/supplierForm'
|
import { emptyContact } from '~/modules/commercial/types/supplierForm'
|
||||||
|
|
||||||
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
|
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
|
||||||
@@ -338,8 +339,15 @@ const addressViews = computed(() => {
|
|||||||
return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }]
|
return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }]
|
||||||
})
|
})
|
||||||
// Exception au placeholder ci-dessus : on n'affiche AUCUN bloc RIB quand le
|
// Exception au placeholder ci-dessus : on n'affiche AUCUN bloc RIB quand le
|
||||||
// fournisseur n'en a pas (un RIB n'existe que pour un reglement LCR — RG-2.08).
|
// fournisseur n'en a pas. ERP-121 : un fournisseur peut desormais conserver des RIB
|
||||||
const ribs = computed(() => (supplier.value?.ribs ?? []).map(mapRibToDraft))
|
// « dormants » apres etre repasse hors-LCR (on ne les supprime plus). En consultation,
|
||||||
|
// decision metier = on les masque TOTALEMENT : on n'affiche les RIB que si le type de
|
||||||
|
// reglement courant est LCR (le `code` est embarque sous supplier:read:accounting).
|
||||||
|
const ribs = computed(() =>
|
||||||
|
isRibRequiredForPaymentType(paymentTypeCodeOf(supplier.value?.paymentType))
|
||||||
|
? (supplier.value?.ribs ?? []).map(mapRibToDraft)
|
||||||
|
: [],
|
||||||
|
)
|
||||||
// Draft comptable (tout null si l'utilisateur n'a pas accounting.view).
|
// Draft comptable (tout null si l'utilisateur n'a pas accounting.view).
|
||||||
const accounting = computed(() => mapAccountingDraft(supplier.value ?? ({} as SupplierDetail)))
|
const accounting = computed(() => mapAccountingDraft(supplier.value ?? ({} as SupplierDetail)))
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,7 @@
|
|||||||
:readonly="isValidated('information')"
|
:readonly="isValidated('information')"
|
||||||
:editable="true"
|
:editable="true"
|
||||||
:error="informationErrors.errors.foundedAt"
|
:error="informationErrors.errors.foundedAt"
|
||||||
|
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="information.employeesCount"
|
v-model="information.employeesCount"
|
||||||
@@ -361,7 +362,7 @@ import {
|
|||||||
isRibComplete,
|
isRibComplete,
|
||||||
isRibRequiredForPaymentType,
|
isRibRequiredForPaymentType,
|
||||||
lastFillableTabKey,
|
lastFillableTabKey,
|
||||||
} from '~/modules/commercial/utils/supplierFormRules'
|
} from '~/modules/commercial/utils/forms/supplierFormRules'
|
||||||
import {
|
import {
|
||||||
buildAccountingPayload,
|
buildAccountingPayload,
|
||||||
buildAddressPayload,
|
buildAddressPayload,
|
||||||
@@ -369,7 +370,7 @@ import {
|
|||||||
buildInformationPayload,
|
buildInformationPayload,
|
||||||
buildMainPayload,
|
buildMainPayload,
|
||||||
buildRibPayload,
|
buildRibPayload,
|
||||||
} from '~/modules/commercial/utils/supplierEdit'
|
} from '~/modules/commercial/utils/forms/supplierEdit'
|
||||||
import {
|
import {
|
||||||
emptyAddress,
|
emptyAddress,
|
||||||
emptyContact,
|
emptyContact,
|
||||||
@@ -549,6 +550,8 @@ const information = reactive({
|
|||||||
description: null as string | null,
|
description: null as string | null,
|
||||||
competitors: null as string | null,
|
competitors: null as string | null,
|
||||||
foundedAt: null as string | null,
|
foundedAt: null as string | null,
|
||||||
|
// Saisie brute invalide remontee par MalioDate (cf. foundedAtRaw, MUI-44).
|
||||||
|
foundedAtRaw: '',
|
||||||
employeesCount: null as string | null,
|
employeesCount: null as string | null,
|
||||||
revenueAmount: null as string | null,
|
revenueAmount: null as string | null,
|
||||||
profitAmount: null as string | null,
|
profitAmount: null as string | null,
|
||||||
@@ -745,13 +748,14 @@ function onPaymentTypeChange(value: string | number | null): void {
|
|||||||
accounting.paymentTypeIri = value === null ? null : String(value)
|
accounting.paymentTypeIri = value === null ? null : String(value)
|
||||||
// La banque n'a de sens que pour un virement : on la vide sinon (RG-2.07).
|
// La banque n'a de sens que pour un virement : on la vide sinon (RG-2.07).
|
||||||
if (!isBankRequired.value) accounting.bankIri = null
|
if (!isBankRequired.value) accounting.bankIri = null
|
||||||
// Les RIB n'ont de sens que pour une LCR (RG-2.08) : amorce un bloc vide quand
|
// ERP-121 : on ne jette plus la saisie RIB au passage hors-LCR. Les blocs sont
|
||||||
// LCR est choisi, vide la liste sinon (pas de RIB fantome soumis).
|
// masques (visibleRibs = []) mais conserves, et reapparaissent si l'on repasse
|
||||||
|
// en LCR. Ils ne sont persistes qu'a la validation SOUS LCR (cf. submitAccounting),
|
||||||
|
// donc une saisie abandonnee hors-LCR ne cree aucun RIB orphelin.
|
||||||
if (isRibRequired.value) {
|
if (isRibRequired.value) {
|
||||||
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
ribs.value = []
|
|
||||||
ribErrors.value = []
|
ribErrors.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -786,31 +790,36 @@ async function submitAccounting(): Promise<void> {
|
|||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
accountingErrors.clearErrors()
|
accountingErrors.clearErrors()
|
||||||
try {
|
try {
|
||||||
// 1) POST/PATCH des RIB d'abord (erreurs inline par ligne). On ne saute une
|
// 1) POST/PATCH des RIB d'abord — UNIQUEMENT sous LCR (erreurs inline par
|
||||||
// amorce neuve vide QUE s'il reste un autre RIB soumettable : sinon (LCR sans
|
// ligne). Hors-LCR (ERP-121), une saisie RIB eventuellement restee dans le
|
||||||
// aucun RIB rempli) on la soumet pour declencher la 422 NotBlank inline.
|
// brouillon est masquee et n'est PAS persistee (pas de RIB orphelin sur un
|
||||||
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
|
// fournisseur en virement). On ne saute une amorce neuve vide QUE s'il reste
|
||||||
const ribHasError = await submitRows(
|
// un autre RIB soumettable : sinon (LCR sans aucun RIB rempli) on la soumet
|
||||||
ribs.value,
|
// pour declencher la 422 NotBlank inline.
|
||||||
ribErrors,
|
if (isRibRequired.value) {
|
||||||
async (rib) => {
|
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
|
||||||
const body = buildRibPayload(rib)
|
const ribHasError = await submitRows(
|
||||||
if (rib.id === null) {
|
ribs.value,
|
||||||
const created = await api.post<{ id: number }>(
|
ribErrors,
|
||||||
`/suppliers/${supplierId.value}/ribs`,
|
async (rib) => {
|
||||||
body,
|
const body = buildRibPayload(rib)
|
||||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
if (rib.id === null) {
|
||||||
)
|
const created = await api.post<{ id: number }>(
|
||||||
rib.id = created.id
|
`/suppliers/${supplierId.value}/ribs`,
|
||||||
}
|
body,
|
||||||
else {
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
await api.patch(`/supplier_ribs/${rib.id}`, body, { toast: false })
|
)
|
||||||
}
|
rib.id = created.id
|
||||||
},
|
}
|
||||||
error => toast.error({ title: t('commercial.suppliers.toast.error'), message: apiErrorMessage(error) }),
|
else {
|
||||||
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
|
await api.patch(`/supplier_ribs/${rib.id}`, body, { toast: false })
|
||||||
)
|
}
|
||||||
if (ribHasError) return
|
},
|
||||||
|
error => toast.error({ title: t('commercial.suppliers.toast.error'), message: apiErrorMessage(error) }),
|
||||||
|
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
|
||||||
|
)
|
||||||
|
if (ribHasError) return
|
||||||
|
}
|
||||||
|
|
||||||
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
|
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
|
||||||
try {
|
try {
|
||||||
|
|||||||
+15
@@ -9,6 +9,7 @@ import {
|
|||||||
mapAddressView,
|
mapAddressView,
|
||||||
mapContactToDraft,
|
mapContactToDraft,
|
||||||
mapRibToDraft,
|
mapRibToDraft,
|
||||||
|
paymentTypeCodeOf,
|
||||||
referentialOptionOf,
|
referentialOptionOf,
|
||||||
relationOf,
|
relationOf,
|
||||||
showArchiveAction,
|
showArchiveAction,
|
||||||
@@ -233,3 +234,17 @@ describe('showArchiveAction / showRestoreAction', () => {
|
|||||||
expect(showRestoreAction(can([]), true)).toBe(false)
|
expect(showRestoreAction(can([]), true)).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', () => {
|
||||||
|
it('retourne le code metier quand le type de reglement est embarque', () => {
|
||||||
|
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1', code: 'LCR' })).toBe('LCR')
|
||||||
|
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/2', code: 'VIREMENT' })).toBe('VIREMENT')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retourne null pour un IRI nu, un objet sans code, ou une relation absente', () => {
|
||||||
|
expect(paymentTypeCodeOf('/api/payment_types/1')).toBeNull()
|
||||||
|
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1' })).toBeNull()
|
||||||
|
expect(paymentTypeCodeOf(null)).toBeNull()
|
||||||
|
expect(paymentTypeCodeOf(undefined)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
+11
@@ -36,6 +36,7 @@ function informationDraft(overrides: Partial<InformationFormDraft> = {}): Inform
|
|||||||
description: 'desc',
|
description: 'desc',
|
||||||
competitors: 'concurrents',
|
competitors: 'concurrents',
|
||||||
foundedAt: '2010-05-01',
|
foundedAt: '2010-05-01',
|
||||||
|
foundedAtRaw: '',
|
||||||
employeesCount: '42',
|
employeesCount: '42',
|
||||||
revenueAmount: '1000000',
|
revenueAmount: '1000000',
|
||||||
profitAmount: '50000',
|
profitAmount: '50000',
|
||||||
@@ -140,6 +141,16 @@ describe('buildInformationPayload — scoping strict groupe client:write:informa
|
|||||||
expect(payload.description).toBeNull()
|
expect(payload.description).toBeNull()
|
||||||
expect(payload.directorName).toBeNull()
|
expect(payload.directorName).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('envoie la saisie invalide (foundedAtRaw) en priorite -> le back tranchera (422)', () => {
|
||||||
|
// Saisie malformee : on transmet le texte brut tel quel pour declencher la
|
||||||
|
// 422 back sur foundedAt (validation autoritaire du format, MUI-44).
|
||||||
|
expect(buildInformationPayload(informationDraft({ foundedAt: null, foundedAtRaw: '32/13/2026' })).foundedAt)
|
||||||
|
.toBe('32/13/2026')
|
||||||
|
// Saisie valide : foundedAtRaw vide -> on envoie la date ISO.
|
||||||
|
expect(buildInformationPayload(informationDraft({ foundedAt: '2010-05-01', foundedAtRaw: '' })).foundedAt)
|
||||||
|
.toBe('2010-05-01')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('buildAccountingPayload — scoping strict groupe client:write:accounting', () => {
|
describe('buildAccountingPayload — scoping strict groupe client:write:accounting', () => {
|
||||||
+15
@@ -9,6 +9,7 @@ import {
|
|||||||
mapAddressView,
|
mapAddressView,
|
||||||
mapContactToDraft,
|
mapContactToDraft,
|
||||||
mapRibToDraft,
|
mapRibToDraft,
|
||||||
|
paymentTypeCodeOf,
|
||||||
referentialOptionOf,
|
referentialOptionOf,
|
||||||
showArchiveAction,
|
showArchiveAction,
|
||||||
showRestoreAction,
|
showRestoreAction,
|
||||||
@@ -222,3 +223,17 @@ describe('showArchiveAction / showRestoreAction', () => {
|
|||||||
expect(showRestoreAction(can([]), true)).toBe(false)
|
expect(showRestoreAction(can([]), true)).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', () => {
|
||||||
|
it('retourne le code metier quand le type de reglement est embarque', () => {
|
||||||
|
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1', code: 'LCR' })).toBe('LCR')
|
||||||
|
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/2', code: 'VIREMENT' })).toBe('VIREMENT')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retourne null pour un IRI nu, un objet sans code, ou une relation absente', () => {
|
||||||
|
expect(paymentTypeCodeOf('/api/payment_types/1')).toBeNull()
|
||||||
|
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1' })).toBeNull()
|
||||||
|
expect(paymentTypeCodeOf(null)).toBeNull()
|
||||||
|
expect(paymentTypeCodeOf(undefined)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
+11
-2
@@ -11,7 +11,7 @@ import {
|
|||||||
mapMainDraft,
|
mapMainDraft,
|
||||||
resolveTabEditability,
|
resolveTabEditability,
|
||||||
} from '../supplierEdit'
|
} from '../supplierEdit'
|
||||||
import type { SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
|
import type { SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation'
|
||||||
import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/supplierForm'
|
import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/supplierForm'
|
||||||
|
|
||||||
describe('buildMainPayload (groupe supplier:write:main)', () => {
|
describe('buildMainPayload (groupe supplier:write:main)', () => {
|
||||||
@@ -37,7 +37,7 @@ describe('buildMainPayload (groupe supplier:write:main)', () => {
|
|||||||
|
|
||||||
describe('buildInformationPayload (groupe supplier:write:information)', () => {
|
describe('buildInformationPayload (groupe supplier:write:information)', () => {
|
||||||
const base = {
|
const base = {
|
||||||
description: null, competitors: null, foundedAt: null, employeesCount: null,
|
description: null, competitors: null, foundedAt: null, foundedAtRaw: '', employeesCount: null,
|
||||||
revenueAmount: null, profitAmount: null, directorName: null, volumeForecast: null,
|
revenueAmount: null, profitAmount: null, directorName: null, volumeForecast: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +48,15 @@ describe('buildInformationPayload (groupe supplier:write:information)', () => {
|
|||||||
})
|
})
|
||||||
expect(buildInformationPayload(base)).toMatchObject({ employeesCount: null, volumeForecast: null })
|
expect(buildInformationPayload(base)).toMatchObject({ employeesCount: null, volumeForecast: null })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('envoie la saisie invalide (foundedAtRaw) en priorite -> le back tranchera (422)', () => {
|
||||||
|
// Saisie malformee transmise telle quelle pour declencher la 422 back (MUI-44).
|
||||||
|
expect(buildInformationPayload({ ...base, foundedAt: null, foundedAtRaw: '32/13/2026' }).foundedAt)
|
||||||
|
.toBe('32/13/2026')
|
||||||
|
// Saisie valide : foundedAtRaw vide -> on envoie la date ISO.
|
||||||
|
expect(buildInformationPayload({ ...base, foundedAt: '2008-04-01', foundedAtRaw: '' }).foundedAt)
|
||||||
|
.toBe('2008-04-01')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('buildContactPayload (sous-ressource supplier_contact)', () => {
|
describe('buildContactPayload (sous-ressource supplier_contact)', () => {
|
||||||
+15
@@ -293,6 +293,21 @@ export function referentialOptionOf(relation: Relation): SelectOption[] {
|
|||||||
return [{ value: relation['@id'], label }]
|
return [{ value: relation['@id'], label }]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Code metier d'un referentiel embarque (ex: PaymentType.code = 'LCR' / 'VIREMENT'),
|
||||||
|
* ou null si la relation est absente / serialisee en IRI nu. Type-safe : la branche
|
||||||
|
* chaine (IRI nu) et l'absence sont court-circuitees avant l'acces au code. Sert a
|
||||||
|
* conditionner l'affichage selon le type de reglement courant (ERP-121 : RIB masques
|
||||||
|
* hors-LCR en consultation).
|
||||||
|
*/
|
||||||
|
export function paymentTypeCodeOf(relation: Relation): string | null {
|
||||||
|
if (!relation || typeof relation === 'string') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (relation.code as string | undefined) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
/** Vue d'une adresse (brouillon + options de select propres a l'adresse). */
|
/** Vue d'une adresse (brouillon + options de select propres a l'adresse). */
|
||||||
export function mapAddressView(address: AddressRead): AddressView {
|
export function mapAddressView(address: AddressRead): AddressView {
|
||||||
return {
|
return {
|
||||||
+14
-3
@@ -20,14 +20,14 @@ import {
|
|||||||
iriOf,
|
iriOf,
|
||||||
relationOf,
|
relationOf,
|
||||||
type ClientDetail,
|
type ClientDetail,
|
||||||
} from '~/modules/commercial/utils/clientConsultation'
|
} from '~/modules/commercial/utils/forms/clientConsultation'
|
||||||
import {
|
import {
|
||||||
ADDRESS_REQUIRED_NON_NULLABLE_KEYS,
|
ADDRESS_REQUIRED_NON_NULLABLE_KEYS,
|
||||||
blankEmptyRequired,
|
blankEmptyRequired,
|
||||||
MAIN_REQUIRED_NON_NULLABLE_KEYS,
|
MAIN_REQUIRED_NON_NULLABLE_KEYS,
|
||||||
omitEmptyRequired,
|
omitEmptyRequired,
|
||||||
RIB_REQUIRED_NON_NULLABLE_KEYS,
|
RIB_REQUIRED_NON_NULLABLE_KEYS,
|
||||||
} from '~/modules/commercial/utils/clientFormRules'
|
} from '~/modules/commercial/utils/forms/clientFormRules'
|
||||||
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
|
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,6 +53,13 @@ export interface InformationFormDraft {
|
|||||||
competitors: string | null
|
competitors: string | null
|
||||||
/** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */
|
/** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */
|
||||||
foundedAt: string | null
|
foundedAt: string | null
|
||||||
|
/**
|
||||||
|
* Saisie brute invalide remontee par MalioDate (`@update:rawValue`) : '' tant
|
||||||
|
* que la saisie est valide/vide, sinon le texte tel que tape. On l'envoie au
|
||||||
|
* back en priorite sur `foundedAt` pour que la 422 (validation autoritaire du
|
||||||
|
* format, ERP-101) porte sur le champ et s'affiche inline. Cf. MUI-44.
|
||||||
|
*/
|
||||||
|
foundedAtRaw: string
|
||||||
/** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */
|
/** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */
|
||||||
employeesCount: string | null
|
employeesCount: string | null
|
||||||
revenueAmount: string | null
|
revenueAmount: string | null
|
||||||
@@ -118,6 +125,8 @@ export function mapInformationDraft(client: ClientDetail): InformationFormDraft
|
|||||||
competitors: client.competitors ?? null,
|
competitors: client.competitors ?? null,
|
||||||
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
|
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
|
||||||
foundedAt: client.foundedAt ? client.foundedAt.slice(0, 10) : null,
|
foundedAt: client.foundedAt ? client.foundedAt.slice(0, 10) : null,
|
||||||
|
// Aucune saisie brute invalide au chargement (la valeur stockee est valide).
|
||||||
|
foundedAtRaw: '',
|
||||||
employeesCount: client.employeesCount != null ? String(client.employeesCount) : null,
|
employeesCount: client.employeesCount != null ? String(client.employeesCount) : null,
|
||||||
revenueAmount: client.revenueAmount ?? null,
|
revenueAmount: client.revenueAmount ?? null,
|
||||||
profitAmount: client.profitAmount ?? null,
|
profitAmount: client.profitAmount ?? null,
|
||||||
@@ -191,7 +200,9 @@ export function buildInformationPayload(information: InformationFormDraft): Reco
|
|||||||
return {
|
return {
|
||||||
description: information.description || null,
|
description: information.description || null,
|
||||||
competitors: information.competitors || null,
|
competitors: information.competitors || null,
|
||||||
foundedAt: information.foundedAt || null,
|
// Saisie invalide (foundedAtRaw) prioritaire : on l'envoie telle quelle
|
||||||
|
// pour que le back renvoie une 422 sur foundedAt (cf. foundedAtRaw).
|
||||||
|
foundedAt: information.foundedAtRaw || information.foundedAt || null,
|
||||||
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
|
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
|
||||||
revenueAmount: information.revenueAmount || null,
|
revenueAmount: information.revenueAmount || null,
|
||||||
profitAmount: information.profitAmount || null,
|
profitAmount: information.profitAmount || null,
|
||||||
+15
@@ -268,6 +268,21 @@ export function referentialOptionOf(relation: Relation): SelectOption[] {
|
|||||||
return [{ value: relation['@id'], label }]
|
return [{ value: relation['@id'], label }]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Code metier d'un referentiel embarque (ex: PaymentType.code = 'LCR' / 'VIREMENT'),
|
||||||
|
* ou null si la relation est absente / serialisee en IRI nu. Type-safe : la branche
|
||||||
|
* chaine (IRI nu) et l'absence sont court-circuitees avant l'acces au code. Sert a
|
||||||
|
* conditionner l'affichage selon le type de reglement courant (ERP-121 : RIB masques
|
||||||
|
* hors-LCR en consultation).
|
||||||
|
*/
|
||||||
|
export function paymentTypeCodeOf(relation: Relation): string | null {
|
||||||
|
if (!relation || typeof relation === 'string') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (relation.code as string | undefined) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
/** Vue d'une adresse (brouillon + options de select propres a l'adresse). */
|
/** Vue d'une adresse (brouillon + options de select propres a l'adresse). */
|
||||||
export function mapAddressView(address: AddressRead): AddressView {
|
export function mapAddressView(address: AddressRead): AddressView {
|
||||||
return {
|
return {
|
||||||
+14
-3
@@ -17,8 +17,8 @@ import {
|
|||||||
MAIN_REQUIRED_NON_NULLABLE_KEYS,
|
MAIN_REQUIRED_NON_NULLABLE_KEYS,
|
||||||
omitEmptyRequired,
|
omitEmptyRequired,
|
||||||
RIB_REQUIRED_NON_NULLABLE_KEYS,
|
RIB_REQUIRED_NON_NULLABLE_KEYS,
|
||||||
} from '~/modules/commercial/utils/supplierFormRules'
|
} from '~/modules/commercial/utils/forms/supplierFormRules'
|
||||||
import { iriOf, type SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
|
import { iriOf, type SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation'
|
||||||
import type {
|
import type {
|
||||||
SupplierAddressFormDraft,
|
SupplierAddressFormDraft,
|
||||||
SupplierContactFormDraft,
|
SupplierContactFormDraft,
|
||||||
@@ -38,6 +38,13 @@ export interface InformationFormDraft {
|
|||||||
competitors: string | null
|
competitors: string | null
|
||||||
/** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */
|
/** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */
|
||||||
foundedAt: string | null
|
foundedAt: string | null
|
||||||
|
/**
|
||||||
|
* Saisie brute invalide remontee par MalioDate (`@update:rawValue`) : '' tant
|
||||||
|
* que la saisie est valide/vide, sinon le texte tel que tape. On l'envoie au
|
||||||
|
* back en priorite sur `foundedAt` pour que la 422 (validation autoritaire du
|
||||||
|
* format, ERP-101) porte sur le champ et s'affiche inline. Cf. MUI-44.
|
||||||
|
*/
|
||||||
|
foundedAtRaw: string
|
||||||
/** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */
|
/** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */
|
||||||
employeesCount: string | null
|
employeesCount: string | null
|
||||||
revenueAmount: string | null
|
revenueAmount: string | null
|
||||||
@@ -95,6 +102,8 @@ export function mapInformationDraft(supplier: SupplierDetail): InformationFormDr
|
|||||||
competitors: supplier.competitors ?? null,
|
competitors: supplier.competitors ?? null,
|
||||||
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
|
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
|
||||||
foundedAt: supplier.foundedAt ? supplier.foundedAt.slice(0, 10) : null,
|
foundedAt: supplier.foundedAt ? supplier.foundedAt.slice(0, 10) : null,
|
||||||
|
// Aucune saisie brute invalide au chargement (la valeur stockee est valide).
|
||||||
|
foundedAtRaw: '',
|
||||||
employeesCount: supplier.employeesCount != null ? String(supplier.employeesCount) : null,
|
employeesCount: supplier.employeesCount != null ? String(supplier.employeesCount) : null,
|
||||||
revenueAmount: supplier.revenueAmount ?? null,
|
revenueAmount: supplier.revenueAmount ?? null,
|
||||||
profitAmount: supplier.profitAmount ?? null,
|
profitAmount: supplier.profitAmount ?? null,
|
||||||
@@ -177,7 +186,9 @@ export function buildInformationPayload(information: InformationFormDraft): Reco
|
|||||||
return {
|
return {
|
||||||
description: information.description || null,
|
description: information.description || null,
|
||||||
competitors: information.competitors || null,
|
competitors: information.competitors || null,
|
||||||
foundedAt: information.foundedAt || null,
|
// Saisie invalide (foundedAtRaw) prioritaire : on l'envoie telle quelle
|
||||||
|
// pour que le back renvoie une 422 sur foundedAt (cf. foundedAtRaw).
|
||||||
|
foundedAt: information.foundedAtRaw || information.foundedAt || null,
|
||||||
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
|
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
|
||||||
revenueAmount: information.revenueAmount || null,
|
revenueAmount: information.revenueAmount || null,
|
||||||
profitAmount: information.profitAmount || null,
|
profitAmount: information.profitAmount || null,
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export default defineNuxtConfig({})
|
||||||
Generated
+4
-4
@@ -7,7 +7,7 @@
|
|||||||
"name": "starseed-frontend",
|
"name": "starseed-frontend",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@malio/layer-ui": "^1.7.8",
|
"@malio/layer-ui": "^1.7.10",
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/i18n": "^10.2.3",
|
"@nuxtjs/i18n": "^10.2.3",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
@@ -1866,9 +1866,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@malio/layer-ui": {
|
"node_modules/@malio/layer-ui": {
|
||||||
"version": "1.7.8",
|
"version": "1.7.10",
|
||||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.8/layer-ui-1.7.8.tgz",
|
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.10/layer-ui-1.7.10.tgz",
|
||||||
"integrity": "sha512-gUMAZzBsPCfQUF3OQSjN/OFzjONvQZYfwqH0u5VUbxaqwBdX1hUGtjD4ym6RvZkyNsKulrxkncFZYTWCS+IdGA==",
|
"integrity": "sha512-ZWYaKvl+VpGAqeTE+4xdyKOmuRd4zwjlUYVppeIBZwGeNAK16kZnrztR+4eQmnzUqPZVybBhEBdKP9weqWHSUg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"test:e2e:ui": "playwright test --ui"
|
"test:e2e:ui": "playwright test --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@malio/layer-ui": "^1.7.8",
|
"@malio/layer-ui": "^1.7.10",
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/i18n": "^10.2.3",
|
"@nuxtjs/i18n": "^10.2.3",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -41,6 +41,22 @@ describe('useFormErrors', () => {
|
|||||||
expect(hasErrors.value).toBe(true)
|
expect(hasErrors.value).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('setServerErrors surcharge un message technique (erreur de type) par la cle i18n', () => {
|
||||||
|
const { errors, setServerErrors } = useFormErrors()
|
||||||
|
const mapped = setServerErrors({
|
||||||
|
violations: [
|
||||||
|
// Code Symfony Type::INVALID_TYPE_ERROR (date non parsable) : surcharge.
|
||||||
|
{ propertyPath: 'foundedAt', message: 'Cette valeur doit être de type DateTimeImmutable|null.', code: 'ba785a8c-82cb-4283-967c-3cf342181b40' },
|
||||||
|
// Violation metier classique : message back conserve.
|
||||||
|
{ propertyPath: 'companyName', message: 'Obligatoire.', code: 'c1051bb4-d103-4f74-8988-acbcafc7fdc3' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(mapped).toBe(true)
|
||||||
|
// Stub i18n -> renvoie la cle telle quelle.
|
||||||
|
expect(errors.foundedAt).toBe('errors.validation.invalidDate')
|
||||||
|
expect(errors.companyName).toBe('Obligatoire.')
|
||||||
|
})
|
||||||
|
|
||||||
it('setServerErrors retourne false et ne touche rien sans violation', () => {
|
it('setServerErrors retourne false et ne touche rien sans violation', () => {
|
||||||
const { errors, setServerErrors } = useFormErrors()
|
const { errors, setServerErrors } = useFormErrors()
|
||||||
expect(setServerErrors({})).toBe(false)
|
expect(setServerErrors({})).toBe(false)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
* appel par ligne), utiliser directement `mapViolationsToRecord` par ligne.
|
* appel par ligne), utiliser directement `mapViolationsToRecord` par ligne.
|
||||||
*/
|
*/
|
||||||
import { computed, reactive } from 'vue'
|
import { computed, reactive } from 'vue'
|
||||||
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
|
import { extractApiErrorMessage, extractApiViolations, resolveViolationMessage } from '~/shared/utils/api'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erreur HTTP capturee par ofetch. On n'expose que les champs lus ici (status
|
* Erreur HTTP capturee par ofetch. On n'expose que les champs lus ici (status
|
||||||
@@ -69,13 +69,16 @@ export function useFormErrors() {
|
|||||||
* violation exploitable).
|
* violation exploitable).
|
||||||
*/
|
*/
|
||||||
function setServerErrors(data: unknown): boolean {
|
function setServerErrors(data: unknown): boolean {
|
||||||
const mapped = mapViolationsToRecord(data)
|
const violations = extractApiViolations(data)
|
||||||
const keys = Object.keys(mapped)
|
let mapped = false
|
||||||
if (keys.length === 0) return false
|
for (const v of violations) {
|
||||||
for (const key of keys) {
|
if (!v.propertyPath) continue
|
||||||
errors[key] = mapped[key]
|
// Message back tel quel, sauf code surcharge par une cle i18n (ex.
|
||||||
|
// erreur de type sur une date non parsable -> « Date invalide »).
|
||||||
|
errors[v.propertyPath] = resolveViolationMessage(v, t)
|
||||||
|
mapped = true
|
||||||
}
|
}
|
||||||
return true
|
return mapped
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { mapViolationsToRecord } from '../api'
|
import { mapViolationsToRecord, resolveViolationMessage } from '../api'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests de `mapViolationsToRecord` — fondation du mapping erreur→champ des
|
* Tests de `mapViolationsToRecord` — fondation du mapping erreur→champ des
|
||||||
@@ -56,3 +56,30 @@ describe('mapViolationsToRecord', () => {
|
|||||||
expect(mapViolationsToRecord(data)).toEqual({ name: 'Second message.' })
|
expect(mapViolationsToRecord(data)).toEqual({ name: 'Second message.' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests de `resolveViolationMessage` — surcharge i18n d'un message back par code
|
||||||
|
* de violation. Le back peut renvoyer un message technique (erreur de type sur
|
||||||
|
* une date non parsable) : on le remplace via le `code` Symfony (stable) par une
|
||||||
|
* cle i18n, sans toucher au back. Le `t` ici renvoie la cle telle quelle.
|
||||||
|
*/
|
||||||
|
describe('resolveViolationMessage', () => {
|
||||||
|
const t = (key: string) => key
|
||||||
|
// Code Symfony Constraints\Type::INVALID_TYPE_ERROR (fige).
|
||||||
|
const TYPE_ERROR = 'ba785a8c-82cb-4283-967c-3cf342181b40'
|
||||||
|
|
||||||
|
it('surcharge le message technique d\'une erreur de type par la cle i18n', () => {
|
||||||
|
const v = { propertyPath: 'foundedAt', message: 'Cette valeur doit être de type DateTimeImmutable|null.', code: TYPE_ERROR }
|
||||||
|
expect(resolveViolationMessage(v, t)).toBe('errors.validation.invalidDate')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renvoie le message back tel quel quand le code n\'est pas surcharge', () => {
|
||||||
|
const v = { propertyPath: 'companyName', message: 'Le nom est obligatoire.', code: 'c1051bb4-d103-4f74-8988-acbcafc7fdc3' }
|
||||||
|
expect(resolveViolationMessage(v, t)).toBe('Le nom est obligatoire.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renvoie le message back tel quel quand il n\'y a pas de code', () => {
|
||||||
|
const v = { propertyPath: 'siren', message: 'SIREN deja utilise.', code: '' }
|
||||||
|
expect(resolveViolationMessage(v, t)).toBe('SIREN deja utilise.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -34,11 +34,15 @@ export function extractHydraMembers<T>(collection: HydraCollection<T>): T[] {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Une violation de contrainte API Platform (reponse 422). Le `propertyPath`
|
* Une violation de contrainte API Platform (reponse 422). Le `propertyPath`
|
||||||
* pointe le champ concerne, `message` est le libelle a afficher.
|
* pointe le champ concerne, `message` est le libelle a afficher, `code` est le
|
||||||
|
* code de contrainte Symfony (UUID stable, independant de la langue) — il sert
|
||||||
|
* a surcharger un message back technique par une cle i18n (cf.
|
||||||
|
* `VIOLATION_MESSAGE_I18N` / `resolveViolationMessage`).
|
||||||
*/
|
*/
|
||||||
export interface ApiViolation {
|
export interface ApiViolation {
|
||||||
propertyPath: string
|
propertyPath: string
|
||||||
message: string
|
message: string
|
||||||
|
code: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,6 +65,7 @@ export function extractApiViolations(data: unknown): ApiViolation[] {
|
|||||||
out.push({
|
out.push({
|
||||||
propertyPath: String(obj.propertyPath ?? ''),
|
propertyPath: String(obj.propertyPath ?? ''),
|
||||||
message: String(obj.message ?? ''),
|
message: String(obj.message ?? ''),
|
||||||
|
code: String(obj.code ?? ''),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@@ -85,6 +90,45 @@ export function mapViolationsToRecord(data: unknown): Record<string, string> {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surcharge i18n d'un message back par CODE de violation.
|
||||||
|
*
|
||||||
|
* La plupart des contraintes back portent deja un message FR explicite (ex.
|
||||||
|
* `#[Assert\NotBlank(message: '...')]`) : on l'affiche tel quel. Mais certaines
|
||||||
|
* 422 portent un message TECHNIQUE non montrable a l'utilisateur — typiquement
|
||||||
|
* l'erreur de TYPE renvoyee par API Platform quand le back ne peut pas
|
||||||
|
* denormaliser la valeur (date non parsable envoyee sur un champ
|
||||||
|
* `DateTimeImmutable` : « Cette valeur doit être de type DateTimeImmutable|null. »,
|
||||||
|
* voire en anglais selon la negociation de langue).
|
||||||
|
*
|
||||||
|
* Plutot que de traduire/maquiller cote back, on surcharge ces messages cote
|
||||||
|
* front via leur `code` de violation. Ce code est un UUID Symfony FIGE (contrat
|
||||||
|
* de compatibilite : il ne change pas entre versions), donc bien plus robuste
|
||||||
|
* qu'un match sur le texte du message (qui depend de la langue). La table
|
||||||
|
* associe un code -> une cle i18n ; `resolveViolationMessage` l'applique.
|
||||||
|
*
|
||||||
|
* Limite a connaitre : le code de type-error est GENERIQUE (toute valeur de
|
||||||
|
* mauvais type). Dans nos formulaires, seul un champ date saisi en texte libre
|
||||||
|
* (MalioDate, qui forwarde la saisie brute invalide) le declenche, d'ou le
|
||||||
|
* libelle « Date invalide ». Si un autre champ typé en saisie libre apparait,
|
||||||
|
* affiner la resolution via `propertyPath` plutot que par code seul.
|
||||||
|
*/
|
||||||
|
export const VIOLATION_MESSAGE_I18N: Record<string, string> = {
|
||||||
|
// Symfony `Constraints\Type::INVALID_TYPE_ERROR` — valeur de mauvais type.
|
||||||
|
'ba785a8c-82cb-4283-967c-3cf342181b40': 'errors.validation.invalidDate',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resout le message a afficher pour une violation : si son `code` est surcharge
|
||||||
|
* par `VIOLATION_MESSAGE_I18N`, renvoie la traduction de la cle associee ;
|
||||||
|
* sinon, le message back tel quel (cas nominal). `t` est passe par l'appelant
|
||||||
|
* (les utils sont purs, sans acces a useI18n).
|
||||||
|
*/
|
||||||
|
export function resolveViolationMessage(v: ApiViolation, t: (key: string) => string): string {
|
||||||
|
const i18nKey = VIOLATION_MESSAGE_I18N[v.code]
|
||||||
|
return i18nKey ? t(i18nKey) : v.message
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extrait un message d'erreur lisible depuis un payload Hydra / JSON
|
* Extrait un message d'erreur lisible depuis un payload Hydra / JSON
|
||||||
* d'erreur API Platform. Essaie les champs courants dans l'ordre :
|
* d'erreur API Platform. Essaie les champs courants dans l'ordre :
|
||||||
|
|||||||
@@ -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
|
* Fixtures dev/test du module Catalog : categories de demonstration rattachees
|
||||||
* a leur CategoryType. Le type CLIENT porte ~11 categories clients (refonte
|
* a leur CategoryType. Le type CLIENT porte ~11 categories clients (refonte
|
||||||
* taxonomie ERP-78) ; le type FOURNISSEUR porte les categories fournisseurs
|
* 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
|
* Alimente le repertoire clients (ClientFixtures, module Commercial) avec des
|
||||||
* donnees realistes couvrant RG-1.03 (codes DISTRIBUTEUR / COURTIER) et RG-1.29
|
* donnees realistes couvrant RG-1.03 (codes DISTRIBUTEUR / COURTIER) et RG-1.29
|
||||||
* (codes interdits sur adresse), et le multi-select Categorie fournisseur (M2).
|
* (codes interdits sur adresse), et le multi-select Categorie fournisseur (M2).
|
||||||
@@ -71,6 +73,11 @@ class CategoryFixtures extends Fixture implements DependentFixtureInterface
|
|||||||
'Grossiste' => 'GROSSISTE',
|
'Grossiste' => 'GROSSISTE',
|
||||||
'Importateur' => 'IMPORTATEUR',
|
'Importateur' => 'IMPORTATEUR',
|
||||||
],
|
],
|
||||||
|
'PRESTATAIRE' => [
|
||||||
|
'Maintenance industrielle' => 'MAINTENANCE_INDUSTRIELLE',
|
||||||
|
'Nettoyage' => 'NETTOYAGE',
|
||||||
|
'Transport' => 'TRANSPORT',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ use Doctrine\Persistence\ObjectManager;
|
|||||||
* taxonomie distincte des fournisseurs (Negociant, Cooperative...). Mirroir de
|
* taxonomie distincte des fournisseurs (Negociant, Cooperative...). Mirroir de
|
||||||
* la migration Version20260605120000.
|
* 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
|
* 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
|
* 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
|
* `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
|
* Source unique des types : code technique => libelle FR. Doit rester aligne
|
||||||
* sur le seed des migrations Version20260602100000 (CLIENT) et
|
* sur le seed des migrations Version20260602100000 (CLIENT),
|
||||||
* Version20260605120000 (FOURNISSEUR).
|
* Version20260605120000 (FOURNISSEUR) et Version20260612080000 (PRESTATAIRE).
|
||||||
*/
|
*/
|
||||||
private const TYPES = [
|
private const TYPES = [
|
||||||
'CLIENT' => 'Client',
|
'CLIENT' => 'Client',
|
||||||
'FOURNISSEUR' => 'Fournisseur',
|
'FOURNISSEUR' => 'Fournisseur',
|
||||||
|
'PRESTATAIRE' => 'Prestataire',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ use DateTimeImmutable;
|
|||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Context;
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
use Symfony\Component\Serializer\Attribute\SerializedName;
|
use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||||
|
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||||
|
|
||||||
@@ -94,6 +96,12 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||||||
security: "is_granted('commercial.clients.manage')",
|
security: "is_granted('commercial.clients.manage')",
|
||||||
normalizationContext: ['groups' => ['client:read', 'default:read', 'category:read', 'site:read']],
|
normalizationContext: ['groups' => ['client:read', 'default:read', 'category:read', 'site:read']],
|
||||||
denormalizationContext: ['groups' => ['client:write:main']],
|
denormalizationContext: ['groups' => ['client:write:main']],
|
||||||
|
// Une valeur de mauvais type (ex. date non parsable sur foundedAt)
|
||||||
|
// doit produire un 422 porte sur le champ (violations[].propertyPath,
|
||||||
|
// mappable inline par useFormErrors) plutot qu'un 400 generique non
|
||||||
|
// exploitable. Le front (MalioDate, MUI-44) forwarde la saisie brute
|
||||||
|
// invalide : le back reste la couche autoritaire du format (ERP-101).
|
||||||
|
collectDenormalizationErrors: true,
|
||||||
processor: ClientProcessor::class,
|
processor: ClientProcessor::class,
|
||||||
),
|
),
|
||||||
new Patch(
|
new Patch(
|
||||||
@@ -117,6 +125,10 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||||||
'client:write:accounting',
|
'client:write:accounting',
|
||||||
'client:write:archive',
|
'client:write:archive',
|
||||||
]],
|
]],
|
||||||
|
// Cf. Post : date non parsable (foundedAt) -> 422 porte sur le champ
|
||||||
|
// au lieu d'un 400 generique. Indispensable au mapping inline du
|
||||||
|
// front (MalioDate MUI-44 forwarde la saisie brute invalide).
|
||||||
|
collectDenormalizationErrors: true,
|
||||||
provider: ClientProvider::class,
|
provider: ClientProvider::class,
|
||||||
processor: ClientProcessor::class,
|
processor: ClientProcessor::class,
|
||||||
),
|
),
|
||||||
@@ -206,6 +218,13 @@ class Client implements TimestampableInterface, BlamableInterface
|
|||||||
|
|
||||||
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||||
#[Groups(['client:read', 'client:write:information'])]
|
#[Groups(['client:read', 'client:write:information'])]
|
||||||
|
// Format d'ENTREE strict ISO `Y-m-d` (le `!` remet l'heure a 00:00:00). Sans
|
||||||
|
// ce format, PHP DateTime accepte des formes ambigues : « 12/25/2026 » (que
|
||||||
|
// le front MalioDate juge invalide en JJ/MM/AAAA) serait sinon interprete en
|
||||||
|
// M/J/AAAA -> 25 decembre 2026, et accepte a tort. Avec le format, toute
|
||||||
|
// saisie brute non-ISO (forwardee par MalioDate sur date invalide) echoue la
|
||||||
|
// denormalisation -> 422 sur foundedAt (cf. collectDenormalizationErrors).
|
||||||
|
#[Context(denormalizationContext: [DateTimeNormalizer::FORMAT_KEY => '!Y-m-d'])]
|
||||||
private ?DateTimeImmutable $foundedAt = null;
|
private ?DateTimeImmutable $foundedAt = null;
|
||||||
|
|
||||||
#[ORM\Column(nullable: true)]
|
#[ORM\Column(nullable: true)]
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ use DateTimeImmutable;
|
|||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Context;
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
use Symfony\Component\Serializer\Attribute\SerializedName;
|
use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||||
|
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||||
|
|
||||||
@@ -94,6 +96,12 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||||||
security: "is_granted('commercial.suppliers.manage')",
|
security: "is_granted('commercial.suppliers.manage')",
|
||||||
normalizationContext: ['groups' => ['supplier:read', 'default:read', 'category:read', 'site:read']],
|
normalizationContext: ['groups' => ['supplier:read', 'default:read', 'category:read', 'site:read']],
|
||||||
denormalizationContext: ['groups' => ['supplier:write:main']],
|
denormalizationContext: ['groups' => ['supplier:write:main']],
|
||||||
|
// Une valeur de mauvais type (ex. date non parsable sur foundedAt)
|
||||||
|
// doit produire un 422 porte sur le champ (violations[].propertyPath,
|
||||||
|
// mappable inline par useFormErrors) plutot qu'un 400 generique. Le
|
||||||
|
// front (MalioDate, MUI-44) forwarde la saisie brute invalide : le
|
||||||
|
// back reste la couche autoritaire du format (ERP-101). Cf. Client.
|
||||||
|
collectDenormalizationErrors: true,
|
||||||
processor: SupplierProcessor::class,
|
processor: SupplierProcessor::class,
|
||||||
),
|
),
|
||||||
new Patch(
|
new Patch(
|
||||||
@@ -113,6 +121,10 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||||||
'supplier:write:accounting',
|
'supplier:write:accounting',
|
||||||
'supplier:write:archive',
|
'supplier:write:archive',
|
||||||
]],
|
]],
|
||||||
|
// Cf. Post : date non parsable (foundedAt) -> 422 porte sur le champ
|
||||||
|
// au lieu d'un 400 generique. Indispensable au mapping inline du
|
||||||
|
// front (MalioDate MUI-44 forwarde la saisie brute invalide).
|
||||||
|
collectDenormalizationErrors: true,
|
||||||
provider: SupplierProvider::class,
|
provider: SupplierProvider::class,
|
||||||
processor: SupplierProcessor::class,
|
processor: SupplierProcessor::class,
|
||||||
),
|
),
|
||||||
@@ -187,6 +199,11 @@ class Supplier implements TimestampableInterface, BlamableInterface
|
|||||||
|
|
||||||
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||||
#[Groups(['supplier:read', 'supplier:write:information'])]
|
#[Groups(['supplier:read', 'supplier:write:information'])]
|
||||||
|
// Format d'ENTREE strict ISO `Y-m-d` : sans lui, PHP DateTime accepte des
|
||||||
|
// formes ambigues (« 12/25/2026 », jugee invalide par MalioDate en JJ/MM/AAAA,
|
||||||
|
// serait lue en M/J -> 25 decembre et acceptee a tort). Avec le format, toute
|
||||||
|
// saisie brute non-ISO echoue -> 422 sur foundedAt. Cf. Client.
|
||||||
|
#[Context(denormalizationContext: [DateTimeNormalizer::FORMAT_KEY => '!Y-m-d'])]
|
||||||
private ?DateTimeImmutable $foundedAt = null;
|
private ?DateTimeImmutable $foundedAt = null;
|
||||||
|
|
||||||
#[ORM\Column(nullable: true)]
|
#[ORM\Column(nullable: true)]
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Commercial\Api;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation back-autoritative du FORMAT de la date de creation (foundedAt,
|
||||||
|
* onglet Information).
|
||||||
|
*
|
||||||
|
* Le front (MalioDate, cf. MUI-44) forwarde desormais la saisie brute invalide
|
||||||
|
* au serveur plutot que de l'avaler. Cote back, une date non parsable doit
|
||||||
|
* produire un 422 porte sur `foundedAt` (mappable inline par useFormErrors),
|
||||||
|
* et non un 400 generique. Repose sur `collectDenormalizationErrors` actif sur
|
||||||
|
* l'operation Patch du Client.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class ClientFoundedAtFormatTest extends AbstractCommercialApiTestCase
|
||||||
|
{
|
||||||
|
private const string MERGE = 'application/merge-patch+json';
|
||||||
|
|
||||||
|
/** Date non parsable -> 422 porte sur foundedAt (et pas un 400 generique). */
|
||||||
|
public function testFoundedAtNonParsableEst422(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$seed = $this->seedClient('Founded Format SARL');
|
||||||
|
|
||||||
|
$body = $client->request('PATCH', '/api/clients/'.$seed->getId(), [
|
||||||
|
'headers' => ['Content-Type' => self::MERGE],
|
||||||
|
'json' => ['foundedAt' => '32/13/2026'],
|
||||||
|
])->toArray(false);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
self::assertArrayHasKey('foundedAt', $this->violationsByPath($body));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cas piege : « 12/25/2026 » est invalide cote front (JJ/MM/AAAA -> mois 25)
|
||||||
|
* mais PHP DateTime l'accepterait en M/J/AAAA (25 decembre). Le format d'entree
|
||||||
|
* strict ISO `Y-m-d` (Context sur foundedAt) doit le rejeter -> 422.
|
||||||
|
*/
|
||||||
|
public function testFoundedAtFormatAmbiguUsEst422(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$seed = $this->seedClient('Founded Ambigu SARL');
|
||||||
|
|
||||||
|
$body = $client->request('PATCH', '/api/clients/'.$seed->getId(), [
|
||||||
|
'headers' => ['Content-Type' => self::MERGE],
|
||||||
|
'json' => ['foundedAt' => '12/25/2026'],
|
||||||
|
])->toArray(false);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
self::assertArrayHasKey('foundedAt', $this->violationsByPath($body));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Non-regression : une date ISO valide reste acceptee (200). */
|
||||||
|
public function testFoundedAtIsoValideEst200(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$seed = $this->seedClient('Founded Ok SARL');
|
||||||
|
|
||||||
|
$data = $client->request('PATCH', '/api/clients/'.$seed->getId(), [
|
||||||
|
'headers' => ['Content-Type' => self::MERGE],
|
||||||
|
'json' => ['foundedAt' => '2010-05-01'],
|
||||||
|
])->toArray();
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(200);
|
||||||
|
self::assertStringStartsWith('2010-05-01', $data['foundedAt']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Commercial\Api;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation back-autoritative du FORMAT de la date de creation (foundedAt,
|
||||||
|
* onglet Information) du fournisseur. Miroir de {@see ClientFoundedAtFormatTest}.
|
||||||
|
*
|
||||||
|
* Une date non parsable (saisie brute forwardee par MalioDate, MUI-44) doit
|
||||||
|
* produire un 422 porte sur `foundedAt` (mappable inline par useFormErrors), et
|
||||||
|
* non un 400 generique. Repose sur `collectDenormalizationErrors` sur les
|
||||||
|
* operations write du Supplier.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class SupplierFoundedAtFormatTest extends AbstractSupplierApiTestCase
|
||||||
|
{
|
||||||
|
/** Date non parsable -> 422 porte sur foundedAt (et pas un 400 generique). */
|
||||||
|
public function testFoundedAtNonParsableEst422(): void
|
||||||
|
{
|
||||||
|
$seed = $this->seedSupplier('Founded Format Negoce');
|
||||||
|
$credentials = $this->createUserWithPermission('commercial.suppliers.manage');
|
||||||
|
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||||
|
|
||||||
|
$body = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
|
||||||
|
'headers' => ['Content-Type' => self::MERGE],
|
||||||
|
'json' => ['foundedAt' => '32/13/2026'],
|
||||||
|
])->toArray(false);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
self::assertArrayHasKey('foundedAt', $this->violationsByPath($body));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cas piege : « 12/25/2026 » est invalide cote front (JJ/MM/AAAA -> mois 25)
|
||||||
|
* mais PHP DateTime l'accepterait en M/J/AAAA. Le format d'entree strict ISO
|
||||||
|
* `Y-m-d` (Context sur foundedAt) doit le rejeter -> 422.
|
||||||
|
*/
|
||||||
|
public function testFoundedAtFormatAmbiguUsEst422(): void
|
||||||
|
{
|
||||||
|
$seed = $this->seedSupplier('Founded Ambigu Negoce');
|
||||||
|
$credentials = $this->createUserWithPermission('commercial.suppliers.manage');
|
||||||
|
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||||
|
|
||||||
|
$body = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
|
||||||
|
'headers' => ['Content-Type' => self::MERGE],
|
||||||
|
'json' => ['foundedAt' => '12/25/2026'],
|
||||||
|
])->toArray(false);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
self::assertArrayHasKey('foundedAt', $this->violationsByPath($body));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Non-regression : une date ISO valide reste acceptee (200). */
|
||||||
|
public function testFoundedAtIsoValideEst200(): void
|
||||||
|
{
|
||||||
|
$seed = $this->seedSupplier('Founded Ok Negoce');
|
||||||
|
$credentials = $this->createUserWithPermission('commercial.suppliers.manage');
|
||||||
|
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||||
|
|
||||||
|
$data = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
|
||||||
|
'headers' => ['Content-Type' => self::MERGE],
|
||||||
|
'json' => ['foundedAt' => '2010-05-01'],
|
||||||
|
])->toArray();
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(200);
|
||||||
|
self::assertStringStartsWith('2010-05-01', $data['foundedAt']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user