Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 58474404b4 | |||
| 779d5be04e | |||
| 6ceef62056 | |||
| 7d8a633eee | |||
| df9451a5f4 | |||
| cb12490ba0 | |||
| a442d124a3 |
@@ -5,10 +5,12 @@ use App\Module\Catalog\CatalogModule;
|
||||
use App\Module\Commercial\CommercialModule;
|
||||
use App\Module\Core\CoreModule;
|
||||
use App\Module\Sites\SitesModule;
|
||||
use App\Module\Technique\TechniqueModule;
|
||||
|
||||
return [
|
||||
CoreModule::class,
|
||||
CommercialModule::class,
|
||||
SitesModule::class,
|
||||
CatalogModule::class,
|
||||
TechniqueModule::class,
|
||||
];
|
||||
|
||||
@@ -80,6 +80,16 @@ doctrine:
|
||||
dir: '%kernel.project_dir%/src/Module/Commercial/Domain/Entity'
|
||||
prefix: 'App\Module\Commercial\Domain\Entity'
|
||||
alias: Commercial
|
||||
# Mapping inconditionnel du module Technique (meme logique que Commercial) :
|
||||
# les tables prestataires (provider + sous-collections + jointures M2M)
|
||||
# creees par la migration M3 (Version20260612100000) doivent etre connues
|
||||
# de l'ORM. L'activation fonctionnelle passe par config/modules.php.
|
||||
Technique:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/Technique/Domain/Entity'
|
||||
prefix: 'App\Module\Technique\Domain\Entity'
|
||||
alias: Technique
|
||||
controller_resolver:
|
||||
auto_mapping: false
|
||||
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.107'
|
||||
app.version: '0.1.109'
|
||||
|
||||
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.
|
||||
@@ -413,7 +413,11 @@
|
||||
"commercial_supplier": "Fournisseur",
|
||||
"commercial_supplieraddress": "Adresse fournisseur",
|
||||
"commercial_suppliercontact": "Contact fournisseur",
|
||||
"commercial_supplierrib": "RIB fournisseur"
|
||||
"commercial_supplierrib": "RIB fournisseur",
|
||||
"technique_provider": "Prestataire",
|
||||
"technique_provideraddress": "Adresse prestataire",
|
||||
"technique_providercontact": "Contact prestataire",
|
||||
"technique_providerrib": "RIB prestataire"
|
||||
},
|
||||
"empty": "Aucune activité enregistrée",
|
||||
"no_results": "Aucun résultat pour ces filtres",
|
||||
|
||||
@@ -26,13 +26,18 @@
|
||||
:error="errors?.firstName"
|
||||
@update:model-value="(v: string) => update('firstName', v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
:model-value="model.jobTitle"
|
||||
:label="t('commercial.clients.form.contact.jobTitle')"
|
||||
:readonly="readonly"
|
||||
:error="errors?.jobTitle"
|
||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||
/>
|
||||
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
|
||||
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
|
||||
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
||||
<div class="col-span-2">
|
||||
<MalioInputText
|
||||
:model-value="model.jobTitle"
|
||||
:label="t('commercial.clients.form.contact.jobTitle')"
|
||||
:readonly="readonly"
|
||||
:error="errors?.jobTitle"
|
||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||
/>
|
||||
</div>
|
||||
<MalioInputEmail
|
||||
:model-value="model.email"
|
||||
:label="t('commercial.clients.form.contact.email')"
|
||||
|
||||
@@ -25,13 +25,18 @@
|
||||
:error="errors?.firstName"
|
||||
@update:model-value="(v: string) => update('firstName', v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
:model-value="model.jobTitle"
|
||||
:label="t('commercial.suppliers.form.contact.jobTitle')"
|
||||
:readonly="readonly"
|
||||
:error="errors?.jobTitle"
|
||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||
/>
|
||||
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
|
||||
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
|
||||
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
||||
<div class="col-span-2">
|
||||
<MalioInputText
|
||||
:model-value="model.jobTitle"
|
||||
:label="t('commercial.suppliers.form.contact.jobTitle')"
|
||||
:readonly="readonly"
|
||||
:error="errors?.jobTitle"
|
||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||
/>
|
||||
</div>
|
||||
<MalioInputEmail
|
||||
:model-value="model.email"
|
||||
:label="t('commercial.suppliers.form.contact.email')"
|
||||
|
||||
@@ -912,17 +912,16 @@ const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
|
||||
function onPaymentTypeChange(value: string | number | null): void {
|
||||
accounting.paymentTypeIri = value === null ? null : String(value)
|
||||
if (!isBankRequired.value) accounting.bankIri = null
|
||||
// Les RIB n'ont de sens que pour une LCR (RG-1.13) : on amorce un bloc vide
|
||||
// quand LCR est choisi, sinon on vide la liste — les RIB deja persistes sont
|
||||
// marques pour suppression serveur au prochain enregistrement.
|
||||
// ERP-121 : un RIB est une coordonnee bancaire du client, decouplee du mode de
|
||||
// reglement. Au passage hors-LCR on ne SUPPRIME plus les RIB existants : ils
|
||||
// restent en base, simplement masques a l'ecran (visibleRibs = []), et
|
||||
// reapparaissent tels quels si l'on repasse en LCR. Seule la corbeille d'un
|
||||
// bloc (askRemoveRib) retire reellement un RIB.
|
||||
if (isRibRequired.value) {
|
||||
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
||||
}
|
||||
else {
|
||||
for (const rib of ribs.value) {
|
||||
if (rib.id != null) removedRibIds.value.push(rib.id)
|
||||
}
|
||||
ribs.value = []
|
||||
// Hors-LCR : on nettoie seulement les erreurs inline (plus affichees).
|
||||
ribErrors.value = []
|
||||
}
|
||||
}
|
||||
@@ -951,50 +950,58 @@ function askRemoveRib(index: number): void {
|
||||
/**
|
||||
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
|
||||
* PATCH des scalaires (groupe client:write:accounting, exige accounting.manage cote
|
||||
* back) PUIS DELETE des RIB retires. Les RIB crees d'abord : le back valide RG-1.13
|
||||
* (LCR => au moins un RIB persiste) sur le PATCH scalaires ; les suppressions en
|
||||
* dernier (le guard back n'autorise la suppression du dernier RIB qu'une fois quitte
|
||||
* LCR). Aucun champ main/information dans le payload (mode strict RG-1.28 : sinon
|
||||
* 403 sur tout le payload).
|
||||
* back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le back
|
||||
* valide RG-1.13 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
|
||||
*
|
||||
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
|
||||
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
|
||||
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES
|
||||
* (corbeille d'un bloc, toujours sous LCR), plus l'auto-suppression au changement
|
||||
* de type de reglement. Aucun champ main/information dans le payload (mode strict
|
||||
* RG-1.28 : sinon 403 sur tout le payload).
|
||||
*/
|
||||
async function submitAccounting(): Promise<void> {
|
||||
if (accountingReadonly.value || tabSubmitting.value) return
|
||||
tabSubmitting.value = true
|
||||
accountingErrors.clearErrors()
|
||||
try {
|
||||
// 1) POST/PATCH des RIB d'abord (erreurs inline par ligne, tous les blocs
|
||||
// tentes). Le back exige >=1 RIB persiste pour valider une LCR a l'etape 2.
|
||||
// 1) POST/PATCH des RIB d'abord — UNIQUEMENT sous LCR (erreurs inline par
|
||||
// ligne, tous les blocs tentes). Le back exige >=1 RIB persiste pour valider
|
||||
// une LCR a l'etape 2. Hors-LCR (ERP-121), les RIB sont des coordonnees
|
||||
// dormantes : rien d'editable n'est affiche, on ne les re-soumet pas.
|
||||
// On ne saute une amorce neuve vide QUE s'il reste un autre RIB soumettable :
|
||||
// sinon (ex. l'unique RIB existant supprime, remplace par un bloc vide), on la
|
||||
// soumet pour declencher la 422 NotBlank inline plutot que de laisser le DELETE
|
||||
// echouer en « dernier RIB d'une LCR » (message plat sans propertyPath).
|
||||
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
|
||||
const ribHasError = await submitRows(
|
||||
ribs.value,
|
||||
ribErrors,
|
||||
async (rib) => {
|
||||
// Edition d'un RIB existant : champ requis vide envoye en `''` (NotBlank
|
||||
// 422) au lieu d'etre omis (sinon le PATCH garderait l'ancienne valeur).
|
||||
const body = buildRibPayload(rib, { forUpdate: rib.id !== null })
|
||||
if (rib.id === null) {
|
||||
const created = await api.post<{ id: number }>(
|
||||
`/clients/${clientId}/ribs`,
|
||||
body,
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
rib.id = created.id
|
||||
}
|
||||
else {
|
||||
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
|
||||
}
|
||||
},
|
||||
error => showError(error),
|
||||
// On ne saute une amorce neuve (id null) totalement vide que si un autre RIB
|
||||
// est soumettable. Un RIB existant vide est toujours soumis -> 422 NotBlank
|
||||
// inline (sinon la modif serait perdue en silence avec un faux toast succes).
|
||||
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
|
||||
)
|
||||
if (ribHasError) return
|
||||
if (isRibRequired.value) {
|
||||
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
|
||||
const ribHasError = await submitRows(
|
||||
ribs.value,
|
||||
ribErrors,
|
||||
async (rib) => {
|
||||
// Edition d'un RIB existant : champ requis vide envoye en `''` (NotBlank
|
||||
// 422) au lieu d'etre omis (sinon le PATCH garderait l'ancienne valeur).
|
||||
const body = buildRibPayload(rib, { forUpdate: rib.id !== null })
|
||||
if (rib.id === null) {
|
||||
const created = await api.post<{ id: number }>(
|
||||
`/clients/${clientId}/ribs`,
|
||||
body,
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
rib.id = created.id
|
||||
}
|
||||
else {
|
||||
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
|
||||
}
|
||||
},
|
||||
error => showError(error),
|
||||
// On ne saute une amorce neuve (id null) totalement vide que si un autre RIB
|
||||
// est soumettable. Un RIB existant vide est toujours soumis -> 422 NotBlank
|
||||
// inline (sinon la modif serait perdue en silence avec un faux toast succes).
|
||||
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
|
||||
)
|
||||
if (ribHasError) return
|
||||
}
|
||||
|
||||
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
|
||||
try {
|
||||
@@ -1005,8 +1012,9 @@ async function submitAccounting(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
// 3) DELETE des RIB retires : APRES le PATCH scalaires (si on quitte LCR, le
|
||||
// guard back n'autorise la suppression du dernier RIB qu'une fois le type change).
|
||||
// 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
|
||||
// PATCH scalaires (le guard back refuse la suppression du dernier RIB d'une
|
||||
// LCR). ERP-121 : plus aucune suppression automatique au passage hors-LCR.
|
||||
for (const id of removedRibIds.value) {
|
||||
await api.delete(`/client_ribs/${id}`, {}, { toast: false })
|
||||
}
|
||||
|
||||
@@ -280,7 +280,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useClient } from '~/modules/commercial/composables/useClient'
|
||||
import { buildClientFormTabKeys } from '~/modules/commercial/utils/clientFormRules'
|
||||
import { buildClientFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/clientFormRules'
|
||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||
import {
|
||||
canEditClient,
|
||||
@@ -290,6 +290,7 @@ import {
|
||||
mapAddressView,
|
||||
mapContactToDraft,
|
||||
mapRibToDraft,
|
||||
paymentTypeCodeOf,
|
||||
referentialOptionOf,
|
||||
relationOf,
|
||||
showArchiveAction,
|
||||
@@ -355,9 +356,16 @@ const addressViews = computed(() => {
|
||||
return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }]
|
||||
})
|
||||
// Exception au placeholder ci-dessus : on n'affiche AUCUN bloc RIB quand le
|
||||
// client n'en a pas (un RIB n'existe que pour un reglement LCR — RG-1.13). Pas
|
||||
// de bloc vierge fantome en consultation.
|
||||
const ribs = computed(() => (client.value?.ribs ?? []).map(mapRibToDraft))
|
||||
// client n'en a pas. Pas de bloc vierge fantome en consultation.
|
||||
// ERP-121 : un client peut desormais conserver des RIB « dormants » apres etre
|
||||
// repasse hors-LCR (on ne les supprime plus). En consultation, decision metier =
|
||||
// on les masque TOTALEMENT : on n'affiche les RIB que si le type de reglement
|
||||
// courant est LCR (le `code` est embarque sous client:read:accounting).
|
||||
const ribs = computed(() =>
|
||||
isRibRequiredForPaymentType(paymentTypeCodeOf(client.value?.paymentType))
|
||||
? (client.value?.ribs ?? []).map(mapRibToDraft)
|
||||
: [],
|
||||
)
|
||||
// Draft comptable (tout null si l'utilisateur n'a pas accounting.view).
|
||||
const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as ClientDetail)))
|
||||
|
||||
|
||||
@@ -881,13 +881,14 @@ function onPaymentTypeChange(value: string | number | null): void {
|
||||
accounting.paymentTypeIri = value === null ? null : String(value)
|
||||
// La banque n'a de sens que pour un virement : on la vide sinon (RG-1.12).
|
||||
if (!isBankRequired.value) accounting.bankIri = null
|
||||
// Les RIB n'ont de sens que pour une LCR (RG-1.13) : on amorce un bloc vide
|
||||
// quand LCR est choisi, on vide la liste sinon (pas de RIB fantome soumis).
|
||||
// ERP-121 : on ne jette plus la saisie RIB au passage hors-LCR. Les blocs sont
|
||||
// masques (visibleRibs = []) mais conserves, et reapparaissent si l'on repasse
|
||||
// en LCR. Ils ne sont persistes qu'a la validation SOUS LCR (cf. submitAccounting),
|
||||
// donc une saisie abandonnee hors-LCR ne cree aucun RIB orphelin.
|
||||
if (isRibRequired.value) {
|
||||
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
||||
}
|
||||
else {
|
||||
ribs.value = []
|
||||
ribErrors.value = []
|
||||
}
|
||||
}
|
||||
@@ -924,36 +925,41 @@ async function submitAccounting(): Promise<void> {
|
||||
tabSubmitting.value = true
|
||||
accountingErrors.clearErrors()
|
||||
try {
|
||||
// 1) POST/PATCH des RIB d'abord (erreurs inline par ligne, tous les blocs
|
||||
// tentes). Le back exige >=1 RIB persiste pour valider une LCR a l'etape 2.
|
||||
// 1) POST/PATCH des RIB d'abord — UNIQUEMENT sous LCR (erreurs inline par
|
||||
// ligne, tous les blocs tentes). Le back exige >=1 RIB persiste pour valider
|
||||
// une LCR a l'etape 2. Hors-LCR (ERP-121), une saisie RIB eventuellement
|
||||
// restee dans le brouillon est masquee et n'est PAS persistee (pas de RIB
|
||||
// orphelin sur un client en virement).
|
||||
// On ne saute une amorce neuve vide QUE s'il reste un autre RIB soumettable :
|
||||
// sinon (LCR sans aucun RIB rempli) on la soumet -> 422 NotBlank inline.
|
||||
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
|
||||
const ribHasError = await submitRows(
|
||||
ribs.value,
|
||||
ribErrors,
|
||||
async (rib) => {
|
||||
// Payload partage avec l'edition (buildRibPayload, ERP-119).
|
||||
const body = buildRibPayload(rib)
|
||||
if (rib.id === null) {
|
||||
const created = await api.post<{ id: number }>(
|
||||
`/clients/${clientId.value}/ribs`,
|
||||
body,
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
rib.id = created.id
|
||||
}
|
||||
else {
|
||||
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
|
||||
}
|
||||
},
|
||||
error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
|
||||
// On ne saute une amorce neuve (id null) totalement vide que si un autre RIB
|
||||
// est soumettable. Un RIB existant vide est toujours soumis -> 422 NotBlank
|
||||
// inline (sinon la modif serait perdue en silence avec un faux toast succes).
|
||||
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
|
||||
)
|
||||
if (ribHasError) return
|
||||
if (isRibRequired.value) {
|
||||
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
|
||||
const ribHasError = await submitRows(
|
||||
ribs.value,
|
||||
ribErrors,
|
||||
async (rib) => {
|
||||
// Payload partage avec l'edition (buildRibPayload, ERP-119).
|
||||
const body = buildRibPayload(rib)
|
||||
if (rib.id === null) {
|
||||
const created = await api.post<{ id: number }>(
|
||||
`/clients/${clientId.value}/ribs`,
|
||||
body,
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
rib.id = created.id
|
||||
}
|
||||
else {
|
||||
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
|
||||
}
|
||||
},
|
||||
error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
|
||||
// On ne saute une amorce neuve (id null) totalement vide que si un autre RIB
|
||||
// est soumettable. Un RIB existant vide est toujours soumis -> 422 NotBlank
|
||||
// inline (sinon la modif serait perdue en silence avec un faux toast succes).
|
||||
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
|
||||
)
|
||||
if (ribHasError) return
|
||||
}
|
||||
|
||||
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
|
||||
try {
|
||||
|
||||
@@ -801,17 +801,16 @@ const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
|
||||
function onPaymentTypeChange(value: string | number | null): void {
|
||||
accounting.paymentTypeIri = value === null ? null : String(value)
|
||||
if (!isBankRequired.value) accounting.bankIri = null
|
||||
// Les RIB n'ont de sens que pour une LCR (RG-2.08) : on amorce un bloc vide
|
||||
// quand LCR est choisi, sinon on vide la liste — les RIB deja persistes sont
|
||||
// marques pour suppression serveur au prochain enregistrement.
|
||||
// ERP-121 : un RIB est une coordonnee bancaire du fournisseur, decouplee du mode
|
||||
// de reglement. Au passage hors-LCR on ne SUPPRIME plus les RIB existants : ils
|
||||
// restent en base, simplement masques a l'ecran (visibleRibs = []), et
|
||||
// reapparaissent tels quels si l'on repasse en LCR. Seule la corbeille d'un
|
||||
// bloc (askRemoveRib) retire reellement un RIB.
|
||||
if (isRibRequired.value) {
|
||||
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
||||
}
|
||||
else {
|
||||
for (const rib of ribs.value) {
|
||||
if (rib.id != null) removedRibIds.value.push(rib.id)
|
||||
}
|
||||
ribs.value = []
|
||||
// Hors-LCR : on nettoie seulement les erreurs inline (plus affichees).
|
||||
ribErrors.value = []
|
||||
}
|
||||
}
|
||||
@@ -840,44 +839,53 @@ function askRemoveRib(index: number): void {
|
||||
/**
|
||||
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
|
||||
* PATCH des scalaires (groupe supplier:write:accounting, exige accounting.manage
|
||||
* cote back) PUIS DELETE des RIB retires. Les RIB crees d'abord : le back valide
|
||||
* RG-2.08 (LCR => au moins un RIB persiste) sur le PATCH scalaires. Aucun champ
|
||||
* main/information dans le payload (mode strict RG-2.16 : sinon 403 sur tout le payload).
|
||||
* cote back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le
|
||||
* back valide RG-2.08 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
|
||||
*
|
||||
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
|
||||
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
|
||||
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES
|
||||
* (corbeille d'un bloc, toujours sous LCR). Aucun champ main/information dans le
|
||||
* payload (mode strict RG-2.16 : sinon 403 sur tout le payload).
|
||||
*/
|
||||
async function submitAccounting(): Promise<void> {
|
||||
if (accountingReadonly.value || tabSubmitting.value) return
|
||||
tabSubmitting.value = true
|
||||
accountingErrors.clearErrors()
|
||||
try {
|
||||
// 1) POST/PATCH des RIB d'abord (erreurs inline par ligne, tous les blocs
|
||||
// tentes). On ne saute une amorce neuve vide QUE s'il reste un autre RIB
|
||||
// 1) POST/PATCH des RIB d'abord — UNIQUEMENT sous LCR (erreurs inline par
|
||||
// ligne, tous les blocs tentes). Hors-LCR (ERP-121), les RIB sont des
|
||||
// coordonnees dormantes : rien d'editable n'est affiche, on ne les re-soumet
|
||||
// pas. On ne saute une amorce neuve vide QUE s'il reste un autre RIB
|
||||
// soumettable : sinon (ex. l'unique RIB existant supprime, remplace par un
|
||||
// bloc vide), on la soumet pour declencher la 422 NotBlank inline plutot que
|
||||
// de laisser le DELETE echouer en « dernier RIB d'une LCR » (message plat).
|
||||
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
|
||||
const ribHasError = await submitRows(
|
||||
ribs.value,
|
||||
ribErrors,
|
||||
async (rib) => {
|
||||
// Edition d'un RIB existant : champ requis vide envoye en `''` (NotBlank
|
||||
// 422) au lieu d'etre omis (sinon le PATCH garderait l'ancienne valeur).
|
||||
const body = buildRibPayload(rib, { forUpdate: rib.id !== null })
|
||||
if (rib.id === null) {
|
||||
const created = await api.post<{ id: number }>(
|
||||
`/suppliers/${supplierId}/ribs`,
|
||||
body,
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
rib.id = created.id
|
||||
}
|
||||
else {
|
||||
await api.patch(`/supplier_ribs/${rib.id}`, body, { toast: false })
|
||||
}
|
||||
},
|
||||
error => showError(error),
|
||||
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
|
||||
)
|
||||
if (ribHasError) return
|
||||
if (isRibRequired.value) {
|
||||
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
|
||||
const ribHasError = await submitRows(
|
||||
ribs.value,
|
||||
ribErrors,
|
||||
async (rib) => {
|
||||
// Edition d'un RIB existant : champ requis vide envoye en `''` (NotBlank
|
||||
// 422) au lieu d'etre omis (sinon le PATCH garderait l'ancienne valeur).
|
||||
const body = buildRibPayload(rib, { forUpdate: rib.id !== null })
|
||||
if (rib.id === null) {
|
||||
const created = await api.post<{ id: number }>(
|
||||
`/suppliers/${supplierId}/ribs`,
|
||||
body,
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
rib.id = created.id
|
||||
}
|
||||
else {
|
||||
await api.patch(`/supplier_ribs/${rib.id}`, body, { toast: false })
|
||||
}
|
||||
},
|
||||
error => showError(error),
|
||||
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
|
||||
)
|
||||
if (ribHasError) return
|
||||
}
|
||||
|
||||
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
|
||||
try {
|
||||
@@ -888,8 +896,9 @@ async function submitAccounting(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
// 3) DELETE des RIB retires : APRES le PATCH scalaires (si on quitte LCR, le
|
||||
// guard back n'autorise la suppression du dernier RIB qu'une fois le type change).
|
||||
// 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
|
||||
// PATCH scalaires (le guard back refuse la suppression du dernier RIB d'une
|
||||
// LCR). ERP-121 : plus aucune suppression automatique au passage hors-LCR.
|
||||
for (const id of removedRibIds.value) {
|
||||
await api.delete(`/supplier_ribs/${id}`, {}, { toast: false })
|
||||
}
|
||||
|
||||
@@ -263,7 +263,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useSupplier } from '~/modules/commercial/composables/useSupplier'
|
||||
import { buildSupplierFormTabKeys } from '~/modules/commercial/utils/supplierFormRules'
|
||||
import { buildSupplierFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/supplierFormRules'
|
||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||
import {
|
||||
canEditSupplier,
|
||||
@@ -274,6 +274,7 @@ import {
|
||||
mapAddressView,
|
||||
mapContactToDraft,
|
||||
mapRibToDraft,
|
||||
paymentTypeCodeOf,
|
||||
referentialOptionOf,
|
||||
showArchiveAction,
|
||||
showRestoreAction,
|
||||
@@ -338,8 +339,15 @@ const addressViews = computed(() => {
|
||||
return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }]
|
||||
})
|
||||
// Exception au placeholder ci-dessus : on n'affiche AUCUN bloc RIB quand le
|
||||
// fournisseur n'en a pas (un RIB n'existe que pour un reglement LCR — RG-2.08).
|
||||
const ribs = computed(() => (supplier.value?.ribs ?? []).map(mapRibToDraft))
|
||||
// fournisseur n'en a pas. ERP-121 : un fournisseur peut desormais conserver des RIB
|
||||
// « dormants » apres etre repasse hors-LCR (on ne les supprime plus). En consultation,
|
||||
// decision metier = on les masque TOTALEMENT : on n'affiche les RIB que si le type de
|
||||
// reglement courant est LCR (le `code` est embarque sous supplier:read:accounting).
|
||||
const ribs = computed(() =>
|
||||
isRibRequiredForPaymentType(paymentTypeCodeOf(supplier.value?.paymentType))
|
||||
? (supplier.value?.ribs ?? []).map(mapRibToDraft)
|
||||
: [],
|
||||
)
|
||||
// Draft comptable (tout null si l'utilisateur n'a pas accounting.view).
|
||||
const accounting = computed(() => mapAccountingDraft(supplier.value ?? ({} as SupplierDetail)))
|
||||
|
||||
|
||||
@@ -745,13 +745,14 @@ function onPaymentTypeChange(value: string | number | null): void {
|
||||
accounting.paymentTypeIri = value === null ? null : String(value)
|
||||
// La banque n'a de sens que pour un virement : on la vide sinon (RG-2.07).
|
||||
if (!isBankRequired.value) accounting.bankIri = null
|
||||
// Les RIB n'ont de sens que pour une LCR (RG-2.08) : amorce un bloc vide quand
|
||||
// LCR est choisi, vide la liste sinon (pas de RIB fantome soumis).
|
||||
// ERP-121 : on ne jette plus la saisie RIB au passage hors-LCR. Les blocs sont
|
||||
// masques (visibleRibs = []) mais conserves, et reapparaissent si l'on repasse
|
||||
// en LCR. Ils ne sont persistes qu'a la validation SOUS LCR (cf. submitAccounting),
|
||||
// donc une saisie abandonnee hors-LCR ne cree aucun RIB orphelin.
|
||||
if (isRibRequired.value) {
|
||||
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
||||
}
|
||||
else {
|
||||
ribs.value = []
|
||||
ribErrors.value = []
|
||||
}
|
||||
}
|
||||
@@ -786,31 +787,36 @@ async function submitAccounting(): Promise<void> {
|
||||
tabSubmitting.value = true
|
||||
accountingErrors.clearErrors()
|
||||
try {
|
||||
// 1) POST/PATCH des RIB d'abord (erreurs inline par ligne). On ne saute une
|
||||
// amorce neuve vide QUE s'il reste un autre RIB soumettable : sinon (LCR sans
|
||||
// aucun RIB rempli) on la soumet pour declencher la 422 NotBlank inline.
|
||||
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
|
||||
const ribHasError = await submitRows(
|
||||
ribs.value,
|
||||
ribErrors,
|
||||
async (rib) => {
|
||||
const body = buildRibPayload(rib)
|
||||
if (rib.id === null) {
|
||||
const created = await api.post<{ id: number }>(
|
||||
`/suppliers/${supplierId.value}/ribs`,
|
||||
body,
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
rib.id = created.id
|
||||
}
|
||||
else {
|
||||
await api.patch(`/supplier_ribs/${rib.id}`, body, { toast: false })
|
||||
}
|
||||
},
|
||||
error => toast.error({ title: t('commercial.suppliers.toast.error'), message: apiErrorMessage(error) }),
|
||||
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
|
||||
)
|
||||
if (ribHasError) return
|
||||
// 1) POST/PATCH des RIB d'abord — UNIQUEMENT sous LCR (erreurs inline par
|
||||
// ligne). Hors-LCR (ERP-121), une saisie RIB eventuellement restee dans le
|
||||
// brouillon est masquee et n'est PAS persistee (pas de RIB orphelin sur un
|
||||
// fournisseur en virement). On ne saute une amorce neuve vide QUE s'il reste
|
||||
// un autre RIB soumettable : sinon (LCR sans aucun RIB rempli) on la soumet
|
||||
// pour declencher la 422 NotBlank inline.
|
||||
if (isRibRequired.value) {
|
||||
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
|
||||
const ribHasError = await submitRows(
|
||||
ribs.value,
|
||||
ribErrors,
|
||||
async (rib) => {
|
||||
const body = buildRibPayload(rib)
|
||||
if (rib.id === null) {
|
||||
const created = await api.post<{ id: number }>(
|
||||
`/suppliers/${supplierId.value}/ribs`,
|
||||
body,
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
rib.id = created.id
|
||||
}
|
||||
else {
|
||||
await api.patch(`/supplier_ribs/${rib.id}`, body, { toast: false })
|
||||
}
|
||||
},
|
||||
error => toast.error({ title: t('commercial.suppliers.toast.error'), message: apiErrorMessage(error) }),
|
||||
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
|
||||
)
|
||||
if (ribHasError) return
|
||||
}
|
||||
|
||||
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
|
||||
try {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
mapAddressView,
|
||||
mapContactToDraft,
|
||||
mapRibToDraft,
|
||||
paymentTypeCodeOf,
|
||||
referentialOptionOf,
|
||||
relationOf,
|
||||
showArchiveAction,
|
||||
@@ -233,3 +234,17 @@ describe('showArchiveAction / showRestoreAction', () => {
|
||||
expect(showRestoreAction(can([]), true)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', () => {
|
||||
it('retourne le code metier quand le type de reglement est embarque', () => {
|
||||
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1', code: 'LCR' })).toBe('LCR')
|
||||
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/2', code: 'VIREMENT' })).toBe('VIREMENT')
|
||||
})
|
||||
|
||||
it('retourne null pour un IRI nu, un objet sans code, ou une relation absente', () => {
|
||||
expect(paymentTypeCodeOf('/api/payment_types/1')).toBeNull()
|
||||
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1' })).toBeNull()
|
||||
expect(paymentTypeCodeOf(null)).toBeNull()
|
||||
expect(paymentTypeCodeOf(undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
mapAddressView,
|
||||
mapContactToDraft,
|
||||
mapRibToDraft,
|
||||
paymentTypeCodeOf,
|
||||
referentialOptionOf,
|
||||
showArchiveAction,
|
||||
showRestoreAction,
|
||||
@@ -222,3 +223,17 @@ describe('showArchiveAction / showRestoreAction', () => {
|
||||
expect(showRestoreAction(can([]), true)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', () => {
|
||||
it('retourne le code metier quand le type de reglement est embarque', () => {
|
||||
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1', code: 'LCR' })).toBe('LCR')
|
||||
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/2', code: 'VIREMENT' })).toBe('VIREMENT')
|
||||
})
|
||||
|
||||
it('retourne null pour un IRI nu, un objet sans code, ou une relation absente', () => {
|
||||
expect(paymentTypeCodeOf('/api/payment_types/1')).toBeNull()
|
||||
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1' })).toBeNull()
|
||||
expect(paymentTypeCodeOf(null)).toBeNull()
|
||||
expect(paymentTypeCodeOf(undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -293,6 +293,21 @@ export function referentialOptionOf(relation: Relation): SelectOption[] {
|
||||
return [{ value: relation['@id'], label }]
|
||||
}
|
||||
|
||||
/**
|
||||
* Code metier d'un referentiel embarque (ex: PaymentType.code = 'LCR' / 'VIREMENT'),
|
||||
* ou null si la relation est absente / serialisee en IRI nu. Type-safe : la branche
|
||||
* chaine (IRI nu) et l'absence sont court-circuitees avant l'acces au code. Sert a
|
||||
* conditionner l'affichage selon le type de reglement courant (ERP-121 : RIB masques
|
||||
* hors-LCR en consultation).
|
||||
*/
|
||||
export function paymentTypeCodeOf(relation: Relation): string | null {
|
||||
if (!relation || typeof relation === 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
return (relation.code as string | undefined) ?? null
|
||||
}
|
||||
|
||||
/** Vue d'une adresse (brouillon + options de select propres a l'adresse). */
|
||||
export function mapAddressView(address: AddressRead): AddressView {
|
||||
return {
|
||||
|
||||
@@ -268,6 +268,21 @@ export function referentialOptionOf(relation: Relation): SelectOption[] {
|
||||
return [{ value: relation['@id'], label }]
|
||||
}
|
||||
|
||||
/**
|
||||
* Code metier d'un referentiel embarque (ex: PaymentType.code = 'LCR' / 'VIREMENT'),
|
||||
* ou null si la relation est absente / serialisee en IRI nu. Type-safe : la branche
|
||||
* chaine (IRI nu) et l'absence sont court-circuitees avant l'acces au code. Sert a
|
||||
* conditionner l'affichage selon le type de reglement courant (ERP-121 : RIB masques
|
||||
* hors-LCR en consultation).
|
||||
*/
|
||||
export function paymentTypeCodeOf(relation: Relation): string | null {
|
||||
if (!relation || typeof relation === 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
return (relation.code as string | undefined) ?? null
|
||||
}
|
||||
|
||||
/** Vue d'une adresse (brouillon + options de select propres a l'adresse). */
|
||||
export function mapAddressView(address: AddressRead): AddressView {
|
||||
return {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export default defineNuxtConfig({})
|
||||
@@ -231,6 +231,7 @@ test-db-setup:
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_code ON category (code) WHERE deleted_at IS NULL"
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_client_company_name_active ON client (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_company_name_active ON supplier (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_provider_company_name_active ON provider (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
|
||||
|
||||
fixtures:
|
||||
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* M3 — Repertoire prestataires (ERP-132) : creation de toute la structure BDD
|
||||
* des prestataires sous le nouveau module Technique (jumeau du M2 fournisseur).
|
||||
*
|
||||
* Tables creees :
|
||||
* - Table principale : provider (formulaire principal + Comptabilite + archive
|
||||
* + soft-delete + Timestampable/Blamable). PAS d onglet Information.
|
||||
* - M2M du formulaire principal : provider_category (RG-3.09),
|
||||
* provider_site (sites du prestataire, RG-3.03 — NOUVEAU vs supplier).
|
||||
* - Sous-collections : provider_contact (1:n), provider_address (1:n),
|
||||
* provider_rib (1:n).
|
||||
* - Jointures de provider_address : provider_address_site (RG-3.05),
|
||||
* provider_address_contact, provider_address_category.
|
||||
*
|
||||
* Differences vs le M2 `supplier` (cf. spec M3 § 3.1) :
|
||||
* - PAS d onglet Information : aucun champ description / competitors /
|
||||
* founded_at / employees_count / revenue_amount / director_name /
|
||||
* profit_amount / volume_forecast. Le provider est minimal : nom + compta.
|
||||
* - AJOUT de provider_site (M2M) : sites rattaches au prestataire directement
|
||||
* sur le formulaire principal (RG-3.03, >= 1). Sert aussi le cloisonnement
|
||||
* par site (idx_provider_site_site, § 2.13).
|
||||
* - provider_address SIMPLIFIEE : pas de address_type / bennes /
|
||||
* triage_provider (specifiques fournisseur). Champs : country / postal_code
|
||||
* / city / street / street_complement / position + M2M sites/contacts/categories.
|
||||
*
|
||||
* Referentiels comptables NON recrees : tva_mode / payment_delay / payment_type
|
||||
* / bank sont ceux du M1 (FK partagees, zero duplication — spec § 2.3).
|
||||
*
|
||||
* CategoryType PRESTATAIRE NON re-seede : il est cree par ERP-131
|
||||
* (Version20260612080000) avec ses categories de demonstration. Le M2M
|
||||
* provider_category / provider_address_category s appuie sur ce type existant.
|
||||
*
|
||||
* Namespace racine `DoctrineMigrations` (regle ABSOLUE Starseed n°11) et NON
|
||||
* `App\Module\Technique\...` : la migration cree un schema avec FK cross-module
|
||||
* (user, category, site, et les referentiels comptables M1). Avec plusieurs
|
||||
* migrations_paths, Doctrine Migrations 3.x trie par FQCN alphabetique — un
|
||||
* namespace modulaire s executerait avant la creation de user/category/site sur
|
||||
* base vide -> echec des FK. Le namespace racine garantit l ordre par timestamp.
|
||||
*
|
||||
* Style DDL aligne sur le M1/M2 (Version20260605130000) : `INT GENERATED BY
|
||||
* DEFAULT AS IDENTITY` (et non SERIAL), `TIMESTAMP(0) WITHOUT TIME ZONE` (et non
|
||||
* TIMESTAMPTZ, car le TimestampableBlamableTrait mappe `datetime_immutable`).
|
||||
* Garantit que `schema:update` restera un no-op quand les entites arriveront
|
||||
* (ticket ERP-133).
|
||||
*
|
||||
* Decision unicite (alignee Q4 M1 / § 2.6 M2) : unicite metier sur le NOM DE
|
||||
* SOCIETE uniquement (uq_provider_company_name_active, partiel). Pas d index
|
||||
* unique sur siren ni email.
|
||||
*
|
||||
* COMMENT ON COLUMN inline (regle ABSOLUE n°12) : chaque colonne metier porte sa
|
||||
* description ici-meme. Volontairement NON ajoutees a `ColumnCommentsCatalog` /
|
||||
* `makefile test-db-setup` a ce stade : tant que les entites Provider* n existent
|
||||
* pas (ERP-133), `schema:update --force` du setup de test droppe ces tables non
|
||||
* mappees — les referencer dans le catalogue ferait planter
|
||||
* `app:apply-column-comments`. Le catalogue + la ligne `dbal:run-sql`
|
||||
* (uq_provider_company_name_active) seront ajoutes au ticket entites (ERP-133),
|
||||
* exactement comme supplier (ERP-86) apres sa migration (ERP-85). Les 4 colonnes
|
||||
* Timestampable/Blamable reutilisent les textes standardises du catalogue
|
||||
* (`timestampableBlamableComments()`, simple tableau statique sans dependance DB).
|
||||
*/
|
||||
final class Version20260612100000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ERP-132 (M3) : tables provider + sous-collections + jointures M2M (referentiels comptables et CategoryType PRESTATAIRE reutilises).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->createProviderTable();
|
||||
$this->createProviderCategory();
|
||||
$this->createProviderSite();
|
||||
$this->createProviderContact();
|
||||
$this->createProviderAddress();
|
||||
$this->createProviderAddressJoinTables();
|
||||
$this->createProviderRib();
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Ordre inverse des dependances FK : jointures et sous-collections
|
||||
// d abord, puis provider. Les referentiels comptables et le
|
||||
// CategoryType PRESTATAIRE ne sont pas touches (crees ailleurs).
|
||||
$this->addSql('DROP TABLE IF EXISTS provider_address_category');
|
||||
$this->addSql('DROP TABLE IF EXISTS provider_address_contact');
|
||||
$this->addSql('DROP TABLE IF EXISTS provider_address_site');
|
||||
$this->addSql('DROP TABLE IF EXISTS provider_rib');
|
||||
$this->addSql('DROP TABLE IF EXISTS provider_address');
|
||||
$this->addSql('DROP TABLE IF EXISTS provider_contact');
|
||||
$this->addSql('DROP TABLE IF EXISTS provider_site');
|
||||
$this->addSql('DROP TABLE IF EXISTS provider_category');
|
||||
$this->addSql('DROP TABLE IF EXISTS provider');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Table principale `provider`
|
||||
// =================================================================
|
||||
|
||||
private function createProviderTable(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE provider (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
company_name VARCHAR(180) NOT NULL,
|
||||
siren VARCHAR(20) DEFAULT NULL,
|
||||
account_number VARCHAR(40) DEFAULT NULL,
|
||||
tva_mode_id INT DEFAULT NULL,
|
||||
n_tva VARCHAR(40) DEFAULT NULL,
|
||||
payment_delay_id INT DEFAULT NULL,
|
||||
payment_type_id INT DEFAULT NULL,
|
||||
bank_id INT DEFAULT NULL,
|
||||
is_archived BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
archived_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
|
||||
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT DEFAULT NULL,
|
||||
updated_by INT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT fk_provider_tva_mode
|
||||
FOREIGN KEY (tva_mode_id) REFERENCES tva_mode (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_provider_payment_delay
|
||||
FOREIGN KEY (payment_delay_id) REFERENCES payment_delay (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_provider_payment_type
|
||||
FOREIGN KEY (payment_type_id) REFERENCES payment_type (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_provider_bank
|
||||
FOREIGN KEY (bank_id) REFERENCES bank (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_provider_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_provider_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE INDEX idx_provider_is_archived ON provider (is_archived)');
|
||||
$this->addSql('CREATE INDEX idx_provider_deleted_at ON provider (deleted_at)');
|
||||
$this->addSql('CREATE INDEX idx_provider_created_by ON provider (created_by)');
|
||||
$this->addSql('CREATE INDEX idx_provider_updated_by ON provider (updated_by)');
|
||||
|
||||
// Index sur les FK des referentiels comptables (Postgres n indexe pas
|
||||
// automatiquement les colonnes portant une FOREIGN KEY).
|
||||
$this->addSql('CREATE INDEX idx_provider_tva_mode_id ON provider (tva_mode_id)');
|
||||
$this->addSql('CREATE INDEX idx_provider_payment_delay_id ON provider (payment_delay_id)');
|
||||
$this->addSql('CREATE INDEX idx_provider_payment_type_id ON provider (payment_type_id)');
|
||||
$this->addSql('CREATE INDEX idx_provider_bank_id ON provider (bank_id)');
|
||||
|
||||
// Unicite metier partielle : nom de societe insensible a la casse, parmi
|
||||
// les non-archives ET non soft-deletes uniquement (RG-3.10). Pas d index
|
||||
// unique sur siren ni email.
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE UNIQUE INDEX uq_provider_company_name_active
|
||||
ON provider (LOWER(company_name))
|
||||
WHERE is_archived = FALSE AND deleted_at IS NULL
|
||||
SQL);
|
||||
|
||||
$this->comment('provider', '_table', 'Repertoire prestataires (M3 Technique) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M4). Pas d onglet Information (≠ supplier).');
|
||||
$this->comment('provider', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('provider', 'company_name', 'Raison sociale du prestataire (stockee en MAJUSCULES). Unique case-insensitive parmi les actifs non archives/non supprimes (uq_provider_company_name_active, RG-3.10).');
|
||||
$this->comment('provider', 'siren', 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-3.10).');
|
||||
$this->comment('provider', 'account_number', 'Onglet Comptabilite : numero de compte comptable du prestataire.');
|
||||
$this->comment('provider', 'tva_mode_id', 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id (referentiel partage M1), ON DELETE RESTRICT.');
|
||||
$this->comment('provider', 'n_tva', 'Onglet Comptabilite : numero de TVA intracommunautaire.');
|
||||
$this->comment('provider', 'payment_delay_id', 'Onglet Comptabilite : delai de reglement — FK -> payment_delay.id (M1), ON DELETE RESTRICT.');
|
||||
$this->comment('provider', 'payment_type_id', 'Onglet Comptabilite : type de reglement — FK -> payment_type.id (M1), ON DELETE RESTRICT. Pilote RG-3.07 (Banque si VIREMENT) et RG-3.08 (RIB).');
|
||||
$this->comment('provider', 'bank_id', 'Onglet Comptabilite : banque — FK -> bank.id (M1), ON DELETE RESTRICT. Obligatoire ssi payment_type = VIREMENT (RG-3.07), null sinon.');
|
||||
$this->comment('provider', 'is_archived', 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission technique.providers.archive.');
|
||||
$this->comment('provider', 'archived_at', 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration.');
|
||||
$this->comment('provider', 'deleted_at', 'Horodatage du soft-delete technique (HP M4) — non expose par l API au M3. Null = ligne active.');
|
||||
$this->addTimestampableBlamableComments('provider');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// M2M provider <-> category (type PRESTATAIRE — RG-3.09)
|
||||
// =================================================================
|
||||
|
||||
private function createProviderCategory(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE provider_category (
|
||||
provider_id INT NOT NULL,
|
||||
category_id INT NOT NULL,
|
||||
PRIMARY KEY (provider_id, category_id),
|
||||
CONSTRAINT fk_provider_category_provider
|
||||
FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_provider_category_category
|
||||
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_provider_category_category ON provider_category (category_id)');
|
||||
|
||||
$this->comment('provider_category', '_table', 'Jointure M2M provider <-> category (Catalog) — categories de type PRESTATAIRE du prestataire, au moins une obligatoire (RG-3.09).');
|
||||
$this->comment('provider_category', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire porteur de la categorie.');
|
||||
$this->comment('provider_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie de type PRESTATAIRE rattachee au prestataire (RG-3.09).');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// M2M provider <-> site (formulaire principal — RG-3.03)
|
||||
// =================================================================
|
||||
|
||||
private function createProviderSite(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE provider_site (
|
||||
provider_id INT NOT NULL,
|
||||
site_id INT NOT NULL,
|
||||
PRIMARY KEY (provider_id, site_id),
|
||||
CONSTRAINT fk_provider_site_provider
|
||||
FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_provider_site_site
|
||||
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT
|
||||
)
|
||||
SQL);
|
||||
// Index sur site_id : sert le filtre de cloisonnement par site
|
||||
// (WHERE site = :currentSite, § 2.13).
|
||||
$this->addSql('CREATE INDEX idx_provider_site_site ON provider_site (site_id)');
|
||||
|
||||
$this->comment('provider_site', '_table', 'Jointure M2M provider <-> site (Sites) — sites du prestataire, selecteur du formulaire principal, au moins un obligatoire (RG-3.03). Sert le cloisonnement par site (§ 2.13).');
|
||||
$this->comment('provider_site', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire porteur du site.');
|
||||
$this->comment('provider_site', 'site_id', 'FK -> site.id, ON DELETE RESTRICT — site rattache au prestataire (RG-3.03, idx_provider_site_site).');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Sous-collection : contacts (1:n)
|
||||
// =================================================================
|
||||
|
||||
private function createProviderContact(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE provider_contact (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
provider_id INT NOT NULL,
|
||||
first_name VARCHAR(120) DEFAULT NULL,
|
||||
last_name VARCHAR(120) DEFAULT NULL,
|
||||
job_title VARCHAR(120) DEFAULT NULL,
|
||||
phone_primary VARCHAR(20) DEFAULT NULL,
|
||||
phone_secondary VARCHAR(20) DEFAULT NULL,
|
||||
email VARCHAR(180) DEFAULT NULL,
|
||||
position INT DEFAULT 0 NOT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT DEFAULT NULL,
|
||||
updated_by INT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT chk_provider_contact_name
|
||||
CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL),
|
||||
CONSTRAINT fk_provider_contact_provider
|
||||
FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_provider_contact_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_provider_contact_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_provider_contact_provider ON provider_contact (provider_id)');
|
||||
|
||||
$this->comment('provider_contact', '_table', 'Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/telephone/email (RG-3.04, chk_provider_contact_name).');
|
||||
$this->comment('provider_contact', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('provider_contact', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du contact.');
|
||||
$this->comment('provider_contact', 'first_name', 'Prenom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).');
|
||||
$this->comment('provider_contact', 'last_name', 'Nom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).');
|
||||
$this->comment('provider_contact', 'job_title', 'Fonction / intitule de poste du contact (≤ 120 caracteres).');
|
||||
$this->comment('provider_contact', 'phone_primary', 'Telephone principal du contact — chiffres uniquement (normalisation serveur).');
|
||||
$this->comment('provider_contact', 'phone_secondary', 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).');
|
||||
$this->comment('provider_contact', 'email', 'Email du contact (lowercase serveur).');
|
||||
$this->comment('provider_contact', 'position', 'Ordre d affichage du contact dans la liste du prestataire (croissant).');
|
||||
$this->addTimestampableBlamableComments('provider_contact');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Sous-collection : adresses (1:n) — SANS address_type / bennes / triage
|
||||
// =================================================================
|
||||
|
||||
private function createProviderAddress(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE provider_address (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
provider_id INT NOT NULL,
|
||||
country VARCHAR(80) DEFAULT 'France' NOT NULL,
|
||||
postal_code VARCHAR(20) NOT NULL,
|
||||
city VARCHAR(120) NOT NULL,
|
||||
street VARCHAR(255) NOT NULL,
|
||||
street_complement VARCHAR(255) DEFAULT NULL,
|
||||
position INT DEFAULT 0 NOT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT DEFAULT NULL,
|
||||
updated_by INT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT fk_provider_address_provider
|
||||
FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_provider_address_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_provider_address_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_provider_address_provider ON provider_address (provider_id)');
|
||||
|
||||
$this->comment('provider_address', '_table', 'Adresses d un prestataire (1:n) — >= 1 site rattache (RG-3.05). SANS address_type / bennes / triage_provider (specifiques fournisseur).');
|
||||
$this->comment('provider_address', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('provider_address', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire de l adresse.');
|
||||
$this->comment('provider_address', 'country', 'Pays de l adresse — defaut France.');
|
||||
$this->comment('provider_address', 'postal_code', 'Code postal (4-5 chiffres attendus) — declenche l autocompletion ville via l API BAN cote front (RG-3.06).');
|
||||
$this->comment('provider_address', 'city', 'Ville — preremplie depuis le code postal via API BAN cote front.');
|
||||
$this->comment('provider_address', 'street', 'Numero et voie de l adresse.');
|
||||
$this->comment('provider_address', 'street_complement', 'Complement d adresse (etage, batiment...) — optionnel.');
|
||||
$this->comment('provider_address', 'position', 'Ordre d affichage de l adresse dans la liste du prestataire (croissant).');
|
||||
$this->addTimestampableBlamableComments('provider_address');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Jointures de provider_address (M2M)
|
||||
// =================================================================
|
||||
|
||||
private function createProviderAddressJoinTables(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE provider_address_site (
|
||||
provider_address_id INT NOT NULL,
|
||||
site_id INT NOT NULL,
|
||||
PRIMARY KEY (provider_address_id, site_id),
|
||||
CONSTRAINT fk_provider_address_site_address
|
||||
FOREIGN KEY (provider_address_id) REFERENCES provider_address (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_provider_address_site_site
|
||||
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT
|
||||
)
|
||||
SQL);
|
||||
$this->comment('provider_address_site', '_table', 'Jointure M2M provider_address <-> site (Sites) — sites rattaches a l adresse (>= 1 obligatoire, RG-3.05).');
|
||||
$this->comment('provider_address_site', 'provider_address_id', 'FK -> provider_address.id, ON DELETE CASCADE — adresse concernee.');
|
||||
$this->comment('provider_address_site', 'site_id', 'FK -> site.id, ON DELETE RESTRICT — site rattache a l adresse.');
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE provider_address_contact (
|
||||
provider_address_id INT NOT NULL,
|
||||
provider_contact_id INT NOT NULL,
|
||||
PRIMARY KEY (provider_address_id, provider_contact_id),
|
||||
CONSTRAINT fk_provider_address_contact_address
|
||||
FOREIGN KEY (provider_address_id) REFERENCES provider_address (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_provider_address_contact_contact
|
||||
FOREIGN KEY (provider_contact_id) REFERENCES provider_contact (id) ON DELETE CASCADE
|
||||
)
|
||||
SQL);
|
||||
$this->comment('provider_address_contact', '_table', 'Jointure M2M provider_address <-> provider_contact — contacts associes a une adresse.');
|
||||
$this->comment('provider_address_contact', 'provider_address_id', 'FK -> provider_address.id, ON DELETE CASCADE — adresse concernee.');
|
||||
$this->comment('provider_address_contact', 'provider_contact_id', 'FK -> provider_contact.id, ON DELETE CASCADE — contact associe a l adresse.');
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE provider_address_category (
|
||||
provider_address_id INT NOT NULL,
|
||||
category_id INT NOT NULL,
|
||||
PRIMARY KEY (provider_address_id, category_id),
|
||||
CONSTRAINT fk_provider_address_category_address
|
||||
FOREIGN KEY (provider_address_id) REFERENCES provider_address (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_provider_address_category_category
|
||||
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT
|
||||
)
|
||||
SQL);
|
||||
$this->comment('provider_address_category', '_table', 'Jointure M2M provider_address <-> category — categories d adresse de type PRESTATAIRE (RG-3.09).');
|
||||
$this->comment('provider_address_category', 'provider_address_id', 'FK -> provider_address.id, ON DELETE CASCADE — adresse concernee.');
|
||||
$this->comment('provider_address_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse de type PRESTATAIRE (RG-3.09).');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Sous-collection : RIB (1:n)
|
||||
// =================================================================
|
||||
|
||||
private function createProviderRib(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE provider_rib (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
provider_id INT NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
bic VARCHAR(20) NOT NULL,
|
||||
iban VARCHAR(34) NOT NULL,
|
||||
position INT DEFAULT 0 NOT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT DEFAULT NULL,
|
||||
updated_by INT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT fk_provider_rib_provider
|
||||
FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_provider_rib_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_provider_rib_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_provider_rib_provider ON provider_rib (provider_id)');
|
||||
|
||||
$this->comment('provider_rib', '_table', 'Coordonnees bancaires d un prestataire (1:n) — >= 1 RIB attendu selon le type de reglement (RG-3.08). Tous les champs audites (pas d AuditIgnore).');
|
||||
$this->comment('provider_rib', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('provider_rib', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du RIB.');
|
||||
$this->comment('provider_rib', 'label', 'Libelle du RIB (ex: compte principal).');
|
||||
$this->comment('provider_rib', 'bic', 'Code BIC/SWIFT de la banque (8 ou 11 caracteres).');
|
||||
$this->comment('provider_rib', 'iban', 'IBAN du compte (≤ 34 caracteres).');
|
||||
$this->comment('provider_rib', 'position', 'Ordre d affichage du RIB dans la liste du prestataire (croissant).');
|
||||
$this->addTimestampableBlamableComments('provider_rib');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Helpers
|
||||
// =================================================================
|
||||
|
||||
/**
|
||||
* Pose les 4 commentaires standardises Timestampable/Blamable sur une table,
|
||||
* en reutilisant le catalogue partage (source unique, cf. ERP-67). Seul le
|
||||
* tableau statique des textes est reutilise — aucune dependance a l etat DB.
|
||||
*/
|
||||
private function addTimestampableBlamableComments(string $table): void
|
||||
{
|
||||
foreach (ColumnCommentsCatalog::timestampableBlamableComments() as $column => $description) {
|
||||
$this->comment($table, $column, $description);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou
|
||||
* `COMMENT ON COLUMN` en dollar-quoting Postgres ($_$...$_$) pour eviter
|
||||
* tout echappement d apostrophe.
|
||||
*/
|
||||
private function comment(string $table, string $column, string $description): void
|
||||
{
|
||||
$quotedTable = '"'.str_replace('"', '""', $table).'"';
|
||||
|
||||
if ('_table' === $column) {
|
||||
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addSql(sprintf(
|
||||
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||
$quotedTable,
|
||||
'"'.str_replace('"', '""', $column).'"',
|
||||
$description,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,9 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
* Fixtures dev/test du module Catalog : categories de demonstration rattachees
|
||||
* a leur CategoryType. Le type CLIENT porte ~11 categories clients (refonte
|
||||
* taxonomie ERP-78) ; le type FOURNISSEUR porte les categories fournisseurs
|
||||
* (ERP-84 : Negociant, Cooperative...). Chaque categorie porte un `code` stable.
|
||||
* (ERP-84 : Negociant, Cooperative...) ; le type PRESTATAIRE porte les categories
|
||||
* prestataires (M3 1.1 : Maintenance industrielle, Nettoyage, Transport). Chaque
|
||||
* categorie porte un `code` stable.
|
||||
* Alimente le repertoire clients (ClientFixtures, module Commercial) avec des
|
||||
* donnees realistes couvrant RG-1.03 (codes DISTRIBUTEUR / COURTIER) et RG-1.29
|
||||
* (codes interdits sur adresse), et le multi-select Categorie fournisseur (M2).
|
||||
@@ -71,6 +73,11 @@ class CategoryFixtures extends Fixture implements DependentFixtureInterface
|
||||
'Grossiste' => 'GROSSISTE',
|
||||
'Importateur' => 'IMPORTATEUR',
|
||||
],
|
||||
'PRESTATAIRE' => [
|
||||
'Maintenance industrielle' => 'MAINTENANCE_INDUSTRIELLE',
|
||||
'Nettoyage' => 'NETTOYAGE',
|
||||
'Transport' => 'TRANSPORT',
|
||||
],
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
|
||||
@@ -21,6 +21,10 @@ use Doctrine\Persistence\ObjectManager;
|
||||
* taxonomie distincte des fournisseurs (Negociant, Cooperative...). Mirroir de
|
||||
* la migration Version20260605120000.
|
||||
*
|
||||
* M3 1.1 : ajout du type PRESTATAIRE (code PRESTATAIRE, label « Prestataire »),
|
||||
* taxonomie distincte des prestataires (Maintenance industrielle, Nettoyage,
|
||||
* Transport). Mirroir de la migration Version20260612080000.
|
||||
*
|
||||
* Pourquoi une fixture EN PLUS du seed de la migration : `category_type` est une
|
||||
* entite managee par l ORM, donc le purger Doctrine la vide avant chaque
|
||||
* `doctrine:fixtures:load`. Sans cette fixture, le type CLIENT seede par la
|
||||
@@ -36,12 +40,13 @@ class CategoryTypeFixtures extends Fixture
|
||||
{
|
||||
/**
|
||||
* Source unique des types : code technique => libelle FR. Doit rester aligne
|
||||
* sur le seed des migrations Version20260602100000 (CLIENT) et
|
||||
* Version20260605120000 (FOURNISSEUR).
|
||||
* sur le seed des migrations Version20260602100000 (CLIENT),
|
||||
* Version20260605120000 (FOURNISSEUR) et Version20260612080000 (PRESTATAIRE).
|
||||
*/
|
||||
private const TYPES = [
|
||||
'CLIENT' => 'Client',
|
||||
'FOURNISSEUR' => 'Fournisseur',
|
||||
'PRESTATAIRE' => 'Prestataire',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Commercial\Domain\Entity\Bank;
|
||||
use App\Module\Commercial\Domain\Entity\PaymentDelay;
|
||||
use App\Module\Commercial\Domain\Entity\PaymentType;
|
||||
use App\Module\Commercial\Domain\Entity\TvaMode;
|
||||
use App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
/**
|
||||
* Prestataire (M3 Technique) — entite racine du repertoire prestataires, jumelle
|
||||
* du Fournisseur (M2). Porte le formulaire principal (nom + categories + sites),
|
||||
* l'onglet Comptabilite, le mecanisme d'archivage (is_archived / archived_at) et
|
||||
* le soft-delete technique prepare mais non expose au M3 (deleted_at, HP M4).
|
||||
*
|
||||
* Differences structurantes vs Supplier (cf. spec M3 § 3.1) :
|
||||
* - PAS d'onglet Information : aucun champ description / competitors / founded_at
|
||||
* / employees_count / revenue_amount / director_name / profit_amount /
|
||||
* volume_forecast. Le prestataire est minimal : nom + comptabilite.
|
||||
* - AJOUT de `sites` (M2M `provider_site`) : sites rattaches DIRECTEMENT au
|
||||
* prestataire sur le formulaire principal (RG-3.03, >= 1). Nouveau vs supplier
|
||||
* (qui n'avait des sites que sur l'adresse). Sert aussi le cloisonnement par
|
||||
* site (§ 2.13, ticket Provider/Processor ERP-134).
|
||||
*
|
||||
* Referentiels comptables (TvaMode / PaymentDelay / PaymentType / Bank) et Site /
|
||||
* Category : consommes en RELATION ORM PARTAGEE (decision Matthieu, § 2.1). Site /
|
||||
* Category passent par les contrats Shared (SiteInterface / CategoryInterface +
|
||||
* resolve_target_entities) comme le fait deja Supplier (regle ABSOLUE n°1). Les 4
|
||||
* referentiels comptables vivent dans le module Commercial et sont references en
|
||||
* direct, faute de contrat Shared dedie (remontee dans Shared tracee HP-M4-2) —
|
||||
* reference de donnees de reference, pas de logique inter-module.
|
||||
*
|
||||
* Contrat de serialisation (RETEX M1, 3 maillons — spec § 4.0) : les read-groups
|
||||
* sont poses ICI (source unique). L'#[ApiResource] est ici un SQUELETTE (operations
|
||||
* + contextes + security) ; le ProviderProvider (liste paginee anti-N+1, exclusion
|
||||
* archives, cloisonnement site, gating accounting) et le ProviderProcessor
|
||||
* (normalisation, archivage, 409 doublon, RG-3.07 / RG-3.08) sont cables au ticket
|
||||
* suivant (ERP-134) — ils ne sont volontairement PAS references ici.
|
||||
*
|
||||
* Audite (#[Auditable], tous champs — y compris RIB embarques, § 2.7) +
|
||||
* Timestampable / Blamable via le trait Shared.
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
security: "is_granted('technique.providers.view')",
|
||||
// La liste embarque les categories (code/name, groupe category:read) et
|
||||
// les sites du prestataire (name/postalCode, groupe site:read — relation
|
||||
// DIRECTE provider.sites, RG-3.03). Maillon (c) : category:read +
|
||||
// site:read presents dans le contexte. L'hydratation anti-N+1 sera
|
||||
// cablee par le ProviderProvider (ERP-134, cf. DoctrineProviderRepository).
|
||||
normalizationContext: ['groups' => ['provider:read', 'category:read', 'site:read', 'default:read']],
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('technique.providers.view')",
|
||||
// Detail : prestataire + sous-collections embarquees (contacts, adresses
|
||||
// + leurs sites/categories/contacts) + RIB (gates compta). Le groupe
|
||||
// provider:read:accounting est volontairement ABSENT : il sera ajoute au
|
||||
// contexte par le ProviderProvider / ReadGroupContextBuilder selon la
|
||||
// permission accounting.view (ERP-134, parade fuite IBAN/BIC — bug #4 M1).
|
||||
normalizationContext: ['groups' => [
|
||||
'provider:read',
|
||||
'provider:item:read',
|
||||
'category:read',
|
||||
'site:read',
|
||||
'default:read',
|
||||
]],
|
||||
),
|
||||
new Post(
|
||||
security: "is_granted('technique.providers.manage')",
|
||||
normalizationContext: ['groups' => ['provider:read', 'category:read', 'site:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['provider:write:main']],
|
||||
),
|
||||
new Patch(
|
||||
// Security elargie : `manage` OU `accounting.manage` — le role Compta n'a
|
||||
// pas `manage` global mais doit pouvoir editer l'onglet Comptabilite d'un
|
||||
// prestataire existant (§ 2.9). Le re-gating onglet par onglet (mode strict
|
||||
// RG-3.15) est porte par le ProviderProcessor (ERP-134).
|
||||
security: "is_granted('technique.providers.manage') or is_granted('technique.providers.accounting.manage')",
|
||||
normalizationContext: ['groups' => ['provider:read', 'category:read', 'site:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => [
|
||||
'provider:write:main',
|
||||
'provider:write:accounting',
|
||||
'provider:write:archive',
|
||||
]],
|
||||
),
|
||||
// Pas de Delete au M3 (HP M4). Archivage via PATCH { isArchived: true }.
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineProviderRepository::class)]
|
||||
#[ORM\Table(name: 'provider')]
|
||||
// Index nommes pour matcher la migration (Version20260612100000). L'index unique
|
||||
// partiel uq_provider_company_name_active reste possede par la migration : Doctrine
|
||||
// ORM ne sait pas exprimer un index fonctionnel (LOWER) + partiel (WHERE) via
|
||||
// attribut. Pas de #[ORM\UniqueConstraint] (§ 2.6).
|
||||
#[ORM\Index(name: 'idx_provider_is_archived', columns: ['is_archived'])]
|
||||
#[ORM\Index(name: 'idx_provider_deleted_at', columns: ['deleted_at'])]
|
||||
#[ORM\Index(name: 'idx_provider_created_by', columns: ['created_by'])]
|
||||
#[ORM\Index(name: 'idx_provider_updated_by', columns: ['updated_by'])]
|
||||
#[Auditable]
|
||||
class Provider implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
/**
|
||||
* RG-3.09 : seules les categories PORTANT ce type sont autorisees sur le
|
||||
* prestataire (entite principale) ET sur ses adresses. Miroir de
|
||||
* ProviderAddress. S'appuie sur CategoryInterface::getCategoryTypeCodes()
|
||||
* (pas d'import du module Catalog — regle ABSOLUE n°1).
|
||||
*/
|
||||
private const string REQUIRED_CATEGORY_TYPE_CODE = 'PRESTATAIRE';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['provider:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
// === Formulaire principal ===
|
||||
#[ORM\Column(length: 180)]
|
||||
#[Assert\NotBlank(message: 'Le nom du prestataire est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(min: 2, max: 180, minMessage: 'Le nom du prestataire doit comporter au moins {{ limit }} caractères.', maxMessage: 'Le nom du prestataire ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['provider:read', 'provider:write:main'])]
|
||||
private ?string $companyName = null;
|
||||
|
||||
// RG-3.09 : au moins une categorie (Count min 1), de type PRESTATAIRE (verifie
|
||||
// par validateCategoryType). M2M vers Category via le contrat CategoryInterface
|
||||
// (resolve_target_entities -> Category). Embarquee en LISTE ET DETAIL ; maillon
|
||||
// (c) : le contexte inclut 'category:read' pour exposer id/code/name.
|
||||
/** @var Collection<int, CategoryInterface> */
|
||||
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
|
||||
#[ORM\JoinTable(name: 'provider_category')]
|
||||
#[ORM\JoinColumn(name: 'provider_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
|
||||
#[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')]
|
||||
#[Groups(['provider:read', 'provider:write:main'])]
|
||||
private Collection $categories;
|
||||
|
||||
// RG-3.03 (SPECIFICITE M3) : au moins un site (Count min 1). Sites rattaches
|
||||
// DIRECTEMENT au prestataire sur le formulaire principal (le fournisseur n'avait
|
||||
// des sites que sur l'adresse). M2M vers Site via le contrat SiteInterface
|
||||
// (resolve_target_entities -> Site). Embarquee en LISTE ET DETAIL ; maillon (c) :
|
||||
// le contexte inclut 'site:read' pour exposer name/postalCode (Site n'a pas de
|
||||
// `code`). L'ecriture cloisonnee par user_site (§ 2.13) est portee par le
|
||||
// ProviderProcessor (ERP-134).
|
||||
/** @var Collection<int, SiteInterface> */
|
||||
#[ORM\ManyToMany(targetEntity: SiteInterface::class)]
|
||||
#[ORM\JoinTable(name: 'provider_site')]
|
||||
#[ORM\JoinColumn(name: 'provider_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
|
||||
#[Assert\Count(min: 1, minMessage: 'Au moins un site est obligatoire.')]
|
||||
#[Groups(['provider:read', 'provider:write:main'])]
|
||||
private Collection $sites;
|
||||
|
||||
// === Onglet Comptabilite ===
|
||||
// Lecture conditionnee via le groupe `provider:read:accounting` (ajoute au
|
||||
// contexte par le ProviderProvider / ReadGroupContextBuilder si l'user a
|
||||
// accounting.view — ERP-134). Ecriture via `provider:write:accounting` (le
|
||||
// Processor exige accounting.manage).
|
||||
#[ORM\Column(length: 20, nullable: true)]
|
||||
#[Assert\Length(max: 20, maxMessage: 'Le SIREN ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
|
||||
private ?string $siren = null;
|
||||
|
||||
#[ORM\Column(length: 40, nullable: true)]
|
||||
#[Assert\Length(max: 40, maxMessage: 'Le numéro de compte ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
|
||||
private ?string $accountNumber = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: TvaMode::class)]
|
||||
#[ORM\JoinColumn(name: 'tva_mode_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
||||
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
|
||||
private ?TvaMode $tvaMode = null;
|
||||
|
||||
#[ORM\Column(length: 40, nullable: true)]
|
||||
#[Assert\Length(max: 40, maxMessage: 'Le numéro de TVA ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
|
||||
private ?string $nTva = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: PaymentDelay::class)]
|
||||
#[ORM\JoinColumn(name: 'payment_delay_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
||||
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
|
||||
private ?PaymentDelay $paymentDelay = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: PaymentType::class)]
|
||||
#[ORM\JoinColumn(name: 'payment_type_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
||||
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
|
||||
private ?PaymentType $paymentType = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Bank::class)]
|
||||
#[ORM\JoinColumn(name: 'bank_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
||||
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
|
||||
private ?Bank $bank = null;
|
||||
|
||||
// === Sous-collections — EMBARQUEES dans le DETAIL (RETEX M1 §2) ===
|
||||
// Maillon (a) : le read-group est porte par le GETTER (getContacts / getAddresses
|
||||
// / getRibs) — sans #[Groups], jamais serialisees. Edition via sous-ressources
|
||||
// (ticket ulterieur M3).
|
||||
/** @var Collection<int, ProviderContact> */
|
||||
#[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
private Collection $contacts;
|
||||
|
||||
/** @var Collection<int, ProviderAddress> */
|
||||
#[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
private Collection $addresses;
|
||||
|
||||
/** @var Collection<int, ProviderRib> */
|
||||
#[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderRib::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
private Collection $ribs;
|
||||
|
||||
// === Archive / Soft delete ===
|
||||
// Groupe d'ECRITURE uniquement sur la propriete (denormalisation PATCH archive).
|
||||
// Le groupe de LECTURE est declare sur le getter isArchived() avec
|
||||
// SerializedName('isArchived') : sans cela, Symfony strip le prefixe "is" et
|
||||
// exposerait la cle JSON "archived" — en pratique la cle est totalement DROPPEE
|
||||
// (piege n°3 du M1). Pattern corrige : Groups + SerializedName sur le getter.
|
||||
#[ORM\Column(name: 'is_archived', options: ['default' => false])]
|
||||
#[Groups(['provider:write:archive'])]
|
||||
private bool $isArchived = false;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||
#[Groups(['provider:read'])]
|
||||
private ?DateTimeImmutable $archivedAt = null;
|
||||
|
||||
// Soft delete technique (HP M4) : non expose en lecture/ecriture au M3.
|
||||
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||
private ?DateTimeImmutable $deletedAt = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->categories = new ArrayCollection();
|
||||
$this->sites = new ArrayCollection();
|
||||
$this->contacts = new ArrayCollection();
|
||||
$this->addresses = new ArrayCollection();
|
||||
$this->ribs = new ArrayCollection();
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.09 : toute categorie posee sur le prestataire doit etre de type
|
||||
* PRESTATAIRE -> sinon 422 avec violation sur le champ `categories`
|
||||
* (propertyPath aligne ERP-101, message FR ERP-107). Miroir de
|
||||
* ProviderAddress::validateCategoryType. S'appuie sur
|
||||
* CategoryInterface::getCategoryTypeCodes() (multi-type — la categorie est
|
||||
* acceptee des qu'elle PORTE le type PRESTATAIRE ; pas d'import du module
|
||||
* Catalog, regle ABSOLUE n°1). Joue avant la base via la validation API
|
||||
* Platform, sur POST (categories ∈ provider:write:main) comme sur PATCH.
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validateCategoryType(ExecutionContextInterface $context): void
|
||||
{
|
||||
foreach ($this->categories as $category) {
|
||||
if ($category instanceof CategoryInterface
|
||||
&& !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) {
|
||||
$context->buildViolation('Type de catégorie non autorisé (PRESTATAIRE attendu).')
|
||||
->atPath('categories')
|
||||
->addViolation()
|
||||
;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getCompanyName(): ?string
|
||||
{
|
||||
return $this->companyName;
|
||||
}
|
||||
|
||||
public function setCompanyName(string $companyName): static
|
||||
{
|
||||
$this->companyName = $companyName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, CategoryInterface> */
|
||||
public function getCategories(): Collection
|
||||
{
|
||||
return $this->categories;
|
||||
}
|
||||
|
||||
public function addCategory(CategoryInterface $category): static
|
||||
{
|
||||
if (!$this->categories->contains($category)) {
|
||||
$this->categories->add($category);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeCategory(CategoryInterface $category): static
|
||||
{
|
||||
$this->categories->removeElement($category);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, SiteInterface> */
|
||||
public function getSites(): Collection
|
||||
{
|
||||
return $this->sites;
|
||||
}
|
||||
|
||||
public function addSite(SiteInterface $site): static
|
||||
{
|
||||
if (!$this->sites->contains($site)) {
|
||||
$this->sites->add($site);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeSite(SiteInterface $site): static
|
||||
{
|
||||
$this->sites->removeElement($site);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSiren(): ?string
|
||||
{
|
||||
return $this->siren;
|
||||
}
|
||||
|
||||
public function setSiren(?string $siren): static
|
||||
{
|
||||
$this->siren = $siren;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAccountNumber(): ?string
|
||||
{
|
||||
return $this->accountNumber;
|
||||
}
|
||||
|
||||
public function setAccountNumber(?string $accountNumber): static
|
||||
{
|
||||
$this->accountNumber = $accountNumber;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTvaMode(): ?TvaMode
|
||||
{
|
||||
return $this->tvaMode;
|
||||
}
|
||||
|
||||
public function setTvaMode(?TvaMode $tvaMode): static
|
||||
{
|
||||
$this->tvaMode = $tvaMode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getNTva(): ?string
|
||||
{
|
||||
return $this->nTva;
|
||||
}
|
||||
|
||||
public function setNTva(?string $nTva): static
|
||||
{
|
||||
$this->nTva = $nTva;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPaymentDelay(): ?PaymentDelay
|
||||
{
|
||||
return $this->paymentDelay;
|
||||
}
|
||||
|
||||
public function setPaymentDelay(?PaymentDelay $paymentDelay): static
|
||||
{
|
||||
$this->paymentDelay = $paymentDelay;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPaymentType(): ?PaymentType
|
||||
{
|
||||
return $this->paymentType;
|
||||
}
|
||||
|
||||
public function setPaymentType(?PaymentType $paymentType): static
|
||||
{
|
||||
$this->paymentType = $paymentType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBank(): ?Bank
|
||||
{
|
||||
return $this->bank;
|
||||
}
|
||||
|
||||
public function setBank(?Bank $bank): static
|
||||
{
|
||||
$this->bank = $bank;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, ProviderContact> */
|
||||
#[Groups(['provider:item:read'])]
|
||||
public function getContacts(): Collection
|
||||
{
|
||||
return $this->contacts;
|
||||
}
|
||||
|
||||
public function addContact(ProviderContact $contact): static
|
||||
{
|
||||
if (!$this->contacts->contains($contact)) {
|
||||
$this->contacts->add($contact);
|
||||
$contact->setProvider($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeContact(ProviderContact $contact): static
|
||||
{
|
||||
if ($this->contacts->removeElement($contact) && $contact->getProvider() === $this) {
|
||||
$contact->setProvider(null);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, ProviderAddress> */
|
||||
#[Groups(['provider:item:read'])]
|
||||
public function getAddresses(): Collection
|
||||
{
|
||||
return $this->addresses;
|
||||
}
|
||||
|
||||
public function addAddress(ProviderAddress $address): static
|
||||
{
|
||||
if (!$this->addresses->contains($address)) {
|
||||
$this->addresses->add($address);
|
||||
$address->setProvider($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeAddress(ProviderAddress $address): static
|
||||
{
|
||||
if ($this->addresses->removeElement($address) && $address->getProvider() === $this) {
|
||||
$address->setProvider(null);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
// Embed gate sur le groupe COMPTABLE (et non provider:item:read comme contacts/
|
||||
// adresses) : provider:read:accounting n'est ajoute au contexte que si l'user a
|
||||
// accounting.view (ProviderProvider / ReadGroupContextBuilder, ERP-134). Resultat :
|
||||
// la cle `ribs` est TOTALEMENT ABSENTE du detail pour un user sans accounting.view
|
||||
// (ex. Commerciale), au meme titre que les scalaires comptables — evite la fuite
|
||||
// IBAN/BIC (piege n°4 M1).
|
||||
/** @return Collection<int, ProviderRib> */
|
||||
#[Groups(['provider:read:accounting'])]
|
||||
public function getRibs(): Collection
|
||||
{
|
||||
return $this->ribs;
|
||||
}
|
||||
|
||||
public function addRib(ProviderRib $rib): static
|
||||
{
|
||||
if (!$this->ribs->contains($rib)) {
|
||||
$this->ribs->add($rib);
|
||||
$rib->setProvider($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeRib(ProviderRib $rib): static
|
||||
{
|
||||
if ($this->ribs->removeElement($rib) && $rib->getProvider() === $this) {
|
||||
$rib->setProvider(null);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
// Groupe de lecture + nom serialise explicite : sans SerializedName, Symfony
|
||||
// exposerait la cle "archived" (strip du prefixe "is" sur les getters) et
|
||||
// droppait silencieusement la cle du JSON (piege n°3 du M1).
|
||||
#[Groups(['provider:read'])]
|
||||
#[SerializedName('isArchived')]
|
||||
public function isArchived(): bool
|
||||
{
|
||||
return $this->isArchived;
|
||||
}
|
||||
|
||||
public function setIsArchived(bool $isArchived): static
|
||||
{
|
||||
$this->isArchived = $isArchived;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getArchivedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->archivedAt;
|
||||
}
|
||||
|
||||
public function setArchivedAt(?DateTimeImmutable $archivedAt): static
|
||||
{
|
||||
$this->archivedAt = $archivedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDeletedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->deletedAt;
|
||||
}
|
||||
|
||||
public function setDeletedAt(?DateTimeImmutable $deletedAt): static
|
||||
{
|
||||
$this->deletedAt = $deletedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Domain\Entity;
|
||||
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
/**
|
||||
* Adresse d'un prestataire (1:n) — onglet Adresse. Version SIMPLIFIEE de
|
||||
* SupplierAddress : PAS de address_type (PROSPECT/DEPART/RENDU), PAS de bennes,
|
||||
* PAS de triage_provider (champs specifiques fournisseur). Champs : country /
|
||||
* postal_code / city / street / street_complement + M2M sites / contacts /
|
||||
* categories.
|
||||
*
|
||||
* Relations M2M :
|
||||
* - sites : SiteInterface (module Sites) via resolve_target_entities — au moins
|
||||
* un site obligatoire (RG-3.05, Assert\Count). Site n'a pas de `code`.
|
||||
* - contacts : ProviderContact (meme module).
|
||||
* - categories : CategoryInterface (module Catalog) via resolve_target_entities —
|
||||
* type PRESTATAIRE attendu (RG-3.09, Assert\Callback validateCategoryType).
|
||||
*
|
||||
* Embarquee sous `provider.addresses` au detail (groupe provider:item:read,
|
||||
* maillon (a)). L'exposition en SOUS-RESSOURCE API est un ticket ulterieur du M3 :
|
||||
* pas d'#[ApiResource] ici.
|
||||
*
|
||||
* Audite (#[Auditable]) + Timestampable / Blamable.
|
||||
*/
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'provider_address')]
|
||||
#[ORM\Index(name: 'idx_provider_address_provider', columns: ['provider_id'])]
|
||||
#[Auditable]
|
||||
class ProviderAddress implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
/**
|
||||
* RG-3.09 : seules les categories PORTANT ce type sont autorisees sur une
|
||||
* adresse prestataire. S'appuie sur CategoryInterface::getCategoryTypeCodes()
|
||||
* (pas d'import du module Catalog — regle ABSOLUE n°1).
|
||||
*/
|
||||
private const string REQUIRED_CATEGORY_TYPE_CODE = 'PRESTATAIRE';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['provider:item:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Provider::class, inversedBy: 'addresses')]
|
||||
#[ORM\JoinColumn(name: 'provider_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ?Provider $provider = null;
|
||||
|
||||
#[ORM\Column(length: 80, options: ['default' => 'France'])]
|
||||
#[Assert\Length(max: 80, maxMessage: 'Le pays ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['provider:item:read', 'provider:write:addresses'])]
|
||||
private string $country = 'France';
|
||||
|
||||
// RG-3.06 : code postal a 4 ou 5 chiffres (pas de controle CP/ville serveur).
|
||||
// Le Regex borne deja la longueur (<= 5) : pas de Length redondant (whitelist
|
||||
// ERP-107).
|
||||
#[ORM\Column(length: 20)]
|
||||
#[Assert\NotBlank(message: 'Le code postal est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: '/^[0-9]{4,5}$/', message: 'Le code postal doit comporter 4 ou 5 chiffres.')]
|
||||
#[Groups(['provider:item:read', 'provider:write:addresses'])]
|
||||
private ?string $postalCode = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Assert\NotBlank(message: 'La ville est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['provider:item:read', 'provider:write:addresses'])]
|
||||
private ?string $city = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Assert\NotBlank(message: 'La rue est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(max: 255, maxMessage: 'La rue ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['provider:item:read', 'provider:write:addresses'])]
|
||||
private ?string $street = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['provider:item:read', 'provider:write:addresses'])]
|
||||
private ?string $streetComplement = null;
|
||||
|
||||
// Ordre d'affichage de l'adresse (gere serveur, non expose au M3).
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
private int $position = 0;
|
||||
|
||||
// RG-3.05 : au moins un site rattache a chaque adresse.
|
||||
/** @var Collection<int, SiteInterface> */
|
||||
#[ORM\ManyToMany(targetEntity: SiteInterface::class)]
|
||||
#[ORM\JoinTable(name: 'provider_address_site')]
|
||||
#[ORM\JoinColumn(name: 'provider_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
|
||||
#[Assert\Count(min: 1, minMessage: 'Au moins un site est obligatoire.')]
|
||||
#[Groups(['provider:item:read', 'provider:write:addresses'])]
|
||||
private Collection $sites;
|
||||
|
||||
/** @var Collection<int, ProviderContact> */
|
||||
#[ORM\ManyToMany(targetEntity: ProviderContact::class)]
|
||||
#[ORM\JoinTable(name: 'provider_address_contact')]
|
||||
#[ORM\JoinColumn(name: 'provider_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'provider_contact_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[Groups(['provider:item:read', 'provider:write:addresses'])]
|
||||
private Collection $contacts;
|
||||
|
||||
// RG-3.09 : au moins une categorie de type PRESTATAIRE par adresse (le type est
|
||||
// controle par validateCategoryType ; le minimum par Assert\Count, miroir sites).
|
||||
/** @var Collection<int, CategoryInterface> */
|
||||
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
|
||||
#[ORM\JoinTable(name: 'provider_address_category')]
|
||||
#[ORM\JoinColumn(name: 'provider_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
|
||||
#[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')]
|
||||
#[Groups(['provider:item:read', 'provider:write:addresses'])]
|
||||
private Collection $categories;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->sites = new ArrayCollection();
|
||||
$this->contacts = new ArrayCollection();
|
||||
$this->categories = new ArrayCollection();
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.09 : toute categorie posee sur une adresse prestataire doit etre de
|
||||
* type PRESTATAIRE -> sinon 422 avec violation sur le champ `categories`
|
||||
* (propertyPath aligne ERP-101, message FR ERP-107). S'appuie sur
|
||||
* CategoryInterface::getCategoryTypeCodes() (multi-type — la categorie est
|
||||
* acceptee des qu'elle PORTE le type PRESTATAIRE ; pas d'import du module
|
||||
* Catalog, regle ABSOLUE n°1). Joue avant la base via la validation API Platform.
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validateCategoryType(ExecutionContextInterface $context): void
|
||||
{
|
||||
foreach ($this->categories as $category) {
|
||||
if ($category instanceof CategoryInterface
|
||||
&& !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) {
|
||||
$context->buildViolation('Type de catégorie non autorisé (PRESTATAIRE attendu).')
|
||||
->atPath('categories')
|
||||
->addViolation()
|
||||
;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getProvider(): ?Provider
|
||||
{
|
||||
return $this->provider;
|
||||
}
|
||||
|
||||
public function setProvider(?Provider $provider): static
|
||||
{
|
||||
$this->provider = $provider;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCountry(): string
|
||||
{
|
||||
return $this->country;
|
||||
}
|
||||
|
||||
public function setCountry(string $country): static
|
||||
{
|
||||
$this->country = $country;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPostalCode(): ?string
|
||||
{
|
||||
return $this->postalCode;
|
||||
}
|
||||
|
||||
public function setPostalCode(?string $postalCode): static
|
||||
{
|
||||
$this->postalCode = $postalCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCity(): ?string
|
||||
{
|
||||
return $this->city;
|
||||
}
|
||||
|
||||
public function setCity(?string $city): static
|
||||
{
|
||||
$this->city = $city;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStreet(): ?string
|
||||
{
|
||||
return $this->street;
|
||||
}
|
||||
|
||||
public function setStreet(?string $street): static
|
||||
{
|
||||
$this->street = $street;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStreetComplement(): ?string
|
||||
{
|
||||
return $this->streetComplement;
|
||||
}
|
||||
|
||||
public function setStreetComplement(?string $streetComplement): static
|
||||
{
|
||||
$this->streetComplement = $streetComplement;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, SiteInterface> */
|
||||
public function getSites(): Collection
|
||||
{
|
||||
return $this->sites;
|
||||
}
|
||||
|
||||
public function addSite(SiteInterface $site): static
|
||||
{
|
||||
if (!$this->sites->contains($site)) {
|
||||
$this->sites->add($site);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeSite(SiteInterface $site): static
|
||||
{
|
||||
$this->sites->removeElement($site);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, ProviderContact> */
|
||||
public function getContacts(): Collection
|
||||
{
|
||||
return $this->contacts;
|
||||
}
|
||||
|
||||
public function addContact(ProviderContact $contact): static
|
||||
{
|
||||
if (!$this->contacts->contains($contact)) {
|
||||
$this->contacts->add($contact);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeContact(ProviderContact $contact): static
|
||||
{
|
||||
$this->contacts->removeElement($contact);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, CategoryInterface> */
|
||||
public function getCategories(): Collection
|
||||
{
|
||||
return $this->categories;
|
||||
}
|
||||
|
||||
public function addCategory(CategoryInterface $category): static
|
||||
{
|
||||
if (!$this->categories->contains($category)) {
|
||||
$this->categories->add($category);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeCategory(CategoryInterface $category): static
|
||||
{
|
||||
$this->categories->removeElement($category);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Domain\Entity;
|
||||
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Contact d'un prestataire (1:n) — onglet Contacts. Un bloc est valide des qu'au
|
||||
* moins un champ est rempli (RG-3.04) : garantie portee par un CHECK BDD
|
||||
* (chk_provider_contact_name) + le ProviderProcessor (ERP-134) ; l'entite reste
|
||||
* permissive (tous les champs nullable).
|
||||
*
|
||||
* Embarque sous `provider.contacts` au detail (groupe provider:item:read,
|
||||
* maillon (a) du contrat de serialisation). Maximum 2 telephones
|
||||
* (phonePrimary + phoneSecondary).
|
||||
*
|
||||
* L'exposition en SOUS-RESSOURCE API (POST /providers/{id}/contacts, PATCH /
|
||||
* DELETE) est un ticket ulterieur du M3 : pas d'#[ApiResource] ici (l'entite est
|
||||
* pour l'instant uniquement embarquee via le detail du prestataire).
|
||||
*
|
||||
* Audite (#[Auditable]) + Timestampable / Blamable (pattern Shared standard).
|
||||
*/
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'provider_contact')]
|
||||
#[ORM\Index(name: 'idx_provider_contact_provider', columns: ['provider_id'])]
|
||||
#[Auditable]
|
||||
class ProviderContact implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['provider:item:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Provider::class, inversedBy: 'contacts')]
|
||||
#[ORM\JoinColumn(name: 'provider_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ?Provider $provider = null;
|
||||
|
||||
// RG-3.04 : au moins un champ du contact renseigne (CHECK BDD + Processor). Les
|
||||
// champs restent nullable au niveau ORM.
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['provider:item:read', 'provider:write:contacts'])]
|
||||
private ?string $firstName = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['provider:item:read', 'provider:write:contacts'])]
|
||||
private ?string $lastName = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['provider:item:read', 'provider:write:contacts'])]
|
||||
private ?string $jobTitle = null;
|
||||
|
||||
// Pas de validation de format telephone (saisie libre), mais une Assert\Length
|
||||
// calee sur la colonne VARCHAR(20) evite l'erreur Postgres (500 non rattachee au
|
||||
// champ) au profit d'une 422 propre (ERP-107).
|
||||
#[ORM\Column(length: 20, nullable: true)]
|
||||
#[Assert\Length(max: 20, maxMessage: 'Le téléphone ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['provider:item:read', 'provider:write:contacts'])]
|
||||
private ?string $phonePrimary = null;
|
||||
|
||||
#[ORM\Column(length: 20, nullable: true)]
|
||||
#[Assert\Length(max: 20, maxMessage: 'Le téléphone secondaire ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['provider:item:read', 'provider:write:contacts'])]
|
||||
private ?string $phoneSecondary = null;
|
||||
|
||||
#[ORM\Column(length: 180, nullable: true)]
|
||||
#[Assert\Email(message: 'L\'adresse email n\'est pas valide.')]
|
||||
#[Assert\Length(max: 180, maxMessage: 'L\'email ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['provider:item:read', 'provider:write:contacts'])]
|
||||
private ?string $email = null;
|
||||
|
||||
// Ordre d'affichage du contact (gere serveur, non expose au M3).
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
private int $position = 0;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getProvider(): ?Provider
|
||||
{
|
||||
return $this->provider;
|
||||
}
|
||||
|
||||
public function setProvider(?Provider $provider): static
|
||||
{
|
||||
$this->provider = $provider;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFirstName(): ?string
|
||||
{
|
||||
return $this->firstName;
|
||||
}
|
||||
|
||||
public function setFirstName(?string $firstName): static
|
||||
{
|
||||
$this->firstName = $firstName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLastName(): ?string
|
||||
{
|
||||
return $this->lastName;
|
||||
}
|
||||
|
||||
public function setLastName(?string $lastName): static
|
||||
{
|
||||
$this->lastName = $lastName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getJobTitle(): ?string
|
||||
{
|
||||
return $this->jobTitle;
|
||||
}
|
||||
|
||||
public function setJobTitle(?string $jobTitle): static
|
||||
{
|
||||
$this->jobTitle = $jobTitle;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPhonePrimary(): ?string
|
||||
{
|
||||
return $this->phonePrimary;
|
||||
}
|
||||
|
||||
public function setPhonePrimary(?string $phonePrimary): static
|
||||
{
|
||||
$this->phonePrimary = $phonePrimary;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPhoneSecondary(): ?string
|
||||
{
|
||||
return $this->phoneSecondary;
|
||||
}
|
||||
|
||||
public function setPhoneSecondary(?string $phoneSecondary): static
|
||||
{
|
||||
$this->phoneSecondary = $phoneSecondary;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEmail(): ?string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
public function setEmail(?string $email): static
|
||||
{
|
||||
$this->email = $email;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Domain\Entity;
|
||||
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Coordonnees bancaires d'un prestataire (1:n) — onglet Comptabilite. Au moins un
|
||||
* RIB est obligatoire si le type de reglement est LCR (RG-3.08, verifie au
|
||||
* ProviderProcessor : refus du DELETE du dernier RIB sous LCR — ERP-134).
|
||||
*
|
||||
* Embarque sous `provider.ribs` UNIQUEMENT si l'user a accounting.view : le
|
||||
* read-group est `provider:read:accounting`, retire du contexte par le
|
||||
* ProviderProvider sinon (gating par omission de cle — evite la fuite IBAN/BIC,
|
||||
* piege n°4 du M1). Aucun #[AuditIgnore] sur iban/bic : l'audit etant admin-only,
|
||||
* la tracabilite RIB est conservee (decision M1 reportee, § 2.7).
|
||||
*
|
||||
* L'exposition en SOUS-RESSOURCE API (POST /providers/{id}/ribs, PATCH / DELETE,
|
||||
* gating accounting.manage) est un ticket ulterieur du M3 : pas d'#[ApiResource]
|
||||
* ici.
|
||||
*
|
||||
* Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony (pas de controle
|
||||
* banque reelle), avec controle croise pays BIC/IBAN (ibanPropertyPath). Audite
|
||||
* (#[Auditable]) + Timestampable / Blamable.
|
||||
*/
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'provider_rib')]
|
||||
#[ORM\Index(name: 'idx_provider_rib_provider', columns: ['provider_id'])]
|
||||
#[Auditable]
|
||||
class ProviderRib implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['provider:read:accounting'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Provider::class, inversedBy: 'ribs')]
|
||||
#[ORM\JoinColumn(name: 'provider_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ?Provider $provider = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Assert\NotBlank(message: 'Le libellé du RIB est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(max: 120, maxMessage: 'Le libellé ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
|
||||
private ?string $label = null;
|
||||
|
||||
// Bic/Iban bornent deja le format (et donc la longueur) : pas de Length redondant
|
||||
// calee sur la colonne (auto-exempte du miroir ERP-107). ibanPropertyPath :
|
||||
// controle croise — le pays du BIC (positions 5-6) doit correspondre au pays de
|
||||
// l'IBAN (positions 1-2). Violation portee sur `bic`.
|
||||
#[ORM\Column(length: 20)]
|
||||
#[Assert\NotBlank(message: 'Le BIC est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Bic(
|
||||
message: 'Le BIC n\'est pas valide.',
|
||||
ibanPropertyPath: 'iban',
|
||||
ibanMessage: 'Le BIC ne correspond pas au pays de l\'IBAN.',
|
||||
)]
|
||||
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
|
||||
private ?string $bic = null;
|
||||
|
||||
#[ORM\Column(length: 34)]
|
||||
#[Assert\NotBlank(message: 'L\'IBAN est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Iban(message: 'L\'IBAN n\'est pas valide.')]
|
||||
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
|
||||
private ?string $iban = null;
|
||||
|
||||
// Ordre d'affichage du RIB (gere serveur, non expose au M3).
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
private int $position = 0;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getProvider(): ?Provider
|
||||
{
|
||||
return $this->provider;
|
||||
}
|
||||
|
||||
public function setProvider(?Provider $provider): static
|
||||
{
|
||||
$this->provider = $provider;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLabel(): ?string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function setLabel(string $label): static
|
||||
{
|
||||
$this->label = $label;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBic(): ?string
|
||||
{
|
||||
return $this->bic;
|
||||
}
|
||||
|
||||
public function setBic(string $bic): static
|
||||
{
|
||||
$this->bic = $bic;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getIban(): ?string
|
||||
{
|
||||
return $this->iban;
|
||||
}
|
||||
|
||||
public function setIban(string $iban): static
|
||||
{
|
||||
$this->iban = $iban;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Domain\Repository;
|
||||
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
interface ProviderRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?Provider;
|
||||
|
||||
public function save(Provider $provider): void;
|
||||
|
||||
/**
|
||||
* Construit un QueryBuilder de liste pour le repertoire prestataires.
|
||||
* - Exclut toujours les prestataires soft-deletes (deleted_at IS NOT NULL, RG-3.16).
|
||||
* - Archivage (RG-3.16) :
|
||||
* - $archivedOnly = true -> uniquement les archives (is_archived = true) ;
|
||||
* - sinon $includeArchived = true -> actifs + archives (echappatoire) ;
|
||||
* - sinon (defaut) -> uniquement les actifs (is_archived = false).
|
||||
* $archivedOnly a la priorite sur $includeArchived.
|
||||
* - Tri par defaut : companyName ASC (RG-3.16).
|
||||
* - $search : recherche fuzzy insensible a la casse sur companyName + les
|
||||
* contacts lies (firstName / lastName / email) via sous-requete.
|
||||
* Metacaracteres LIKE echappes. Ignore si null/vide.
|
||||
* - $categoryCodes : restreint aux prestataires possedant au moins une
|
||||
* categorie dont le code est dans la liste (OR). Liste vide = pas de filtre.
|
||||
* - $siteIds : restreint aux prestataires rattaches a l'un des sites donnes
|
||||
* (OR — RG-3.03, relation DIRECTE provider.sites). Liste vide = pas de filtre.
|
||||
*
|
||||
* Filtrage centralise ICI (et non dans le provider/controller) pour que la
|
||||
* liste paginee et l'export partagent strictement la meme logique de selection
|
||||
* (miroir M2).
|
||||
*
|
||||
* Contrat = SELECTION uniquement (filtres + tri). Aucun fetch-join to-many :
|
||||
* l'hydratation des collections affichees est deleguee a
|
||||
* {@see self::hydrateListCollections()} pour ne pas imposer le cout d'un
|
||||
* produit cartesien aux chemins non pagines (§ 2.12, cf. M1/ERP-100, M2).
|
||||
*
|
||||
* NB : le cloisonnement par site pilote par l'utilisateur (RG-3.17, § 2.13) est
|
||||
* applique en AMONT par le ProviderProvider (ERP-134), pas par ce QueryBuilder
|
||||
* (qui ne connait pas l'user courant).
|
||||
*
|
||||
* @param list<string> $categoryCodes
|
||||
* @param list<int> $siteIds
|
||||
*/
|
||||
public function createListQueryBuilder(
|
||||
bool $includeArchived = false,
|
||||
?string $search = null,
|
||||
array $categoryCodes = [],
|
||||
array $siteIds = [],
|
||||
bool $archivedOnly = false,
|
||||
): QueryBuilder;
|
||||
|
||||
/**
|
||||
* Hydrate en lot les collections affichees par le repertoire (categories puis
|
||||
* sites — relation DIRECTE provider.sites, RG-3.03) sur un jeu de prestataires
|
||||
* DEJA charges, via l'identity map Doctrine (memes instances). A appeler apres
|
||||
* une selection bornee (page courante ou jeu d'export) pour eviter le N+1 a la
|
||||
* serialisation, sans imposer de fetch-join au QueryBuilder de selection
|
||||
* (anti N+1, § 2.12).
|
||||
*
|
||||
* Charge les categories et les sites en DEUX requetes distinctes (et non un
|
||||
* double fetch-join) pour ne pas multiplier categories x sites en un seul
|
||||
* produit cartesien.
|
||||
*
|
||||
* @param list<Provider> $providers
|
||||
*/
|
||||
public function hydrateListCollections(array $providers): void;
|
||||
|
||||
/**
|
||||
* Hydrate en lot la collection `contacts` sur un jeu de prestataires DEJA
|
||||
* charges (memes instances via l'identity map). Reservee aux chemins qui ont
|
||||
* besoin du contact principal (export) : la LISTE paginee n'embarque pas les
|
||||
* contacts (§ 2.12), d'ou une methode dediee plutot qu'une passe supplementaire
|
||||
* dans {@see self::hydrateListCollections()}.
|
||||
*
|
||||
* @param list<Provider> $providers
|
||||
*/
|
||||
public function hydrateContacts(array $providers): void;
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use App\Module\Technique\Domain\Repository\ProviderRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Provider>
|
||||
*/
|
||||
class DoctrineProviderRepository extends ServiceEntityRepository implements ProviderRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Provider::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?Provider
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
public function save(Provider $provider): void
|
||||
{
|
||||
$this->getEntityManager()->persist($provider);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function createListQueryBuilder(
|
||||
bool $includeArchived = false,
|
||||
?string $search = null,
|
||||
array $categoryCodes = [],
|
||||
array $siteIds = [],
|
||||
bool $archivedOnly = false,
|
||||
): QueryBuilder {
|
||||
// SELECTION uniquement (filtres + tri) : pas de fetch-join to-many ici.
|
||||
// L'hydratation des collections affichees (Catégories / Site(s)) est
|
||||
// deleguee a hydrateListCollections() une fois le jeu borne, pour ne pas
|
||||
// imposer un produit cartesien aux chemins non pagines (export,
|
||||
// ?pagination=false) — § 2.12 (cf. M1/ERP-100, M2).
|
||||
$qb = $this->createQueryBuilder('p')
|
||||
->andWhere('p.deletedAt IS NULL')
|
||||
->orderBy('p.companyName', 'ASC')
|
||||
;
|
||||
|
||||
// Perimetre d'archivage : archivedOnly prioritaire sur includeArchived.
|
||||
if ($archivedOnly) {
|
||||
$qb->andWhere('p.isArchived = true');
|
||||
} elseif (!$includeArchived) {
|
||||
$qb->andWhere('p.isArchived = false');
|
||||
}
|
||||
|
||||
$this->applySearch($qb, $search);
|
||||
$this->applyCategoryCodes($qb, $categoryCodes);
|
||||
$this->applySiteIds($qb, $siteIds);
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
public function hydrateListCollections(array $providers): void
|
||||
{
|
||||
$ids = $this->collectIds($providers);
|
||||
if ([] === $ids) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1re passe : categories (colonne « Catégories »). Produit p x cat seul.
|
||||
$this->createQueryBuilder('p')
|
||||
->leftJoin('p.categories', 'cat')->addSelect('cat')
|
||||
->where('p.id IN (:ids)')->setParameter('ids', $ids)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
|
||||
// 2e passe : sites (colonne « Site(s) »). SPECIFICITE M3 : les sites sont
|
||||
// portes DIRECTEMENT par le prestataire (provider.sites, RG-3.03), pas via
|
||||
// les adresses comme au M2 — d'ou un simple join p -> site (pas d'imbrication
|
||||
// addr -> site). Separer des categories casse le cartesien cat x site.
|
||||
$this->createQueryBuilder('p')
|
||||
->leftJoin('p.sites', 'site')->addSelect('site')
|
||||
->where('p.id IN (:ids)')->setParameter('ids', $ids)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
|
||||
public function hydrateContacts(array $providers): void
|
||||
{
|
||||
$ids = $this->collectIds($providers);
|
||||
if ([] === $ids) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Une seule requete IN bornee : remplit la collection `contacts` des MEMES
|
||||
// instances Provider (identity map). Tri par position pour que le « contact
|
||||
// principal » (plus petit position) soit deterministe a l'export.
|
||||
$this->createQueryBuilder('p')
|
||||
->leftJoin('p.contacts', 'pc')->addSelect('pc')
|
||||
->where('p.id IN (:ids)')->setParameter('ids', $ids)
|
||||
->orderBy('pc.position', 'ASC')
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche fuzzy insensible a la casse sur companyName ET sur les contacts
|
||||
* lies (firstName / lastName / email) — miroir M2. Les deux criteres sont unis
|
||||
* par OR : un prestataire matche si son nom de societe OU l'un de ses contacts
|
||||
* matche. Le critere contact passe par une sous-requete IN (plutot qu'un JOIN
|
||||
* sur la collection) pour ne pas perturber le DISTINCT / ORDER BY / pagination
|
||||
* principal. Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester
|
||||
* litteraux.
|
||||
*/
|
||||
private function applySearch(QueryBuilder $qb, ?string $search): void
|
||||
{
|
||||
if (null === $search || '' === trim($search)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
|
||||
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
|
||||
|
||||
$contactSub = $this->getEntityManager()->createQueryBuilder()
|
||||
->select('p2.id')
|
||||
->from(Provider::class, 'p2')
|
||||
->join('p2.contacts', 'pc2')
|
||||
->where('LOWER(pc2.firstName) LIKE :search')
|
||||
->orWhere('LOWER(pc2.lastName) LIKE :search')
|
||||
->orWhere('LOWER(pc2.email) LIKE :search')
|
||||
;
|
||||
|
||||
$qb->andWhere(
|
||||
$qb->expr()->orX(
|
||||
'LOWER(p.companyName) LIKE :search',
|
||||
$qb->expr()->in('p.id', $contactSub->getDQL()),
|
||||
),
|
||||
)->setParameter('search', $pattern);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restreint aux prestataires possedant au moins une categorie dont le code
|
||||
* figure dans la liste (OR). Alimente le filtre « Catégories » du drawer.
|
||||
* Sous-requete IN (plutot qu'un JOIN sur la collection M2M) pour ne pas
|
||||
* perturber le DISTINCT / ORDER BY principal.
|
||||
*
|
||||
* @param list<string> $categoryCodes
|
||||
*/
|
||||
private function applyCategoryCodes(QueryBuilder $qb, array $categoryCodes): void
|
||||
{
|
||||
$codes = $this->normalizeStringList($categoryCodes);
|
||||
if ([] === $codes) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sub = $this->getEntityManager()->createQueryBuilder()
|
||||
->select('p3.id')
|
||||
->from(Provider::class, 'p3')
|
||||
->join('p3.categories', 'cat3')
|
||||
->where('cat3.code IN (:categoryCodes)')
|
||||
;
|
||||
|
||||
$qb->andWhere($qb->expr()->in('p.id', $sub->getDQL()))
|
||||
->setParameter('categoryCodes', $codes)
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restreint aux prestataires rattaches a l'un des sites donnes (OR). SPECIFICITE
|
||||
* M3 : les sites sont portes DIRECTEMENT par le prestataire (provider.sites,
|
||||
* RG-3.03), d'ou une sous-requete sur p.sites (et non sur les adresses comme au
|
||||
* M2). Sous-requete IN pour ne pas perturber le tri/pagination principal.
|
||||
*
|
||||
* @param list<int> $siteIds
|
||||
*/
|
||||
private function applySiteIds(QueryBuilder $qb, array $siteIds): void
|
||||
{
|
||||
$ids = $this->normalizeIntList($siteIds);
|
||||
if ([] === $ids) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sub = $this->getEntityManager()->createQueryBuilder()
|
||||
->select('p4.id')
|
||||
->from(Provider::class, 'p4')
|
||||
->join('p4.sites', 'site4')
|
||||
->where('site4.id IN (:siteIds)')
|
||||
;
|
||||
|
||||
$qb->andWhere($qb->expr()->in('p.id', $sub->getDQL()))
|
||||
->setParameter('siteIds', $ids)
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait les identifiants non nuls d'un jeu de prestataires (entites managees).
|
||||
* Les requetes d'hydratation renvoient les MEMES instances Provider (identity
|
||||
* map), dont les collections sont alors remplies — anti N+1 a la serialisation.
|
||||
*
|
||||
* @param list<Provider> $providers
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function collectIds(array $providers): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($providers as $provider) {
|
||||
$id = $provider->getId();
|
||||
if (null !== $id) {
|
||||
$ids[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoie une liste de chaines : trim, retrait des vides, reindexation.
|
||||
* Defensive : tolere des elements scalaires non-string (cast) et ignore le
|
||||
* reste sans lever de TypeError, le contrat etant de normaliser une entree
|
||||
* potentiellement brute (query params).
|
||||
*
|
||||
* @param array<mixed> $values
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function normalizeStringList(array $values): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($values as $value) {
|
||||
if (is_string($value) || is_int($value) || is_float($value)) {
|
||||
$trimmed = trim((string) $value);
|
||||
if ('' !== $trimmed) {
|
||||
$out[] = $trimmed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoie une liste d'identifiants : cast int, retrait des <= 0, reindexation.
|
||||
* Defensive (cf. normalizeStringList) : accepte des entiers ou des chaines
|
||||
* numeriques ('1', '2') sans TypeError, ignore le reste.
|
||||
*
|
||||
* @param array<mixed> $values
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function normalizeIntList(array $values): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($values as $value) {
|
||||
if (is_numeric($value) && (int) $value > 0) {
|
||||
$out[] = (int) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -361,6 +361,91 @@ final class ColumnCommentsCatalog
|
||||
'iban' => 'IBAN du compte (≤ 34 caracteres).',
|
||||
'position' => 'Ordre d affichage du RIB dans la liste du fournisseur (croissant).',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
// Tables provider* (M3 Technique) — ajoutees au ticket entites (ERP-133),
|
||||
// comme l a fait supplier (ERP-86) apres sa migration (ERP-85). En test,
|
||||
// `schema:update --force` recree ces tables depuis le mapping ORM (sans
|
||||
// COMMENT) ; `app:apply-column-comments` les repose depuis ce catalogue.
|
||||
'provider' => [
|
||||
'_table' => 'Repertoire prestataires (M3 Technique) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M4). Pas d onglet Information (≠ supplier).',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'company_name' => 'Raison sociale du prestataire (stockee en MAJUSCULES). Unique case-insensitive parmi les actifs non archives/non supprimes (uq_provider_company_name_active, RG-3.10).',
|
||||
'siren' => 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-3.10).',
|
||||
'account_number' => 'Onglet Comptabilite : numero de compte comptable du prestataire.',
|
||||
'tva_mode_id' => 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id (referentiel partage M1), ON DELETE RESTRICT.',
|
||||
'n_tva' => 'Onglet Comptabilite : numero de TVA intracommunautaire.',
|
||||
'payment_delay_id' => 'Onglet Comptabilite : delai de reglement — FK -> payment_delay.id (M1), ON DELETE RESTRICT.',
|
||||
'payment_type_id' => 'Onglet Comptabilite : type de reglement — FK -> payment_type.id (M1), ON DELETE RESTRICT. Pilote RG-3.07 (Banque si VIREMENT) et RG-3.08 (RIB).',
|
||||
'bank_id' => 'Onglet Comptabilite : banque — FK -> bank.id (M1), ON DELETE RESTRICT. Obligatoire ssi payment_type = VIREMENT (RG-3.07), null sinon.',
|
||||
'is_archived' => 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission technique.providers.archive.',
|
||||
'archived_at' => 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration.',
|
||||
'deleted_at' => 'Horodatage du soft-delete technique (HP M4) — non expose par l API au M3. Null = ligne active.',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
'provider_category' => [
|
||||
'_table' => 'Jointure M2M provider <-> category (Catalog) — categories de type PRESTATAIRE du prestataire, au moins une obligatoire (RG-3.09).',
|
||||
'provider_id' => 'FK -> provider.id, ON DELETE CASCADE — prestataire porteur de la categorie.',
|
||||
'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie de type PRESTATAIRE rattachee au prestataire (RG-3.09).',
|
||||
],
|
||||
|
||||
'provider_site' => [
|
||||
'_table' => 'Jointure M2M provider <-> site (Sites) — sites du prestataire, selecteur du formulaire principal, au moins un obligatoire (RG-3.03). Sert le cloisonnement par site (§ 2.13).',
|
||||
'provider_id' => 'FK -> provider.id, ON DELETE CASCADE — prestataire porteur du site.',
|
||||
'site_id' => 'FK -> site.id, ON DELETE RESTRICT — site rattache au prestataire (RG-3.03, idx_provider_site_site).',
|
||||
],
|
||||
|
||||
'provider_contact' => [
|
||||
'_table' => 'Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/telephone/email (RG-3.04, chk_provider_contact_name).',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'provider_id' => 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du contact.',
|
||||
'first_name' => 'Prenom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).',
|
||||
'last_name' => 'Nom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).',
|
||||
'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).',
|
||||
'phone_primary' => 'Telephone principal du contact — chiffres uniquement (normalisation serveur).',
|
||||
'phone_secondary' => 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).',
|
||||
'email' => 'Email du contact (lowercase serveur).',
|
||||
'position' => 'Ordre d affichage du contact dans la liste du prestataire (croissant).',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
'provider_address' => [
|
||||
'_table' => 'Adresses d un prestataire (1:n) — >= 1 site rattache (RG-3.05). SANS address_type / bennes / triage_provider (specifiques fournisseur).',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'provider_id' => 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire de l adresse.',
|
||||
'country' => 'Pays de l adresse — defaut France.',
|
||||
'postal_code' => 'Code postal (4-5 chiffres attendus) — declenche l autocompletion ville via l API BAN cote front (RG-3.06).',
|
||||
'city' => 'Ville — preremplie depuis le code postal via API BAN cote front.',
|
||||
'street' => 'Numero et voie de l adresse.',
|
||||
'street_complement' => 'Complement d adresse (etage, batiment...) — optionnel.',
|
||||
'position' => 'Ordre d affichage de l adresse dans la liste du prestataire (croissant).',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
'provider_address_site' => [
|
||||
'_table' => 'Jointure M2M provider_address <-> site (Sites) — sites rattaches a l adresse (>= 1 obligatoire, RG-3.05).',
|
||||
'provider_address_id' => 'FK -> provider_address.id, ON DELETE CASCADE — adresse concernee.',
|
||||
'site_id' => 'FK -> site.id, ON DELETE RESTRICT — site rattache a l adresse.',
|
||||
],
|
||||
|
||||
'provider_address_contact' => [
|
||||
'_table' => 'Jointure M2M provider_address <-> provider_contact — contacts associes a une adresse.',
|
||||
'provider_address_id' => 'FK -> provider_address.id, ON DELETE CASCADE — adresse concernee.',
|
||||
'provider_contact_id' => 'FK -> provider_contact.id, ON DELETE CASCADE — contact associe a l adresse.',
|
||||
],
|
||||
|
||||
'provider_address_category' => [
|
||||
'_table' => 'Jointure M2M provider_address <-> category — categories d adresse de type PRESTATAIRE (RG-3.09).',
|
||||
'provider_address_id' => 'FK -> provider_address.id, ON DELETE CASCADE — adresse concernee.',
|
||||
'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse de type PRESTATAIRE (RG-3.09).',
|
||||
],
|
||||
|
||||
'provider_rib' => [
|
||||
'_table' => 'Coordonnees bancaires d un prestataire (1:n) — >= 1 RIB attendu selon le type de reglement (RG-3.08). Tous les champs audites (pas d AuditIgnore).',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'provider_id' => 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du RIB.',
|
||||
'label' => 'Libelle du RIB (ex: compte principal).',
|
||||
'bic' => 'Code BIC/SWIFT de la banque (8 ou 11 caracteres).',
|
||||
'iban' => 'IBAN du compte (≤ 34 caracteres).',
|
||||
'position' => 'Ordre d affichage du RIB dans la liste du prestataire (croissant).',
|
||||
] + self::timestampableBlamableComments(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,8 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
|
||||
'ClientAddress::postalCode' => 'Regex {4,5} borne deja la longueur.',
|
||||
// Idem cote fournisseur (meme Regex CP).
|
||||
'SupplierAddress::postalCode' => 'Regex {4,5} borne deja la longueur.',
|
||||
// Idem cote prestataire (meme Regex CP — M3 Technique).
|
||||
'ProviderAddress::postalCode' => 'Regex {4,5} borne deja la longueur.',
|
||||
// Le Choice {PROSPECT,DEPART,RENDU} borne les valeurs (<= 8 < 20).
|
||||
'SupplierAddress::addressType' => 'Choice {PROSPECT,DEPART,RENDU} borne deja les valeurs.',
|
||||
// Le Regex /^#[0-9A-Fa-f]{6}$/ borne la longueur a exactement 7 caracteres.
|
||||
|
||||
@@ -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,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