Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c402418937 | |||
| 2866fb8865 | |||
| 0ed131ce57 | |||
| a948eed9b6 |
@@ -74,3 +74,53 @@ Exemple : pour qu'`User.profile` soit embarque au lieu d'un lien IRI sous le gro
|
||||
## PostgreSQL
|
||||
|
||||
- Noms de colonnes toujours en **minuscules** dans le SQL brut (commun a tous les projets MALIO)
|
||||
|
||||
## Migrations Doctrine
|
||||
|
||||
### Documentation SQL obligatoire (`COMMENT ON COLUMN`)
|
||||
|
||||
**Toute migration qui cree ou modifie une colonne d'une table metier doit poser un `COMMENT ON COLUMN` decrivant le champ.** La description est stockee dans `pg_description` et visible dans tous les outils d'admin BDD (DBeaver, DataGrip, pgAdmin), sans avoir a lire les annotations PHP.
|
||||
|
||||
**Format de la description** :
|
||||
- En francais
|
||||
- ≤ 200 caracteres
|
||||
- Semantique du champ — contraintes / lien RG si pertinent
|
||||
- Pour les colonnes d'identifiant ou FK, mentionner la cible
|
||||
|
||||
Exemples :
|
||||
|
||||
```php
|
||||
// Migration : creation d'une colonne avec son commentaire dans la meme migration
|
||||
$this->addSql("ALTER TABLE client ADD COLUMN siren VARCHAR(9) DEFAULT NULL");
|
||||
$this->addSql("COMMENT ON COLUMN client.siren IS 'SIREN (9 chiffres) — identifiant legal entreprise. Unique parmi non-archives (RG-1.15).'");
|
||||
|
||||
// Cas FK : preciser la cible
|
||||
$this->addSql("COMMENT ON COLUMN client.legal_form_id IS 'Reference forme juridique (SARL, SAS, SA...) — FK -> legal_form.id, ON DELETE RESTRICT.'");
|
||||
|
||||
// Cas booleen : preciser le sens et la valeur par defaut
|
||||
$this->addSql("COMMENT ON COLUMN user.is_admin IS 'Drapeau super-administrateur — bypass complet RBAC. Faux par defaut.'");
|
||||
|
||||
// Bonus : decrire la table elle-meme
|
||||
$this->addSql("COMMENT ON TABLE client IS 'Repertoire clients (M1 Commercial) — entites archivables.'");
|
||||
```
|
||||
|
||||
### Helper Timestampable/Blamable
|
||||
|
||||
Les 4 colonnes `created_at`, `updated_at`, `created_by`, `updated_by` ajoutees par `TimestampableBlamableTrait` recoivent une description **standardisee** via le helper centralise pour eviter la duplication. Helper a creer ou appeler :
|
||||
|
||||
```php
|
||||
// Dans la migration, apres avoir ajoute les 4 colonnes :
|
||||
$this->addStandardTimestampableBlamableComments($schema, 'client');
|
||||
```
|
||||
|
||||
L'implementation du helper applique :
|
||||
- `created_at` : « Horodatage de creation de la ligne (UTC, rempli automatiquement par TimestampableBlamableSubscriber). »
|
||||
- `updated_at` : « Horodatage de derniere modification de la ligne (UTC, rempli automatiquement par TimestampableBlamableSubscriber). »
|
||||
- `created_by` : « ID de l'utilisateur ayant cree la ligne — null pour les creations hors HTTP (CLI, migration, fixture). FK -> user.id, ON DELETE SET NULL. »
|
||||
- `updated_by` : « ID de l'utilisateur ayant modifie la ligne en dernier — null pour les modifications hors HTTP. FK -> user.id, ON DELETE SET NULL. »
|
||||
|
||||
### Garde-fou architecture
|
||||
|
||||
`tests/Architecture/ColumnsHaveSqlCommentTest` parcourt `information_schema.columns` filtre sur le schema `public` et echoue si **une seule colonne** n'a pas de `col_description`. Seules les tables system (`doctrine_migration_versions`) et la whitelist `EXCLUDED_TABLES` explicite (commentaire de justification + ticket Lesstime ouvert pour le retrofit) sont tolerees.
|
||||
|
||||
Conclusion : si tu crees une colonne sans poser son `COMMENT ON COLUMN`, `make test` casse en CI.
|
||||
|
||||
@@ -84,6 +84,9 @@ jobs:
|
||||
php bin/console doctrine:database:create --env=test --if-not-exists --no-interaction
|
||||
php bin/console doctrine:migrations:migrate --env=test --no-interaction
|
||||
php bin/console doctrine:schema:update --env=test --force --no-interaction
|
||||
# Rejoue le catalogue COMMENT ON apres schema:update (cf. ERP-67) :
|
||||
# schema:update drop les commentaires des tables managees par l'ORM.
|
||||
php bin/console app:apply-column-comments --env=test --no-interaction
|
||||
php bin/console doctrine:fixtures:load --env=test --no-interaction
|
||||
php bin/console app:sync-permissions --env=test --no-interaction
|
||||
php bin/console --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL"
|
||||
|
||||
@@ -24,6 +24,7 @@ Doc humaine : @README.md — Spec audit : @doc/audit-log.md
|
||||
9. **Jamais commit sans demande explicite** de l'utilisateur ; jamais force push sans confirmation.
|
||||
10. **Jamais mentionner Claude, Anthropic ou une IA** dans un commit (message, titre, body, footer, trailer) ou une PR (titre, description). Pas de `Co-Authored-By: Claude`, pas de `Generated with Claude Code`, pas de `🤖`, pas d'emoji robot, rien. Les commits sont signes par l'utilisateur uniquement.
|
||||
11. **Migrations d'initialisation au namespace racine** `DoctrineMigrations` dans `migrations/` (setup user, RBAC, seed de base). Les migrations modulaires (`src/Module/*/Infrastructure/Doctrine/Migrations/`) sont reservees aux evolutions post-schema (ajout de colonnes, index) — cf. @.claude/rules/architecture.md pour la raison.
|
||||
12. **Toujours documenter chaque colonne BDD via `COMMENT ON COLUMN`** dans la migration qui la cree ou la modifie. Description en francais, courte (≤ 200 caracteres), explique la semantique metier + contraintes implicites (unicite partielle, FK importante, lien RG). Garde-fou : `tests/Architecture/ColumnsHaveSqlCommentTest` echoue si une colonne `public` n'a pas de description (`col_description IS NULL`). Details et exemples : @.claude/rules/backend.md § Migrations Doctrine.
|
||||
|
||||
## Conventions
|
||||
@.claude/rules/architecture.md
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.52'
|
||||
app.version: '0.1.54'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,289 @@
|
||||
---
|
||||
# === IDENTITÉ ===
|
||||
module: M1
|
||||
nom: "Répertoire clients"
|
||||
ecran: repertoire-clients
|
||||
owner_spec: Matthieu
|
||||
backup_spec: Tristan
|
||||
version: V0
|
||||
date_redaction: 2026-05-28
|
||||
|
||||
# === LIENS ===
|
||||
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-31898"
|
||||
regles_metier: [RG-1.01, RG-1.02, RG-1.03, RG-1.04, RG-1.05, RG-1.06, RG-1.07, RG-1.08, RG-1.09, RG-1.10, RG-1.11, RG-1.12, RG-1.13, RG-1.14, RG-1.15, RG-1.16, RG-1.17, RG-1.18, RG-1.19, RG-1.20, RG-1.21, RG-1.22, RG-1.23, RG-1.24, RG-1.25, RG-1.26, RG-1.27, RG-1.28, RG-1.29]
|
||||
roles: [Admin, Bureau, Compta, Commerciale, Usine]
|
||||
lien_spec_back: ./spec-back.md
|
||||
|
||||
# === VALIDATION CLIENT #1 ===
|
||||
client_validation_1:
|
||||
statut: validee
|
||||
date: 2026-05-22
|
||||
canal: ecrit
|
||||
valide_par: "Matthieu (CP MALIO) — validation implicite, périmètre projet"
|
||||
resume: "Module 1 — Répertoire clients. Page d'entrée Commercial. Datatable + 3 écrans (Ajouter / Consulter / Modifier). Création par onglets : Information / Contact / Adresse / Comptabilité (Transport, Statistiques, Rapports, Échanges = placeholders blancs)."
|
||||
trace_archivee: "uploads/4a1b026f-M1-reportoire-clients.docx (V0 d'origine .docx)"
|
||||
|
||||
# === LIEN LESSTIME ===
|
||||
lesstime_taskgroup_id: 23
|
||||
lesstime_project_id: 6
|
||||
statut_global: en_dev
|
||||
---
|
||||
|
||||
# Module 1 — Répertoire clients (V0 front)
|
||||
|
||||
> **Origine** : spec front V0 livrée le 22/05/2026 (`M1-reportoire-clients.docx`). Restitution Markdown pour intégration au workflow MALIO. Le contenu original n'est pas modifié — toute précision et toute décision (en particulier côté back) vit dans [`spec-back.md`](./spec-back.md).
|
||||
|
||||
## But
|
||||
|
||||
Permettre aux utilisateurs Starseed (selon rôle) de gérer le **répertoire des clients** de l'organisation : consultation, création, modification, archivage. Cette page est la **porte d'entrée du module Commercial**.
|
||||
|
||||
## Accès
|
||||
|
||||
- **Depuis** : menu principal → section **Commercial** → entrée « Répertoire clients »
|
||||
- **Rôles autorisés** :
|
||||
|
||||
| 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** | ❌ | ❌ | ❌ |
|
||||
|
||||
> **Note** : aligné sur le docx d'origine — Compta édite uniquement l'onglet Comptabilité (champs SIREN / TVA / Délai de règlement / Type de règlement / Banque / RIBs). Compta ne peut pas **créer** un client (pas de droit `manage` général), mais peut éditer la partie comptable d'un client existant créé par Admin ou Bureau.
|
||||
|
||||
## Navigation
|
||||
|
||||
L'écran est la page d'entrée du module **Commercial**. Titre : « **Répertoire clients** ».
|
||||
|
||||
- Affichage principal : un **datatable** listant tous les clients **actifs** de l'organisation (les clients archivés sont masqués par défaut — filtre UI dédié pour les voir).
|
||||
- **Clic sur une ligne** → bascule sur l'écran **Consultation client** (page dédiée, pas un drawer — cf. maquette Figma).
|
||||
- **Bouton « + Ajouter »** (en haut à droite) → bascule sur l'écran **Ajouter un client**.
|
||||
- **Bouton « Exporter »** (en haut à droite) → télécharge un **fichier XLSX** des clients **affichés** (cf. filtre actif). Format détaillé dans [`spec-back.md` § Export](./spec-back.md).
|
||||
|
||||
## Datatable du Répertoire
|
||||
|
||||
Composant : `<MalioDataTable>`. Colonnes (à raffiner avec Tristan en revue maquette) :
|
||||
|
||||
| Colonne | Source | Tri |
|
||||
|---|---|---|
|
||||
| **Nom entreprise** | `client.companyName` | ASC par défaut |
|
||||
| **Contact principal** | `firstName + lastName` | Oui |
|
||||
| **Téléphone principal** | `phonePrimary` (formaté `XX XX XX XX XX`) | Non |
|
||||
| **Email principal** | `email` | Oui |
|
||||
| **Catégories** | liste des codes catégories séparés par `,` | Non |
|
||||
| **Site(s)** | sites rattachés à au moins une adresse (badges colorés) | Non |
|
||||
|
||||
> **Filtre archivés** : toggle UI en haut du datatable. Désactivé par défaut. État local (pas dans l'URL — cf. règle ABSOLUE Starseed n°6).
|
||||
|
||||
> **Pagination** : front via `<MalioDataTable>` (volumétrie cible faible — quelques centaines). Tri serveur `companyName ASC` par défaut.
|
||||
|
||||
## Écran « Ajouter un client »
|
||||
|
||||
Création par **onglets successifs avec validation incrémentale** : pour pouvoir passer à l'onglet suivant, il faut avoir validé l'onglet en cours. **Une fois un onglet validé, on passe automatiquement au suivant**, et les champs de l'onglet validé passent en lecture seule + bouton « Valider » désactivé (disabled).
|
||||
|
||||
### Formulaire principal (pré-onglets)
|
||||
|
||||
C'est le 1er bloc à remplir. Sans validation de ce formulaire, les onglets ne sont pas accessibles.
|
||||
|
||||
| Champ | Type composant | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Nom du client (Entreprise)** | `<MalioInputText>` | Oui | RG-1.18 (normalisation UPPERCASE serveur) |
|
||||
| **Nom du contact principal** | `<MalioInputText>` | Conditionnel | RG-1.01 + RG-1.19 (Capitalize) |
|
||||
| **Prénom du contact principal** | `<MalioInputText>` | Conditionnel | RG-1.01 + RG-1.19 (Capitalize) |
|
||||
| **Catégorie** | `<MalioSelectCheckbox>` (multi) | Oui | Liste des `Category` de l'API ; M2M Client ↔ Category |
|
||||
| **Téléphone principal** | `<MalioInputText>` (masque tel) | Oui | RG-1.02 + RG-1.20 (format `XX XX XX XX XX`) |
|
||||
| **Téléphone secondaire** | `<MalioInputText>` (masque tel) | Non | Apparaît au clic sur le bouton `+` (RG-1.02). Max 2 — bouton `+` disparaît une fois rempli. |
|
||||
| **Email** | `<MalioInputText>` type email | Oui | RG-1.21 (lowercase) |
|
||||
| **Distributeur / Courtier** | `<MalioSelect>` | Non | Valeurs : `Dépend du distributeur` / `Dépend du courtier` / `Aucun`. RG-1.03 conditionne les 2 champs suivants. |
|
||||
| **Nom du distributeur** | `<MalioSelect>` | Conditionnel | Visible si « Dépend du distributeur ». Liste = clients ayant ≥ 1 catégorie de type `DISTRIBUTEUR`. RG-1.03. |
|
||||
| **Nom du courtier** | `<MalioSelect>` | Conditionnel | Visible si « Dépend du courtier ». Liste = clients ayant ≥ 1 catégorie de type `COURTIER`. RG-1.03. |
|
||||
| **Prestation de triage** | `<MalioCheckbox>` | Non | — |
|
||||
|
||||
**Action** : « Valider » (`<MalioButton>`) → POST `/api/clients` ([`spec-back.md` § 4.3](./spec-back.md)). Si succès, on passe automatiquement à l'onglet « Information ».
|
||||
|
||||
### Onglet « Information »
|
||||
|
||||
Saisir les informations de l'entreprise.
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Description** | `<MalioInputTextArea>` | Conditionnel | RG-1.04 (obligatoire pour rôle Commerciale) |
|
||||
| **Concurrents** | `<MalioInputText>` | Conditionnel | RG-1.04 |
|
||||
| **Date de création** (de l'entreprise) | `<input type="date">` (exception Malio — pas de composant date couvert) | Conditionnel | RG-1.04 |
|
||||
| **Nombre de salariés** | `<MalioInputNumber>` | Conditionnel | RG-1.04 |
|
||||
| **CA €** | `<MalioInputAmount>` | Conditionnel | RG-1.04 |
|
||||
| **Dirigeant** | `<MalioInputText>` | Conditionnel | RG-1.04 |
|
||||
| **Résultat €** | `<MalioInputAmount>` | Conditionnel | RG-1.04 |
|
||||
|
||||
**Action** : « Valider » → PATCH partiel `/api/clients/{id}` (groupe `client:write:information`).
|
||||
|
||||
### Onglet « Contact »
|
||||
|
||||
Saisir un ou plusieurs contacts associés au client. Le 1er bloc est **pré-rempli** depuis les champs du formulaire principal (Nom, Prénom, Téléphone, Email — édition autorisée).
|
||||
|
||||
**Bloc Contact** :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Nom** | `<MalioInputText>` | Conditionnel | RG-1.05 + RG-1.19 (Capitalize) |
|
||||
| **Prénom** | `<MalioInputText>` | Conditionnel | RG-1.05 + RG-1.19 (Capitalize) |
|
||||
| **Fonction** | `<MalioInputText>` | Non | — |
|
||||
| **Téléphone** (x1, +1 possible) | `<MalioInputText>` | Non | RG-1.20 (format) |
|
||||
| **Email** | `<MalioInputText>` type email | Non | RG-1.21 (lowercase) |
|
||||
|
||||
**RG-1.14 (renforcement validée par Tristan le 28/05)** : **au moins 1 bloc Contact valide** (au moins Nom OU Prénom rempli) est obligatoire pour valider l'onglet. Donc l'onglet Contact ne peut pas être finalisé vide.
|
||||
|
||||
**Actions** :
|
||||
- « + Nouveau contact » : ajoute un bloc. Bouton **désactivé tant que le bloc précédent n'a pas Prénom OU Nom rempli** (RG-1.05).
|
||||
- « Supprimer » (icône) sur un bloc : modal de confirmation (`<MalioButton>` Annuler / Confirmer). Si Oui → suppression du bloc.
|
||||
- « Valider » → PATCH `/api/clients/{id}/contacts` (création/mise à jour de la collection).
|
||||
|
||||
### Onglet « Adresse »
|
||||
|
||||
Saisir une ou plusieurs adresses du client, rattachées à un ou plusieurs sites Starseed (Châtellerault 86 / Saint-Jean 17 / Pommevic 82) et à des contacts.
|
||||
|
||||
**Bloc Adresse** :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Prospect** | `<MalioCheckbox>` | Non | RG-1.06 — masque Adresse de livraison + Facturation si coché |
|
||||
| **Adresse de livraison** | `<MalioCheckbox>` | Non | RG-1.07 — masque Prospect si coché |
|
||||
| **Facturation** | `<MalioCheckbox>` | Non | RG-1.08 — masque Prospect si coché ; affiche le champ Email (RG-1.11) |
|
||||
| **Catégorie** | `<MalioSelectCheckbox>` (multi) | Oui | Liste des `Category` de **type SECTEUR + AUTRE** uniquement (cf. décision Q5 — DISTRIBUTEUR et COURTIER qualifient une relation entre clients, pas un lieu) |
|
||||
| **Pays** | `<MalioSelect>` | Oui | Préremplie « France » |
|
||||
| **Code postal** | `<MalioInputText>` (masque numérique) | Oui | RG-1.09 — déclenche autocomplete ville via BAN |
|
||||
| **Ville** | `<MalioSelect>` | Oui | RG-1.09 — alimentée par api-adresse.data.gouv.fr suivant le CP |
|
||||
| **Adresse** | `<MalioInputText>` (saisie assistée) | Oui | RG-1.09 — autocomplete BAN |
|
||||
| **Adresse complémentaire** | `<MalioInputText>` | Non | — |
|
||||
| **Sites Starseed** | `<MalioSelectCheckbox>` (multi-checkbox 86 / 17 / 82) | Oui | RG-1.10 — ≥ 1 site obligatoire |
|
||||
| **Contact(s) rattaché(s)** | `<MalioSelectCheckbox>` (multi) | Non | Liste = blocs Contact saisis dans l'onglet Contact |
|
||||
| **Email (facturation)** | `<MalioInputText>` type email | Conditionnel | RG-1.11 — visible/obligatoire uniquement si « Facturation » coché |
|
||||
|
||||
**Actions** :
|
||||
- « + Nouvelle Adresse » : ajoute un bloc identique.
|
||||
- « Supprimer » : modal de confirmation puis suppression.
|
||||
- « Valider » → PATCH `/api/clients/{id}/addresses`.
|
||||
|
||||
### Onglet « Transport »
|
||||
|
||||
🚧 **Placeholder blanc au M1.** Frame vide. Aucun champ. Aucun bouton de validation. L'utilisateur passe automatiquement à l'onglet suivant. **Pas de mention « En cours »** — c'est juste blanc (décision Tristan 28/05).
|
||||
|
||||
### Onglet « Comptabilité »
|
||||
|
||||
⚠ **Accessible aux rôles avec `commercial.clients.accounting.manage`** (Admin + Compta au M1). Bureau et Commerciale ne voient pas l'onglet. **Compta peut éditer cet onglet** (champs SIREN / N° compte / TVA / Délai / Type de règlement / Banque / RIBs) — cf. décision Q1, aligné docx. Compta ne peut pas créer un client (pas de `manage` général).
|
||||
|
||||
**Champs comptables** :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **SIREN** | `<MalioInputText>` (masque 9 chiffres) | Oui | Format 9 chiffres. **Pas d'unicité** (décision Q4) |
|
||||
| **Numéro de compte** | `<MalioInputText>` | Oui | — |
|
||||
| **Mode de TVA** | `<MalioSelect>` | Oui | Liste depuis `/api/tva_modes` |
|
||||
| **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-1.12 — visible et obligatoire **si** Type de règlement = `VIREMENT`. Liste depuis `/api/banks`. |
|
||||
|
||||
**Bloc RIB** (0..n blocs, présence obligatoire conditionnée par RG-1.13) :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Libellé** | `<MalioInputText>` | Oui (si LCR) | RG-1.13 |
|
||||
| **BIC** | `<MalioInputText>` | Oui (si LCR) | RG-1.13 — `#[AuditIgnore]` (champ sensible) |
|
||||
| **IBAN** | `<MalioInputText>` | Oui (si LCR) | RG-1.13 — `#[AuditIgnore]` (champ sensible) |
|
||||
|
||||
**Actions** :
|
||||
- « + RIB » : ajoute un bloc.
|
||||
- « Supprimer » (icône) : modal de confirmation.
|
||||
- « Valider » → PATCH `/api/clients/{id}/accounting`.
|
||||
|
||||
### Onglets « Statistiques » / « Rapports » / « Échanges »
|
||||
|
||||
🚧 **Placeholders blancs au M1.** Mêmes règles que Transport (frames vides, pas de validation).
|
||||
|
||||
## Écran « Consultation client »
|
||||
|
||||
Tous les champs en **lecture seule**. Layout identique à l'écran Ajouter mais sans bouton « Valider », sans bouton `+` pour ajouter des blocs Contact / Adresse / RIB.
|
||||
|
||||
- **Flèche retour** (à gauche) → revient au Répertoire.
|
||||
- **Bouton « Modifier »** (à droite, visible si l'utilisateur a la permission `commercial.clients.manage`) → bascule sur l'écran Modification.
|
||||
- **Bouton « Archiver »** (à droite, visible **uniquement pour Admin** via permission `commercial.clients.archive`) → ouvre une modal de confirmation, puis PATCH `/api/clients/{id}` `{ "isArchived": true }`. Le client passe en archivé (cf. flag `is_archived`).
|
||||
|
||||
> Le client archivé peut être restauré (`isArchived: false`) — bouton « Restaurer » remplace « Archiver » dans la consultation d'un archivé. Décision validée Tristan 28/05.
|
||||
|
||||
### Onglets affichés en consultation
|
||||
|
||||
Mêmes onglets qu'en création, **plus** les 4 placeholders blancs. L'utilisateur navigue librement entre les onglets (pas de séquence forcée en consultation).
|
||||
|
||||
## Écran « Modification client »
|
||||
|
||||
Comportement identique à l'écran Ajouter sauf :
|
||||
- **Pas de formulaire principal** (les champs principaux sont édités via les onglets correspondants).
|
||||
- Les champs sont **pré-remplis** avec les valeurs actuelles.
|
||||
- **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` restent en lecture seule (pas de bouton Valider, pas d'icône suppression de bloc).
|
||||
- Les onglets placeholders restent inaccessibles à l'édition (blancs).
|
||||
|
||||
## Composants UI à utiliser (`@malio/layer-ui`)
|
||||
|
||||
- **Datatable** : `<MalioDataTable>` (Répertoire)
|
||||
- **Input texte** : `<MalioInputText>`
|
||||
- **Input numérique** : `<MalioInputNumber>`
|
||||
- **Input montant** : `<MalioInputAmount>` (CA, Résultat)
|
||||
- **TextArea** : `<MalioInputTextArea>` (Description)
|
||||
- **Select simple** : `<MalioSelect>` (Pays, Ville, distributeur/courtier, refs comptables)
|
||||
- **Select multi (cases à cocher)** : `<MalioSelectCheckbox>` (Catégorie, Sites, Contacts rattachés)
|
||||
- **Checkbox** : `<MalioCheckbox>` (Prospect, Adresse livraison, Facturation, Prestation de triage)
|
||||
- **Bouton** : `<MalioButton>`, `<MalioButtonIcon>`
|
||||
- **Toasts** : standards via `useApi()`
|
||||
|
||||
**Exceptions autorisées** (à commenter `// TODO migrer quand Malio couvre`) :
|
||||
- `<input type="date">` pour « Date de création » (composant `MalioDate` non couvert)
|
||||
- Modal de confirmation : composant à confirmer côté équipe front (probablement `<MalioModal>` ou un wrapper à créer dans `frontend/shared/`)
|
||||
|
||||
## Règles de formatage et normalisation
|
||||
|
||||
Le serveur normalise systématiquement (cf. RG-1.18 à RG-1.21 dans [`spec-back.md`](./spec-back.md)) :
|
||||
|
||||
| Champ | Normalisation serveur | Affichage front |
|
||||
|---|---|---|
|
||||
| Nom entreprise (`companyName`) | UPPERCASE intégral | UPPERCASE |
|
||||
| Nom + Prénom contact | Capitalize (1ère lettre majuscule + reste minuscule) | identique |
|
||||
| Téléphone (`phonePrimary`, `phoneSecondary`, contact phones) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` à l'affichage (filter Vue) |
|
||||
| Email | lowercase intégral | identique |
|
||||
|
||||
> **Le front ne fait pas la normalisation** — il envoie la valeur saisie, le serveur normalise puis renvoie la valeur normalisée. L'UI affiche immédiatement la valeur normalisée renvoyée par l'API. Cohérent avec le pattern `useApi()`.
|
||||
|
||||
## API adresse postale
|
||||
|
||||
Le composant `Code postal` + `Ville` + `Adresse` est branché sur **api-adresse.data.gouv.fr** (Base Adresse Nationale, gratuite, française).
|
||||
|
||||
- Composable dédié `useAddressAutocomplete()` (à créer en M1).
|
||||
- Appel HTTP **direct depuis le front** (CORS OK), pas de proxy back.
|
||||
- Pattern : à la saisie du code postal (5 chiffres), GET `https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville. Sur saisie d'adresse : `?q={addr}&postcode={cp}&type=housenumber` → suggestions adresse.
|
||||
- Cas dégradé : si l'API ne répond pas (offline, timeout), le champ Ville devient un `<MalioInputText>` libre éditable + toast d'avertissement. Validation serveur acceptera la saisie libre.
|
||||
|
||||
## Points laissés ouverts par la V0 (résolus côté back)
|
||||
|
||||
| # | Zone d'ombre V0 | Résolution (cf. `spec-back.md`) |
|
||||
|---|---|---|
|
||||
| 1 | Catégorie en multi-select non clarifiée (1 ou n par client) | **M2M `client_category`** validée. CategoryType seedé avec `DISTRIBUTEUR`, `COURTIER`, `SECTEUR`, `AUTRE` (HP-3 du M0 levé). |
|
||||
| 2 | Distributeur / Courtier : liste de quoi ? | **Auto-référence Client** via 2 FK nullables `distributor_id` et `broker_id` (cf. RG-1.03). Une seule des deux est remplie à la fois. |
|
||||
| 3 | Onglet « Comptabilité » : qui édite ? | **Admin et Compta** peuvent éditer l'onglet Comptabilité (`commercial.clients.accounting.manage`). Bureau / Commerciale ne voient pas l'onglet. Compta ne peut pas créer un client (pas de `manage` global), mais peut éditer la partie comptable d'un client existant. |
|
||||
| 4 | Workflow par onglet | **Sauvegarde incrémentale**. POST formulaire principal crée le `Client` (status implicite « actif »). Chaque onglet validé = PATCH partiel par groupe de sérialisation dédié. Pas d'état « draft ». |
|
||||
| 5 | Onglets « À venir » | **Placeholders blancs** (frames vides, pas de message). Ré-activables sans rebuild quand les modules associés arriveront. |
|
||||
| 6 | Archive vs soft delete | **Flag `is_archived` séparé de `deleted_at`**. Archive ≠ delete : un client archivé est masqué par défaut mais reste en BDD éditable (Admin seul). Filtres UI distincts. Soft delete = HP M2. |
|
||||
| 7 | Unicité métier | **Nom d'entreprise uniquement** (case-insensitive, parmi non-archivés) — décision Q4. SIREN et email NON uniques. Index partiel Postgres `uq_client_company_name_active`. Doublon de nom → 409 Conflict. |
|
||||
| 8 | Téléphones (max 2) | **2 colonnes plates** `phone_primary` + `phone_secondary`. Pas de table séparée. |
|
||||
| 9 | API code postal | **api-adresse.data.gouv.fr** (BAN). Appel direct front via composable dédié. Cas dégradé : saisie libre + toast. |
|
||||
| 10 | Référentiels comptables | **4 entités CRUD-ables** (`TvaMode`, `PaymentDelay`, `PaymentType`, `Bank`) seedées au M1, CRUD admin futur (HP-M2). |
|
||||
| 11 | Format de l'export | **XLSX uniquement** au M1. CSV à étudier en HP. |
|
||||
|
||||
---
|
||||
|
||||
## 📦 Tickets Lesstime générés
|
||||
|
||||
**TaskGroup Lesstime** : à créer — `M1 — Répertoire clients` (projet `ERP / Starseed`, projectId=6).
|
||||
|
||||
> Détail complet, table des tickets et action manuelle dans Lesstime → voir [`spec-back.md § Tickets Lesstime générés`](./spec-back.md#-tickets-lesstime-générés).
|
||||
@@ -207,10 +207,16 @@ migration-migrate:
|
||||
# recree par dbal:run-sql pour que les tests RG-1.07 (unicite
|
||||
# case-insensitive) voient bien la contrainte SQL. Sans ce restore, les
|
||||
# POST doublons remontent 201 au lieu de 409.
|
||||
# 5. app:apply-column-comments : meme cause, schema:update drop les COMMENT
|
||||
# ON COLUMN/TABLE des tables managees par l'ORM (le mapping PHP ne porte
|
||||
# pas d'attribut options['comment']). On rejoue le catalogue partage
|
||||
# `ColumnCommentsCatalog` pour conserver la documentation SQL exigee par
|
||||
# le test architecture ColumnsHaveSqlCommentTest (ERP-67).
|
||||
test-db-setup:
|
||||
$(SYMFONY_CONSOLE) doctrine:database:create --env=test --if-not-exists
|
||||
$(SYMFONY_CONSOLE) doctrine:migrations:migrate --env=test --no-interaction
|
||||
$(SYMFONY_CONSOLE) doctrine:schema:update --env=test --force
|
||||
$(SYMFONY_CONSOLE) --env=test --no-interaction app:apply-column-comments
|
||||
$(SYMFONY_CONSOLE) --env=test --no-interaction doctrine:fixtures:load
|
||||
$(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL"
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* ERP-67 — Retrofit `COMMENT ON COLUMN` / `COMMENT ON TABLE` sur toutes les
|
||||
* tables metier existantes.
|
||||
*
|
||||
* Postgres stocke la description dans `pg_description`. Les outils d'admin
|
||||
* (DBeaver, DataGrip, pgAdmin) l'affichent automatiquement, ce qui evite de
|
||||
* remonter au code Doctrine pour comprendre la semantique d'une colonne.
|
||||
*
|
||||
* Source unique : `ColumnCommentsCatalog::comments()`. Le meme catalogue est
|
||||
* rejoue par `app:apply-column-comments` apres `doctrine:schema:update --force`
|
||||
* en environnement de test (Doctrine ORM ne conservant pas les commentaires
|
||||
* absents du mapping PHP).
|
||||
*
|
||||
* Convention :
|
||||
* - Description en francais, ≤ 200 caracteres.
|
||||
* - Semantique du champ + contraintes / lien RG si pertinent.
|
||||
*
|
||||
* Migration placee au namespace racine `DoctrineMigrations` (regle ABSOLUE
|
||||
* Starseed n°11) car elle touche plusieurs modules. Les futures migrations
|
||||
* applicatives devront poser leur propre `COMMENT ON COLUMN` au moment de
|
||||
* creer leurs colonnes (cf. regle ABSOLUE n°12 + .claude/rules/backend.md).
|
||||
*/
|
||||
final class Version20260528120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ERP-67 : retrofit COMMENT ON COLUMN/TABLE sur toutes les tables metier existantes.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
foreach (ColumnCommentsCatalog::toSqlStatements() as $sql) {
|
||||
$this->addSql($sql);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
foreach (ColumnCommentsCatalog::comments() as $table => $entries) {
|
||||
$quotedTable = '"'.str_replace('"', '""', $table).'"';
|
||||
foreach ($entries as $column => $_) {
|
||||
if ('_table' === $column) {
|
||||
$this->addSql(sprintf('COMMENT ON TABLE %s IS NULL', $quotedTable));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->addSql(sprintf(
|
||||
'COMMENT ON COLUMN %s.%s IS NULL',
|
||||
$quotedTable,
|
||||
'"'.str_replace('"', '""', $column).'"',
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\Console;
|
||||
|
||||
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:apply-column-comments',
|
||||
description: 'Reapplique les COMMENT ON TABLE/COLUMN du catalogue (workaround schema:update).',
|
||||
)]
|
||||
final class ApplyColumnCommentsCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$statements = ColumnCommentsCatalog::toSqlStatements();
|
||||
|
||||
foreach ($statements as $sql) {
|
||||
$this->connection->executeStatement($sql);
|
||||
}
|
||||
|
||||
$io->success(sprintf('%d COMMENT ON statements appliques.', count($statements)));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Database;
|
||||
|
||||
/**
|
||||
* Catalogue centralise des descriptions SQL (`COMMENT ON TABLE` /
|
||||
* `COMMENT ON COLUMN`) appliquees aux tables metier de Starseed.
|
||||
*
|
||||
* Source unique de verite, utilisee par :
|
||||
* - `migrations/Version20260528120000.php` : retrofit initial des tables
|
||||
* pre-existantes (ERP-67).
|
||||
* - `App\Module\Core\Infrastructure\Console\ApplyColumnCommentsCommand` :
|
||||
* reapplique les commentaires apres `doctrine:schema:update --force` en
|
||||
* environnement de test (cf. commentaire de `test-db-setup` dans le
|
||||
* `makefile`). Doctrine ORM ne conservant pas les commentaires absents
|
||||
* du mapping PHP, on les rejoue depuis ce catalogue.
|
||||
*
|
||||
* Pour ajouter ou modifier un commentaire :
|
||||
* - Mettre a jour `comments()` ci-dessous.
|
||||
* - La migration retrofit pose la valeur initiale, la commande la rejoue
|
||||
* en boucle. Toute future colonne doit etre documentee dans sa propre
|
||||
* migration (cf. CLAUDE.md regle ABSOLUE n°12) — ce catalogue ne sert
|
||||
* qu'au retrofit + au workaround schema:update.
|
||||
*
|
||||
* Convention : description en francais, ≤ 200 caracteres, semantique du
|
||||
* champ + contraintes / lien RG si pertinent. La cle speciale `_table` est
|
||||
* appliquee a la table elle-meme (`COMMENT ON TABLE`).
|
||||
*/
|
||||
final class ColumnCommentsCatalog
|
||||
{
|
||||
/**
|
||||
* @return array<string, array<string, string>>
|
||||
*/
|
||||
public static function comments(): array
|
||||
{
|
||||
return [
|
||||
'audit_log' => [
|
||||
'_table' => "Journal d'audit append-only — trace toutes les modifications BDD sur entites annotees #[Auditable]. Lecture seule via API.",
|
||||
'id' => "UUID v7 — identifiant de la ligne d'audit (genere en PHP, ordre temporel garanti).",
|
||||
'entity_type' => "Type d'entite auditee au format module.Entity (ex: core.User, commercial.Client) — evite les collisions inter-modules.",
|
||||
'entity_id' => "Identifiant de l'entite auditee (supporte INT et UUID — stocke en varchar pour rester generique).",
|
||||
'action' => "Type d'operation auditee : 'create', 'update' ou 'delete'.",
|
||||
'changes' => 'Snapshot complet pour create/delete, diff {champ: {old, new}} pour update. Cles sensibles filtrees (password, token, secret).',
|
||||
'performed_by' => "Username de l'auteur de l'action (denormalise, survit a la suppression du user) — vaut 'system' en CLI.",
|
||||
'performed_at' => "Horodatage UTC de l'action auditee.",
|
||||
'ip_address' => "Adresse IP de l'auteur (IPv4/IPv6) — null hors contexte HTTP.",
|
||||
'request_id' => "UUID v4 de la requete HTTP — regroupe les changements d'un meme flush, facilite la correlation logs.",
|
||||
],
|
||||
|
||||
'category' => [
|
||||
'_table' => 'Categories M0 — referentiel type par category_type, soft-delete via deleted_at, unicite (LOWER(name), category_type_id) parmi les actifs.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'name' => 'Libelle de la categorie (≤ 120 caracteres) — unique par type parmi les actifs (RG-1.06).',
|
||||
'category_type_id' => 'Reference au type de la categorie — FK -> category_type.id, ON DELETE RESTRICT (un type ne peut etre supprime tant qu il a des categories).',
|
||||
'deleted_at' => 'Horodatage UTC du soft-delete (archivage logique) — null si la categorie est active.',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
'category_type' => [
|
||||
'_table' => 'Referentiel statique des types de categories — code technique stable + libelle FR.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'code' => 'Code technique stable du type (snake_case, ≤ 40 caracteres) — unique, utilise dans le code et les configurations.',
|
||||
'label' => 'Libelle affichable du type (FR, ≤ 120 caracteres).',
|
||||
],
|
||||
|
||||
'permission' => [
|
||||
'_table' => 'Referentiel des permissions RBAC — codes au format module.resource[.subresource].action, synchronise par app:sync-permissions.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'code' => 'Code RBAC au format module.resource[.subresource].action — unique, synchronise par app:sync-permissions.',
|
||||
'label' => 'Libelle affichable de la permission (FR).',
|
||||
'module' => 'Identifiant du module proprietaire de la permission (snake_case, ex: core, commercial).',
|
||||
'orphan' => "Drapeau permission orpheline — vrai quand son module declarant a ete supprime, masquee de l'interface RBAC.",
|
||||
],
|
||||
|
||||
'role' => [
|
||||
'_table' => 'Referentiel des roles RBAC — agregent un ensemble de permissions, attribues aux utilisateurs.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'code' => 'Code technique stable du role (snake_case) — utilise dans le code (ex: admin, user). Unique.',
|
||||
'label' => 'Libelle affichable du role (FR).',
|
||||
'description' => 'Description longue du role (optionnelle).',
|
||||
'is_system' => "Drapeau role systeme — bloque la suppression et la modification du code via l'interface.",
|
||||
],
|
||||
|
||||
'role_permission' => [
|
||||
'_table' => 'Table de jointure roles <-> permissions (ManyToMany).',
|
||||
'role_id' => 'FK -> role.id, ON DELETE CASCADE — role qui porte la permission.',
|
||||
'permission_id' => 'FK -> permission.id, ON DELETE CASCADE — permission attribuee au role.',
|
||||
],
|
||||
|
||||
'site' => [
|
||||
'_table' => 'Sites geographiques — perimetre de scoping multi-site, attribues aux utilisateurs via user_site.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'name' => 'Nom du site (≤ 100 caracteres).',
|
||||
'city' => 'Ville du site (≤ 100 caracteres).',
|
||||
'postal_code' => 'Code postal (chaine ≤ 20 caracteres) — VARCHAR pour gerer les zeros initiaux et les formats internationaux.',
|
||||
'color' => "Code couleur hexadecimal (#RRGGBB) — differenciation visuelle dans l'UI.",
|
||||
'street' => "Numero et voie de l'adresse (≤ 200 caracteres).",
|
||||
'complement' => "Complement d'adresse (etage, batiment...) — optionnel.",
|
||||
'created_at' => 'Horodatage UTC de creation de la ligne — rempli par TimestampableBlamableSubscriber au prePersist.',
|
||||
'updated_at' => 'Horodatage UTC de derniere modification — rempli par TimestampableBlamableSubscriber au preUpdate.',
|
||||
],
|
||||
|
||||
'user' => [
|
||||
'_table' => 'Comptes utilisateurs Starseed — authentification JWT, RBAC via roles et permissions directes.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'username' => 'Identifiant de connexion (≤ 100 caracteres) — unique.',
|
||||
'password' => 'Hash du mot de passe (algorithme courant Symfony) — exclu de l audit via #[AuditIgnore].',
|
||||
'created_at' => 'Horodatage UTC de creation du compte — rempli manuellement dans le constructeur (pas via TimestampableBlamableSubscriber).',
|
||||
'is_admin' => 'Drapeau super-administrateur — bypass complet RBAC. Faux par defaut.',
|
||||
'current_site_id' => "Site actuellement selectionne par l'utilisateur (contexte de session) — FK -> site.id, ON DELETE SET NULL.",
|
||||
],
|
||||
|
||||
'user_permission' => [
|
||||
'_table' => 'Table de jointure utilisateurs <-> permissions directes (hors role).',
|
||||
'user_id' => 'FK -> user.id, ON DELETE CASCADE — utilisateur destinataire de la permission directe.',
|
||||
'permission_id' => 'FK -> permission.id, ON DELETE CASCADE — permission accordee individuellement.',
|
||||
],
|
||||
|
||||
'user_role' => [
|
||||
'_table' => 'Table de jointure utilisateurs <-> roles (ManyToMany).',
|
||||
'user_id' => 'FK -> user.id, ON DELETE CASCADE — utilisateur portant le role.',
|
||||
'role_id' => 'FK -> role.id, ON DELETE CASCADE — role attribue a l utilisateur.',
|
||||
],
|
||||
|
||||
'user_site' => [
|
||||
'_table' => 'Table de jointure utilisateurs <-> sites accessibles — gere le scoping multi-site (un user ne voit que les donnees de ses sites).',
|
||||
'user_id' => 'FK -> user.id, ON DELETE CASCADE — utilisateur ayant acces au site.',
|
||||
'site_id' => 'FK -> site.id, ON DELETE CASCADE — site accessible par l utilisateur.',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Descriptions standardisees pour les 4 colonnes du pattern
|
||||
* Timestampable/Blamable (`TimestampableBlamableTrait`).
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function timestampableBlamableComments(): array
|
||||
{
|
||||
return [
|
||||
'created_at' => 'Horodatage UTC de creation de la ligne — rempli par TimestampableBlamableSubscriber au prePersist.',
|
||||
'updated_at' => 'Horodatage UTC de derniere modification — rempli par TimestampableBlamableSubscriber au preUpdate.',
|
||||
'created_by' => "ID de l'utilisateur ayant cree la ligne — null hors HTTP (CLI, migration, fixture). FK -> \"user\".id, ON DELETE SET NULL.",
|
||||
'updated_by' => "ID de l'utilisateur ayant modifie la ligne en dernier — null hors HTTP. FK -> \"user\".id, ON DELETE SET NULL.",
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit la liste des requetes SQL `COMMENT ON TABLE/COLUMN` (en
|
||||
* dollar-quoting Postgres `$_$`) a partir du catalogue.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function toSqlStatements(): array
|
||||
{
|
||||
$statements = [];
|
||||
foreach (self::comments() as $table => $entries) {
|
||||
$quotedTable = self::quoteIdent($table);
|
||||
foreach ($entries as $column => $description) {
|
||||
if ('_table' === $column) {
|
||||
$statements[] = sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$statements[] = sprintf(
|
||||
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||
$quotedTable,
|
||||
self::quoteIdent($column),
|
||||
$description,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $statements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote un identifiant SQL avec des guillemets doubles. Necessaire pour
|
||||
* la table `user` (mot reserve PG) ; applique a tous par coherence.
|
||||
*/
|
||||
private static function quoteIdent(string $name): string
|
||||
{
|
||||
return '"'.str_replace('"', '""', $name).'"';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Architecture;
|
||||
|
||||
use Doctrine\DBAL\ArrayParameterType;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* Garde-fou architecture : toute colonne d'une table metier doit porter une
|
||||
* description SQL (`COMMENT ON COLUMN`).
|
||||
*
|
||||
* Postgres stocke la description dans `pg_description`, recuperable via
|
||||
* `col_description(table_oid, column_position)`. Une colonne sans description
|
||||
* remonte `NULL`. Le test parcourt `information_schema.columns` filtre sur le
|
||||
* schema `public` et echoue si une seule colonne metier n'a pas de description.
|
||||
*
|
||||
* Tables ignorees :
|
||||
* - `doctrine_migration_versions` : table system Doctrine, schema fige par la
|
||||
* librairie.
|
||||
* - Whitelist `EXCLUDED_TABLES` : doit rester vide ou justifiee — toute entree
|
||||
* doit avoir un ticket Lesstime ouvert pour le retrofit.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ColumnsHaveSqlCommentTest extends KernelTestCase
|
||||
{
|
||||
/**
|
||||
* Tables system, gerees par Doctrine — leur schema n'est pas notre.
|
||||
*/
|
||||
private const EXCLUDED_BUILTINS = [
|
||||
'doctrine_migration_versions',
|
||||
];
|
||||
|
||||
/**
|
||||
* Entites mappees uniquement en `when@test` (fixtures techniques pour les
|
||||
* tests d'integration, jamais en prod). Pas de migration, donc pas de
|
||||
* lieu naturel pour poser un COMMENT ON COLUMN.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const EXCLUDED_TEST_FIXTURES = [
|
||||
// tests/Fixtures/SiteAware/FakeSiteAwareEntity.php — fixture du module
|
||||
// Sites pour couvrir le SiteScopedQueryExtension. Cree via schema:update
|
||||
// sur la DB de test uniquement.
|
||||
'fake_site_aware_entity',
|
||||
];
|
||||
|
||||
/**
|
||||
* Whitelist metier — DOIT rester vide ou justifiee.
|
||||
*
|
||||
* Chaque entree doit comporter (1) un commentaire expliquant pourquoi la
|
||||
* table n'est pas encore documentee et (2) la reference d'un ticket
|
||||
* Lesstime ouvert pour le retrofit.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const EXCLUDED_TABLES = [];
|
||||
|
||||
public function testAllPublicColumnsHaveASqlComment(): void
|
||||
{
|
||||
/** @var Connection $conn */
|
||||
$conn = self::getContainer()->get('doctrine.dbal.default_connection');
|
||||
|
||||
$excluded = [...self::EXCLUDED_BUILTINS, ...self::EXCLUDED_TEST_FIXTURES, ...self::EXCLUDED_TABLES];
|
||||
|
||||
$rows = $conn->fetchAllAssociative(
|
||||
<<<'SQL'
|
||||
SELECT c.table_name, c.column_name
|
||||
FROM information_schema.columns c
|
||||
WHERE c.table_schema = 'public'
|
||||
AND c.table_name NOT IN (:excluded)
|
||||
AND col_description(
|
||||
(c.table_schema || '.' || c.table_name)::regclass,
|
||||
c.ordinal_position
|
||||
) IS NULL
|
||||
ORDER BY c.table_name, c.ordinal_position
|
||||
SQL,
|
||||
['excluded' => $excluded],
|
||||
['excluded' => ArrayParameterType::STRING],
|
||||
);
|
||||
|
||||
if ([] !== $rows) {
|
||||
$missing = array_map(
|
||||
static fn (array $row): string => sprintf('%s.%s', $row['table_name'], $row['column_name']),
|
||||
$rows,
|
||||
);
|
||||
|
||||
self::fail(sprintf(
|
||||
"%d colonne(s) sans COMMENT ON COLUMN — ajouter une description SQL dans la migration qui les cree (cf. .claude/rules/backend.md § Migrations Doctrine) :\n - %s",
|
||||
count($missing),
|
||||
implode("\n - ", $missing),
|
||||
));
|
||||
}
|
||||
|
||||
// Garde : si la requete ne renvoie rien et qu'aucune table publique
|
||||
// n'existe (sauf doctrine_migration_versions), le test deviendrait un
|
||||
// faux positif vert. On verifie qu'il y a bien des tables a auditer.
|
||||
$tableCount = (int) $conn->fetchOne(
|
||||
<<<'SQL'
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name NOT IN (:excluded)
|
||||
SQL,
|
||||
['excluded' => $excluded],
|
||||
['excluded' => ArrayParameterType::STRING],
|
||||
);
|
||||
|
||||
self::assertGreaterThan(0, $tableCount, 'Aucune table publique a auditer : schema vide ou whitelist trop large.');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user