Compare commits

...

33 Commits

Author SHA1 Message Date
Matthieu e1b8f8a28d feat(commercial) : add Client API Platform provider + processor + business rules
Branche l'API REST du repertoire clients (M1) sur l'entite Client preparee en
ERP-54. Operations GetCollection / Get / Post / Patch (pas de Delete au M1 :
l'archivage passe par PATCH isArchived).

ClientProvider :
- liste paginee (Paginator ORM, aligne sur la convention ERP-72) + echappatoire
  ?pagination=false
- exclut archives + soft-deletes par defaut (RG-1.24), ?includeArchived=true
  reintegre les archives (RG-1.25)
- tri companyName ASC (RG-1.26), filtres ?search (fuzzy companyName/lastName/
  email) et ?categoryType=<code>
- detail : 404 sur soft-delete, embarque contacts/adresses/ribs

ClientProcessor :
- normalisation serveur via ClientFieldNormalizer (RG-1.18 a 1.21)
- 409 sur doublon de nom de societe (RG-1.16) ; 409 dedie sur conflit de
  restauration (RG-1.23)
- gating par onglet : champ comptable -> accounting.manage, isArchived ->
  archive, mode strict 403 sur tout le payload (RG-1.28) ; archivage exclusif
  (RG-1.22) + pose/retrait archivedAt
- regles metier RG-1.01 (prenom/nom), RG-1.03 (distributor/broker exclusifs +
  controle du type de categorie), RG-1.12 (Virement -> banque), RG-1.13 (LCR ->
  >= 1 RIB), RG-1.04 (completude Information pour le role Commerciale)

Lecture comptable conditionnelle : ClientReadGroupContextBuilder ajoute le
groupe client:read:accounting selon commercial.clients.accounting.view.

Resolution des references categorie : CategoryReferenceDenormalizer resout les
IRI vers Category quand la propriete est type-hintee par le contrat
CategoryInterface (denormalisation impossible sur une interface sinon).

Contrats Shared :
- CategoryInterface::getCategoryTypeCode() (implemente par Category) pour la
  verification de type sans import inter-modules
- BusinessRoleAwareInterface (implemente par User) + BusinessRoles::COMMERCIALE
  pour detecter le role metier ; le code de role sera seede par ERP-74 et
  reutilise par ERP-59/60. RG-1.04 reste dormante tant qu'aucun user ne porte
  ce role.

Coordination stack :
- chaines de permission commercial.clients.* referencees ici, declarees en
  ERP-59 (tests RBAC complets en ERP-60)
- config globale de pagination (itemsPerPage client, max 50) portee par ERP-72
- referentiels comptables (PaymentType/Bank/...) exposes en ERP-56

Tests : 31 tests Commercial (integration admin sur les regles metier + unitaires
sur le gating, RG-1.04/1.12/1.13 et le context builder). Suite complete verte
(339 tests).
2026-05-29 16:41:40 +02:00
Matthieu 311e758dea feat(commercial) : add M1 client entities + accounting referentials + repositories
Entites metier (Client, ClientContact, ClientAddress, ClientRib) avec
#[Auditable] + Timestampable/Blamable, et 4 referentiels comptables statiques
(TvaMode, PaymentDelay, PaymentType, Bank). 8 repositories interfaces + impl
Doctrine. Aucun ApiResource (Provider/Processor = ERP-55).

- Client : 2 FK auto-referentes distributor/broker (mutuellement exclusives,
  CHECK en base), M2M categories, FK referentiels comptables, groupes de
  serialisation par onglet. Pas de #[ORM\UniqueConstraint] : unicite du nom de
  societe portee par l'index partiel Postgres (decision Q4).
- ClientRib : tous les champs audites, aucun #[AuditIgnore] sur iban/bic
  (decision 29/05, audit admin-only).
- M2M Category via le contrat Shared CategoryInterface + resolve_target_entities
  (regle n°1, pas d'import inter-modules) ; sites via SiteInterface.
- CommercialReferentialFixtures : re-seed idempotent des 4 referentiels (sinon
  vides apres db-reset car desormais tables mappees, purgees par les fixtures).
- Referentiels whitelistes dans EntitiesAreTimestampableBlamableTest::EXCLUDED.
- doctrine.yaml : mapping ORM du module Commercial + resolve CategoryInterface.
- ColumnCommentsCatalog : ajout des colonnes M1 (chemin schema:update/test) ;
  migration retrofit Version20260528120000 filtree sur les tables existantes
  pour ne pas casser sur les tables des modules crees plus tard.
- makefile test-db-setup : recreation de l'index partiel uq_client_company_name_active.

Refs ERP-54.
2026-05-29 15:36:14 +02:00
Matthieu 9f96d1c40d feat(commercial) : migrate M1 client tables + accounting referentials + extend category_type seed
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m20s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m36s
2026-05-29 14:48:40 +02:00
Matthieu 836f177ff9 docs(commercial) : migration racine + seed fixture CategoryType (blocages ERP-53 vérifiés) 2026-05-29 14:41:43 +02:00
Matthieu 1d91b4dea9 docs(commercial) : fix § 3.5 incohérence AuditIgnore RIB (aligné sur § 2.5)
Auto Tag Develop / tag (push) Failing after 30s
2026-05-29 14:23:51 +02:00
gitea-actions c402418937 chore: bump version to v0.1.54
Auto Tag Develop / tag (push) Successful in 35s
Build & Push Docker Image / build (push) Successful in 56s
2026-05-29 10:01:36 +00:00
matthieu 2866fb8865 [docs] M1 — Répertoire clients : specs front + back (#23)
Auto Tag Develop / tag (push) Successful in 36s
## Contexte

Spécifications front + back du **Module 1 — Répertoire clients** (premier module métier Tiers, extension du module `Commercial` existant).

Origine : V0 client `.docx` du 22/05/2026 (`M1-reportoire-clients.docx`) + maquette Figma `https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-31898`.

Pattern de rédaction : strictement aligné sur `docs/specs/M0-categories/` (spec-front léger + spec-back très détaillé).

## Contenu

- `docs/specs/M1-clients/spec-front.md` (289 lignes) — V0 client, structure UI, composants Malio, permissions par rôle, règles de formatage
- `docs/specs/M1-clients/spec-back.md` (1056 lignes) — décisions archi, modèle données, migration SQL Postgres, API REST, RBAC matrice complète, 27 RG numérotées, tests à automatiser, 16 HP, liens & dépendances

## Décisions structurantes (validées avec Tristan le 28/05/2026)

- **Module** : extension de `Commercial/` (pas nouveau module)
- **Catégories Client** : M2M `client_category` + seed `CategoryType` (`DISTRIBUTEUR`, `COURTIER`, `SECTEUR`, `AUTRE`)
- **Distributeur / Courtier** : 2 FK auto-référentes nullables sur `client` + contrainte CHECK mutex
- **Workflow création** : sauvegarde incrémentale par onglet (POST formulaire principal → PATCH par onglet)
- **Onglets « À venir »** (Transport / Statistiques / Rapports / Échanges) : placeholders blancs (frames vides, pas de message texte)
- **Archive vs delete** : flag `is_archived` exposé au M1, colonne `deleted_at` préparée mais non exposée (HP M2)
- **API adresse** : api-adresse.data.gouv.fr (BAN), appel direct front via `useAddressAutocomplete()`
- **Unicité métier** : SIREN + `companyName` + email (indexes partiels Postgres, ignorent archivés et soft-deletés)
- **Téléphones** : 2 colonnes plates `phone_primary` + `phone_secondary`
- **Export** : XLSX uniquement (controller custom avec `priority: 1`)
- **Compta = lecture seule** ⚠ s'écarte du tableau du `.docx` (ligne « Compta = Ajout/Modification Comptabilité uniquement » invalidée) — documenté en HP-M2-10

## Seed M1 (référentiels comptables)

| Référentiel | Valeurs |
|---|---|
| `tva_mode` | `FRANCE_VENTES`, `EXPORT_VENTES`, `INTRACOM_VENTES` |
| `payment_delay` | `J15`, `J30`, `A_RECEPTION` |
| `payment_type` | `VIREMENT`, `LCR`, `NON_SOUMISE`, `CHEQUE` |
| `bank` | `SG`, `CIC`, `CA` (Société Générale / CIC / Crédit Agricole) |
| `category_type` (extension M0) | `DISTRIBUTEUR`, `COURTIER`, `SECTEUR`, `AUTRE` |

## RG ajoutées au-delà du `.docx`

- **RG-1.14** : ≥ 1 bloc Contact valide obligatoire (renforcement Tristan)
- **RG-1.15/16/17** : unicités SIREN / nom / email
- **RG-1.18** : `companyName` UPPERCASE serveur
- **RG-1.19** : `firstName` / `lastName` Capitalize serveur
- **RG-1.20** : téléphones chiffres seuls en BDD, formatage `XX XX XX XX XX` au front
- **RG-1.21** : emails lowercase serveur
- **RG-1.22/23** : archivage / restauration + conflit unicité à la restauration

## Permissions RBAC (à synchroniser dans les 3 miroirs au moment du dev)

| Permission | Admin | Bureau | Compta | Commerciale | Usine |
|---|---|---|---|---|---|
| `commercial.clients.view` |  |  |  |  |  |
| `commercial.clients.manage` |  |  |  |  |  |
| `commercial.clients.accounting.view` |  |  |  |  |  |
| `commercial.clients.accounting.manage` |  |  |  |  |  |
| `commercial.clients.archive` |  |  |  |  |  |

## Prochaines étapes (hors MR)

1. Revue / validation des specs par Matthieu
2. Création du **TaskGroup Lesstime** `M1 — Répertoire clients` (projet `ERP / Starseed`, projectId=6)
3. Découpage en ~14 tickets (ordre indicatif listé en bas du `spec-back.md`)

## Reviewer suggéré

Matthieu (CP MALIO).

## Cible

`develop`.

---------

Co-authored-by: admin malio <malio@yuno.malio.fr>
Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #23
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-05-29 09:58:32 +00:00
gitea-actions 0ed131ce57 chore: bump version to v0.1.53
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 16s
2026-05-29 09:44:34 +00:00
matthieu a948eed9b6 [ERP-67] Documenter toutes les colonnes BDD via COMMENT ON COLUMN + garde-fou (#24)
Auto Tag Develop / tag (push) Successful in 7s
Ticket Lesstime : ERP-67 — `[Convention SQL / Backend / L]`

## Objectif

Documenter toutes les colonnes BDD via `COMMENT ON COLUMN` (visible dans DBeaver / DataGrip / pgAdmin sans lire le code Doctrine) et verrouiller la convention par un garde-fou de test architecture.

## Changements

### Convention (CLAUDE.md + rules)

- `CLAUDE.md` regle ABSOLUE n°12 : toute migration creant ou modifiant une colonne doit poser un `COMMENT ON COLUMN` (FR, ≤ 200 caracteres).
- `.claude/rules/backend.md` § Migrations Doctrine : exemples + helper standardise pour les 4 colonnes du `TimestampableBlamableTrait`.

### Garde-fou architecture

- `tests/Architecture/ColumnsHaveSqlCommentTest` : echoue si une colonne `public` n'a pas de `col_description` (hors `doctrine_migration_versions` et `fake_site_aware_entity` fixture de test).
- Whitelist metier `EXCLUDED_TABLES` volontairement vide.

### Retrofit des tables existantes

- Migration `Version20260528120000` : 64 `COMMENT ON TABLE/COLUMN` sur les 11 tables metier (audit_log, category, category_type, permission, role, role_permission, site, user, user_permission, user_role, user_site).
- Source unique de verite : `src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php`.
- Commande `app:apply-column-comments` (Module/Core/Infrastructure/Console) : rejoue le catalogue apres `doctrine:schema:update --force` (sinon l'ORM drop les commentaires absents du mapping PHP). Branchee dans `makefile test-db-setup` et `.gitea/workflows/pull-request.yml`.

## Validation

- `make db-reset` puis `make test` : 312 tests verts, 0 regression.
- `make php-cs-fixer-allow-risky` : 0 fix.
- Couverture : 53/53 colonnes documentees sur `starseed` et `starseed_test`.

## Test plan

- [ ] `make db-reset` passe sans erreur.
- [ ] `make test` passe ; `ColumnsHaveSqlCommentTest` vert sur DB de test.
- [ ] Verifier dans DBeaver / pgAdmin que les commentaires apparaissent sur les colonnes de `category`, `user`, `audit_log`.
- [ ] Verifier que le workflow CI Gitea (`pull-request.yml`) passe.

## A noter pour la suite

La convention `options: ['comment' => '...']` sur chaque `#[ORM\Column]` reste recommandee pour les nouvelles entites — Doctrine genere alors automatiquement le `COMMENT ON COLUMN` dans la migration et `schema:update` le preserve sans avoir a rejouer le catalogue. A discuter si on veut en faire une regle forte.

---------

Co-authored-by: admin malio <malio@yuno.malio.fr>
Co-authored-by: Matthieu <contact@malio.fr>
Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #24
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-05-29 09:41:29 +00:00
gitea-actions fc78f434d1 chore: bump version to v0.1.52
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 32s
2026-05-29 09:23:47 +00:00
tristan 53e19d61ac [ERP-51] Écrire les tests Vitest des composables Catalog (#26)
Auto Tag Develop / tag (push) Successful in 7s
## Résumé

Couvre les deux composables Catalog extraits du refactor ERP-50 avec **42 tests Vitest unitaires** (happy-dom, sans dépendance backend).

- 14 tests sur \`useCategoriesAdmin\` (fetchAll/fetchTypes, includeDeleted, loading, error, reset, singleton)
- 28 tests sur \`useCategoryForm\` (validation RG-1.02/1.04/1.05 + trim, POST/PATCH/DELETE, mapping 409 RG-1.07 + 422 violations, isDirty, loadFrom, reset, isolation)

Mocks via \`vi.stubGlobal\` (useApi / useI18n / useToast) et \`vi.mock\` (\`~/shared/stores/auth\` pour neutraliser l'auto-enregistrement \`onAuthSessionCleared\`). La suite tourne en **~1.2s**.

Ticket Lesstime : #51

## Tests automatisés

- \`make nuxt-test\` ✓ 85 tests (dont 42 nouveaux), 0 échec, 1.2s

## Reviewer

@matthieu

## À tester en local

- [ ] \`make nuxt-test\` passe
- [ ] Mock \`useApi\` reste stable si le pattern d'auto-import Nuxt évolue
- [ ] Couverture jugée suffisante des cas back miroir

Reviewed-on: #26
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-29 09:23:41 +00:00
gitea-actions ece8146c03 chore: bump version to v0.1.51
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 32s
2026-05-29 09:18:36 +00:00
tristan 58589e93d0 [ERP-50] Implémenter les composables useCategoriesAdmin et useCategoryForm (#25)
Auto Tag Develop / tag (push) Successful in 6s
Lien Lesstime : #50

## Résumé
Refacto : extraction de la logique fetch/CRUD inline de la page categories (ERP-49) vers deux composables dédiés, conformément au pattern Starseed (useSidebar / useModules).

- **useCategoriesAdmin** : singleton state (`categories` + `types` + `loading` + `error`). Pré-chargement des types au mount de la page (au lieu d'un fetch par ouverture du drawer). Reset au logout via `onAuthSessionCleared` + appel explicite dans `logout.vue`.
- **useCategoryForm** : state local par form (pas singleton, contrairement à `useCategoriesAdmin`). Valide côté client en miroir des RG back (RG-1.02 / RG-1.04 / RG-1.05), mappe les erreurs 409 (RG-1.07 doublon) et 422 (violations API Platform) sur les bons champs. `submitCreate` / `submitUpdate` / `submitDelete` renvoient la ressource ou `null` pour découpler la décision de fermeture du drawer.

La page et le drawer deviennent purement présentationnels — aucune régression UX attendue (mêmes validations, mêmes toasts, même bascule view → edit via `isDirty` exposé par le composable).

## Décisions
- `useCategoriesAdmin` porte aussi les types (`fetchTypes`), pas seulement `categories` — sinon le drawer continuerait à fetcher tout seul et la refacto n'aurait rien centralisé.
- `buildCreatePayload` retourne `Record<string, unknown>` (pas `CategoryCreateInput`) car la signature `useApi.post(body: AnyObject)` n'accepte pas les types stricts (variance TS).
- Reset au logout : double mécanisme conservé (auto via `onAuthSessionCleared` pour 401, explicite dans `logout.vue` pour logout volontaire — pattern existant Starseed).

## Tests
- `npx nuxi typecheck` ✓ 0 erreur nouvelle (1 erreur pré-existante sur `modules/catalog/nuxt.config.ts` héritée d'ERP-49)
- `make nuxt-test` ✓ 43/43, 0 régression
- PHPUnit ✓ 311/311 (pre-commit)
- Manuel navigateur : à valider (cahier de test consigné dans Lesstime #50)

## ⚠ Note d'intégration
La branche contient encore les 3 commits ERP-49 (`4046910`, `216f388`, `934a12b`) car elle a été créée depuis la branche ERP-49 avant son merge sur develop. Selon l'ordre de merge : soit ERP-49 est mergée d'abord (cette MR ne contiendra plus que le commit ERP-50 après rebase auto), soit cette MR embarque tout l'historique catalog.

Reviewed-on: #25
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-29 09:18:29 +00:00
gitea-actions e0d59962d6 chore: bump version to v0.1.50
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 37s
2026-05-29 08:59:54 +00:00
tristan 3ce40a707f [ERP-49] Créer la page Gestion des catégories (datatable + drawer) (#22)
Auto Tag Develop / tag (push) Successful in 7s
## Contexte
Ticket Lesstime : [#49](https://lesstime.malio.fr/tasks/460) — premier ticket front du M0 (Gestion des catégories).
Suit la chaîne back ERP-43..48 mergée sur develop.

## Contenu first draft (Claude Code)
- Page Nuxt `/admin/categories` (`MalioDataTable` + bouton `+ Ajouter`)
- Composant `<CategoryDrawer>` : modes création / consultation / édition, transition auto view → edit à la première modification, validation client miroir RG-1.02 (name requis) / RG-1.04 (longueur 2-120) / RG-1.05 (type requis), mapping erreurs 409 (doublon) et 422 (violations)
- Composant `<CategoryDeleteModal>` : confirmation suppression (soft delete RG-1.12)
- Types TS `Category`, `CategoryType`, `User`
- i18n `admin.categories.*` ajouté dans `fr.json`
- Fix latent en passant : ajout de `'categories'` à `AdminLinkSlug` du Page Object e2e (oublié lors d'ERP-47 quand l'item sidebar a été ajouté)

## Décisions marquantes
- Logique `fetch` inline dans `categories.vue` (sera extraite en composables `useCategoriesAdmin` + `useCategoryForm` au ticket ERP-50 / 0.8)
- Drawer dans composant séparé pour réutilisabilité
- Aucun état de tableau persisté dans l'URL (règle ABSOLUE n°6)
- Tous les composants formulaires sont `Malio*` (`MalioDataTable`, `MalioInputText`, `MalioSelect`, `MalioButton`, `MalioDrawer`)

## Polish à venir (Tristan)
Tristan testera en navigateur et peaufinera : UX, classes Tailwind, animations, icônes, wording de toasts.
Les commits de polish suivront sur la même branche.

## Tests
- `npx nuxi typecheck` : net 0 nouvelle erreur (mêmes erreurs pré-existantes que sur `develop`, infrastructure auto-import) + 1 latente corrigée (AdminLinkSlug)
- `make nuxt-test` : 43/43 passent (0 régression)
- Tests manuels navigateur : voir cahier de test du ticket Lesstime #49

## Note pre-commit hook
Le hook a remonté un échec PHPUnit pré-existant sur `develop` (`CategoryDeleteTest::testPatchOnSoftDeletedReturns404` → 401 au lieu de 404, JWT non initialisé en test runner). Aucun PHP touché dans cette MR. Commit avec `--no-verify` autorisé par Tristan.

## Reviewer suggéré
Matthieu (back ↔ front + permissions).

---------

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #22
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-29 08:59:47 +00:00
gitea-actions 9613857650 chore: bump version to v0.1.49
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 1m8s
2026-05-28 12:29:43 +00:00
tristan 2a0918bbfe [#ERP-42] Mettre à jour la lib Malio UI (#16)
Auto Tag Develop / tag (push) Successful in 9s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #16
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-28 12:27:33 +00:00
gitea-actions 8e31e1759c chore: bump version to v0.1.48
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 18s
2026-05-28 10:25:11 +00:00
matthieu 4824690923 [ERP-48] Écrire les tests PHPUnit RG-1.01 à RG-1.17 (#20)
Auto Tag Develop / tag (push) Successful in 9s
## Mode stacked PR — DERNIER ticket back du M0

**Cible : `feature/ERP-47-declarer-module-catalog-rbac`** (PAS develop).
Quand la MR ERP-47 sera mergée sur develop, repointer la cible de cette MR vers develop.

## Résumé

Suite PHPUnit complète qui mappe chaque RG (1.01 → 1.17) de la spec M0 Catalog vers un ou plusieurs tests ciblés. **63 nouveaux tests** dans 9 classes sous `tests/Module/Catalog/Api/`.

## Cahier de test

| RG | Test(s) |
|---|---|
| **RG-1.01** | `CategoryPermissionsTest::testPersonaWithoutCatalogPermissionGets403*` (4 personas × 4 verbes) + `testAnonymousGets401*` + `testAdminGets{200,201,204}*` + `testUserWithViewPermissionGets200*` |
| **RG-1.02** | `CategoryValidationTest::testNameRequiredReturns422` + `testNameEmptyStringReturns422` + `testNameWhitespaceOnlyReturns422` |
| **RG-1.03** | `CategoryValidationTest::testNameIsTrimmedOnCreate` |
| **RG-1.04** | `CategoryValidationTest::testNameTooShortReturns422` + `testNameTooLongReturns422` + `testNameAtMaxLengthIs201` |
| **RG-1.05** | `CategoryValidationTest::testCategoryTypeRequiredReturns422` + `testCategoryTypeNullIsRejected` |
| **RG-1.06** | `CategoryValidationTest::testCategoryTypeMustExistReturns4xx` |
| **RG-1.07** | `CategoryUniqueTest::testDuplicateNameSameTypeReturns409` + `testDuplicateNameCaseInsensitiveReturns409` + `testSameNameDifferentTypeAllowed` + `testRecreateAfterSoftDeleteAllowed` |
| **RG-1.08** | `CategoryListTest::testListExcludesSoftDeletedByDefault` |
| **RG-1.09** | `CategoryListTest::testIncludeDeletedFlagSurfacesSoftDeleted` |
| **RG-1.10** | `CategoryListTest::testDefaultSortIsNameAsc` |
| **RG-1.11** | `CategoryGetTest::testGetSoftDeletedReturns404` + `testGetSoftDeletedWithFlagReturns200` + `testGetNonExistentReturns404` + `testGetActiveCategoryReturns200` |
| **RG-1.12** | `CategoryDeleteTest::testDeleteReturns204AndPersistsSoftDelete` |
| **RG-1.13** | `CategoryDeleteTest::testPatchCannotSetDeletedAt` |
| **RG-1.11 étendue (404 sur soft-deleted)** | `CategoryDeleteTest::testPatchOnSoftDeletedReturns404` + `testDeleteOnSoftDeletedReturns404` |
| **Audit** | `CategoryAuditTest::testAuditLogOnCreate` + `testAuditLogOnUpdate` + `testAuditLogOnSoftDelete` + `testAuditLogPerformerCarriesAuthenticatedUsername` |
| **RG-1.15** | `CategoryTimestampableBlamableTest::testCreatedByAdminOnPost` + `testCreatedByNullInConsoleContext` |
| **RG-1.16** | `CategoryTimestampableBlamableTest::testPatchUpdatesUpdatedFieldsOnly` + `testSoftDeleteAlsoUpdatesUpdatedFields` |
| **RG-1.17** | `EntitiesAreTimestampableBlamableTest::testAllBusinessEntitiesImplementBothInterfaces` (déjà livré ERP-52, reste vert avec `Category` détectée Timestampable/Blamable et `CategoryType` whitelistée) |

## Side fixes révélés par la suite

### 1. `Category.php` — `normalizer: 'trim'` sur Assert\NotBlank + Length

Avant le fix, POST `{name: "   "}` retournait **201** au lieu de **422** : le Processor trim après validation, mais NotBlank ne fait pas de trim natif. La RG-1.02 (whitespace-only → 422) combinée à la RG-1.03 (trim serveur) exige le `normalizer: 'trim'`. 1 ligne, aligne le contrat sans réordonnancer Validate/Process.

### 2. `makefile` — recréer l'index partiel `uq_category_name_type_active` après `schema:update`

`doctrine:schema:update --env=test --force` drop systématiquement l'index partiel `uq_category_name_type_active` (l'ORM ne sait pas exprimer un index fonctionnel + partiel via attribut Doctrine, donc le voit comme orphelin). Conséquence : POST doublon `(name, type)` retournait **201** au lieu de **409** (la `UniqueConstraintViolation` ne se déclenche plus). Fix : `dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS ..."` ajouté en fin de `test-db-setup`. Approche chirurgicale validée avec Matthieu, ne touche pas à `fake_site_aware_entity` (dépend de schema:update pour exister avant le purger fixtures:load).

## Helpers livrés

`tests/Module/Catalog/Api/AbstractCatalogApiTestCase.php` :
- Factories : `createCategory()`, `createCategoryType()`
- Auth : `createAdminClient()`, `createManageClient()`, `createViewClient()`, `createPersonaClient(string \$label)` (4 personas MALIO sans permission catalog)
- Cleanup : purge complète Category + CategoryType (aucune fixture au M0) + users/roles `test_*`

## Vérifications

-  `make php-cs-fixer-allow-risky` (auto-applied via pre-commit)
-  `make db-reset` (index partiel restauré, vérifié `\d category`)
-  `make test` → **311 tests, 1071 assertions, 0 failure, 0 risky**
  (248 existants + 63 nouveaux, dont 6 deprecations + 6 notices héritées des tests Core RBAC pré-existants — pas de régression introduite par ce ticket)

## Suite

DERNIER ticket back du M0. ERP-52, ERP-43, ERP-44, ERP-45, ERP-46, ERP-47, ERP-48 sont tous en review.
Quand la MR ERP-47 sera mergée sur develop, Matthieu repointera la cible de cette MR vers develop. Tristan reviewe la stack en série.

---------

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #20
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-05-28 09:48:16 +00:00
matthieu fceb1e0e83 [ERP-47] Déclarer le module Catalog et synchroniser RBAC (#19)
Auto Tag Develop / tag (push) Successful in 10s
## Contexte

Ticket Lesstime #47 — M0 position 0.5. Wire le nouveau module **Catalog** dans Starseed et synchronise les 3 sources RBAC (sidebar + personas E2E + seed back). Couvre **RG-1.01** (Admin uniquement) côté infra.

Spec : [`docs/specs/M0-categories/spec-back.md` § 5.1 + § 5.3](https://gitea.malio.fr/MALIO-DEV/Starseed/src/branch/feature/M0-spec-categories/docs/specs/M0-categories/spec-back.md).

> ⚠ **Mode stacked PR** — cible `feature/ERP-46-exposer-category-type-lecture-seule`, **PAS** `develop`. Quand la MR ERP-46 sera mergée sur develop, repointer la cible de cette MR vers develop.

## Modifications (6 fichiers — règle ABSOLUE Starseed n°8 : les 3 sources RBAC bougent ENSEMBLE)

| Fichier | Rôle |
|---|---|
| `src/Module/Catalog/CatalogModule.php` (nouveau) | Déclaration du module : `ID=catalog`, `LABEL=Catalogue`, `REQUIRED=true`, 2 permissions (`view` + `manage`) |
| `config/modules.php` | Wire `CatalogModule::class` |
| `config/sidebar.php` | Item « Gestion des catégories » dans section Administration, gate sur `catalog.categories.view` |
| `frontend/i18n/locales/fr.json` | Clé `sidebar.catalog.categories` = `Gestion des catégories` |
| `frontend/tests/e2e/_fixtures/personas.ts` | `user-full` reçoit les 2 permissions + `'categories'` dans `expectedAdminLinks`. `super-admin` et `ALL_ADMIN_LINKS` étendus avec `'categories'`. `user-readonly` inchangé (Admin-only au M0 — pas de mode read-only spec'é). |
| `src/Module/Core/Infrastructure/Console/SeedE2ECommand.php` | Miroir back : `user-full` reçoit les 2 permissions |

## Décisions

- **`REQUIRED = true`** : la spec § 5.1 + le prompt user disent `true` (Category sera FK NOT NULL côté futurs modules Tiers). Le ticket Lesstime dit `false` par erreur — j'ai suivi la spec.
- **Personas E2E « Admin » = `user-full`** : pas de persona métier « Admin » explicite dans `personas.ts` (personas techniques : `super-admin` bypass, `user-full` = toutes permissions). `user-full` est l'équivalent fonctionnel.
- **`user-readonly` NON touché** : RG-1.01 dit « Admin uniquement », pas de pattern read-only spec'é au M0. À rouvrir dans un futur ticket si besoin.

## Validation

- `make php-cs-fixer-allow-risky` ✓ (0 fichier corrigé)
- `make db-reset` ✓ (sync-permissions : 11 codes en base, dont les 2 nouveaux `catalog.categories.*` vérifiés via `dbal:run-sql`)
- `make test` ✓ (248 tests, 0 régression)
- **RG-1.01 vérifiée manuellement** via curl :
  - Admin → 200 sur `GET /api/categories` et `GET /api/category_types`
  - Bob (zéro permission) → 403 sur `GET /api/categories`, `POST /api/categories`, `GET /api/category_types`
  - Anonyme → 401 sur `GET /api/categories`

---------

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #19
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-05-28 09:45:33 +00:00
matthieu adda62c1e1 [ERP-46] Exposer le référentiel CategoryType en lecture seule (#18)
Auto Tag Develop / tag (push) Successful in 10s
## Contexte

Ticket Lesstime #46 — position 0.4 (M0 Catalog, quick win).

Expose `CategoryType` en lecture seule pour alimenter le `<MalioSelect>` du formulaire `Category` côté front. Pas d'écriture exposée au M0 (table vide à la livraison).

## Mode stacked PR

⚠ **Cible (base branch) : `feature/ERP-45-implementer-provider-processor-category`** (PAS develop).
Quand MR ERP-45 sera mergée sur develop, repointer la cible de cette MR vers develop.

## Changement

Le gros du travail (`#[ApiResource(operations: [GetCollection, Get])]`, security `is_granted('catalog.categories.view')`, groupes de sérialisation) a été livré dans le ticket ERP-44. Cette MR ajoute uniquement ce qui manquait à la spec § 4.6 :

- `order: ['label' => 'ASC']` sur l'opération `GetCollection` → tri alphabétique stable pour le select front.

## Critères d'acceptation (spec § 4.6)

- [x] `GET /api/category_types` retourne tous les `CategoryType` triés par `label ASC`
- [x] `GET /api/category_types/{id}` retourne le détail
- [x] POST / PATCH / DELETE → 404 (opérations non déclarées)
- [x] Security `is_granted('catalog.categories.view')` sur les 2 opérations
- [x] `make php-cs-fixer-allow-risky` passe (0 fix)

## Vérifications

\`\`\`
$ php bin/console debug:router | grep category_type
_api_/category_types{._format}_get_collection   GET   /api/category_types.{_format}
_api_/category_types/{id}{._format}_get         GET   /api/category_types/{id}.{_format}
\`\`\`

→ Exactement 2 routes générées, aucune POST/PATCH/DELETE.

- \`make php-cs-fixer-allow-risky\` ✓ (0 fix)
- \`make test\` ✓ (248/248)

---------

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #18
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-05-28 09:45:06 +00:00
matthieu 80fabcae91 [ERP-45] Implémenter Provider et Processor Category (#17)
Auto Tag Develop / tag (push) Successful in 7s
## Mode stacked PR — cible `feature/ERP-44-creer-entites-category`

> ⚠ Cette MR a pour base la branche ERP-44 (en review). Quand ERP-44 sera mergée sur develop, **repointer la cible vers develop**.

## Résumé
- Provider `CategoryProvider` : filtre soft-delete par défaut (RG-1.08), `?includeDeleted=true` (RG-1.09), tri name ASC (RG-1.10), 404 si soft-deleted hors flag (RG-1.11).
- Processor `CategoryProcessor` : trim du `name` (RG-1.03), conversion DELETE → UPDATE (RG-1.12), mapping `UniqueConstraintViolationException` → HTTP 409 avec message exact (RG-1.07).
- Câblage Provider/Processor dans `#[ApiResource]` de `Category`. Provider câblé aussi sur Patch + Delete (au-delà du scope strict du ticket) pour fermer la fuite RG-1.11 sur PATCH.
- `DoctrineCategoryRepository` expose `createListQueryBuilder($includeDeleted)`.

## Décisions notables
- **Filtre soft-delete via QueryBuilder** (choix `a` du ticket) : pas de filtre Doctrine global, lisibilité directe.
- **Pas de `remove_processor` injecté** : la DELETE est convertie en UPDATE via le `persist_processor`. API Platform 4 utilise le processor déclaré sur l'opération sans fallback automatique.
- **Provider sur Patch + Delete aussi** : décision prise pendant le dev pour fermer une fuite RG-1.11 sur PATCH. Coût : 2 lignes dans `Category.php`.

## Tests
- `make php-cs-fixer-allow-risky` ✓
- `make test` ✓ (248 tests, 0 régression — pas de test métier Category, c'est ERP-48)
- Tests manuels curl ✓ — 8 cas RG-1.03 → RG-1.13 validés (détail dans le ticket Lesstime #45)

## Tickets
- Lesstime : #45 (ERP-45) → En review
- Position M0 : 0.3
- Spec : `docs/specs/M0-categories/spec-back.md` § 4.1 + § 4.3 + § 4.4 + § 4.5

## Suite
- ERP-46 (0.4 CategoryType lecture seule) — base : cette branche

---------

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #17
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-05-28 09:44:43 +00:00
matthieu ff6086bc4d [ERP-44] Créer les entités Category et CategoryType (#15)
Auto Tag Develop / tag (push) Successful in 7s
## Objectif
Couche Domain DDD du module Catalog (ticket M0 · position 0.2). Crée les entités `Category` et `CategoryType`, leurs repositories, et branche le pattern Timestampable + Blamable Shared.

> **Mode stacked PR** : cible `feature/ERP-43-migrer-tables-category`. Quand la MR ERP-43 sera mergée sur develop, Matthieu repointera la cible de cette MR vers develop.

## Contenu
- **`Category`** : `#[ApiResource]` (GetCollection, Get, Post, Patch, Delete), `#[Auditable]`, `TimestampableBlamableTrait` + interfaces, asserts (`NotBlank`/`Length` sur `name`, `NotNull` sur `categoryType`), soft delete via `deletedAt`. Provider/Processor branchés au ticket 0.3 (ERP-45).
- **`CategoryType`** : référentiel statique en lecture seule (GetCollection + Get), embarqué dans `Category` via le groupe `category:read`. Pas de Trait — whitelisté dans `EntitiesAreTimestampableBlamableTest::EXCLUDED` (RG-1.17).
- **Repositories** : interfaces Domain + implémentations Doctrine.
- **`config/packages/doctrine.yaml`** : mapping ORM `Catalog` inconditionnel (miroir de `Sites`) — nécessaire pour que l'ORM reconnaisse les entités. La déclaration du module (`config/modules.php`) reste pour le ticket 0.5 (ERP-47).
- Groupes : `category:read` / `category:write` + `default:read` (expose les 4 colonnes du Trait).

## Notes techniques
- Index nommés déclarés sur les entités pour matcher la migration (cf. Role/Permission/Site).
- L'index unique partiel `uq_category_name_type_active` (`LOWER(name), category_type_id WHERE deleted_at IS NULL`) reste possédé par la seule migration : Doctrine ORM ne sait pas exprimer un index fonctionnel + partiel. Seul diff résiduel de `doctrine:schema:validate`.

## Tests
- `make php-cs-fixer-allow-risky` ✓
- `make test` ✓ (248 tests, 0 échec)
- `make db-reset` ✓
- `debug:router` ✓ (7 routes exposées)
- `doctrine:schema:validate` : mapping correct

---------

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #15
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-05-28 09:44:18 +00:00
gitea-actions d01bbfbc65 chore: bump version to v0.1.43
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 17s
2026-05-28 09:42:08 +00:00
matthieu 92a6343b66 [ERP-43] Migrer les tables Category et CategoryType (#14)
Auto Tag Develop / tag (push) Successful in 8s
## Ticket
- Lesstime : [#43](https://gitea.malio.fr) — Migrer les tables Category et CategoryType (M0 Catalog, position 0.1)

## Contenu
Migration Doctrine `migrations/Version20260527164000.php` (namespace racine `DoctrineMigrations`, règle ABSOLUE Starseed n°11) :
- Table `category_type` : `id INT IDENTITY`, `code VARCHAR(40)` (UNIQUE), `label VARCHAR(120)`
- Table `category` : `id`, `name`, `category_type_id` (FK RESTRICT), `deleted_at` (soft delete), + 4 colonnes Timestampable/Blamable (`created_at`/`updated_at` NOT NULL, `created_by`/`updated_by` nullable FK `"user"` ON DELETE SET NULL)
- Index unique partiel `uq_category_name_type_active` sur `(LOWER(name), category_type_id) WHERE deleted_at IS NULL` → matérialise **RG-1.07**
- Index `idx_category_deleted_at`, `idx_category_type_id`, `idx_category_created_by`, `idx_category_updated_by`

## Tests
- `make php-cs-fixer-allow-risky` ✓
- `make db-reset` ✓ (migration exécutée sans erreur)
- `make test` ✓ — 248 tests / 858 assertions, 0 échec
- Vérification psql `\d category` ✓ (index partiel + 8 colonnes + 3 FK avec les bons ON DELETE)

## ⚠ Mode stacked PR
Cette MR cible `feature/ERP-52-creer-pattern-timestampable-blamable-shared` au lieu de `develop`. Quand la MR #13 (ERP-52) sera mergée sur develop, Matthieu repointera la cible de cette MR vers develop.

Reviewer suggéré : Tristan

---------

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #14
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-05-28 09:40:00 +00:00
gitea-actions 02df221a0b chore: bump version to v0.1.42
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 20s
2026-05-28 09:39:02 +00:00
matthieu 6efe7aa8ea [ERP-52] Créer le pattern Timestampable + Blamable Shared (#13)
Auto Tag Develop / tag (push) Successful in 9s
## Contexte
Ticket Lesstime : [#52](https://project.malio-dev.fr/projects/6/tasks/463)
Position dans le groupe M0 : 0.0 (prérequis transverse)

## Implémentation
- 2 interfaces (`TimestampableInterface`, `BlamableInterface`) dans `Shared/Domain/Contract/`
- 1 trait (`TimestampableBlamableTrait`) dans `Shared/Domain/Trait/`
- 1 Subscriber Doctrine (`TimestampableBlamableSubscriber`) dans `Shared/Infrastructure/Doctrine/`
- 1 ligne `resolve_target_entities` ajoutée à `config/packages/doctrine.yaml` (`UserInterface` → `User`)
- 1 test architecture (`EntitiesAreTimestampableBlamableTest`) garde-fou L3 de la spec § 2.8.bis
- 1 test unitaire (`TimestampableBlamableSubscriberTest`) 4 cas

## Décision EXCLUDED (cf. réponse review)
Les 4 entités préexistantes (`User`, `Role`, `Permission`, `Site`) sont **whitelistées** dans `EXCLUDED` avec justification par entrée, plutôt que rétrofitées dans ce ticket. Le rétrofit de `User` et `Site` est documenté en **HP-9 / HP-10** (récursion Blamable + migration → décision archi scopée). Doc mise à jour : spec § 2.8.bis, § 9, et `.claude/rules/backend.md`.

## Tests
- PHPUnit : 5 nouveaux tests, 0 échec, 0 risky (248 tests / 874 assertions au total)
- php-cs-fixer : OK

## Reviewer suggéré
- Tristan

---------

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #13
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-05-28 09:37:18 +00:00
gitea-actions 6c27ac8640 chore: bump version to v0.1.41
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 22s
2026-05-28 09:33:38 +00:00
matthieu 2ef344e22f ci : speed up PR workflow (nuxt build cache + remove broken runner cache) (#21)
Auto Tag Develop / tag (push) Successful in 11s
## Contexte
2 fixes CI extraits de la MR #13 (ERP-52) pour propreté. Ces commits sont indépendants du pattern Shared.

## Contenu
- db6619a ci(frontend) : accelere le job PR (nuxt build + cache node_modules & build Nuxt/Vite)
- 8035a9d ci : retire tout le caching (backend de cache runner injoignable, timeout 4m30)

## Reviewer suggéré
Matthieu (CI infra).

---------

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #21
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-05-28 09:32:27 +00:00
gitea-actions 43d80df1e1 chore: bump version to v0.1.40
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 27s
2026-05-27 09:15:00 +00:00
matthieu 5db644d22e docs(catalog) : M0 categories specs (back + front) (#12)
Auto Tag Develop / tag (push) Successful in 10s
## Contexte

Premier passage du workflow MALIO sur un module concret. Cette MR introduit **uniquement la documentation** (2 specs Markdown) du **Module 0 — Gestion des catégories**. Aucun code applicatif n'est touché.

La spec back a été cadrée sur l'archi DDD réelle de Starseed (cf. `.claude/rules/architecture.md` + `backend.md`) après audit du repo (modules `Core`, `Commercial`, `Sites`). Elle introduit aussi un **nouveau module `Catalog`** (bounded context "référentiels partagés") — non créé dans cette MR, il viendra avec les tickets de dev.

## Contenu

| Fichier | Lignes | Objet |
|---|---|---|
| `docs/specs/M0-categories/spec-back.md` | 700 | Spec back v1.1 : modèle data, API REST (5 endpoints + CategoryType lecture seule), 13 RG (RG-1.01 → RG-1.13), validation, autorisation, audit, tests, hors-périmètre |
| `docs/specs/M0-categories/spec-front.md` | 113 | Spec front V0 client (validée 2026-05-22) : UI admin (datatable + drawer), 2 champs (Nom + Type), 3 actions (Ajouter / Consulter / Modifier), permissions par rôle |

## Décisions d'archi (auto-validation back-only)

Toutes les décisions sont documentées dans `spec-back.md § 2` :

- **Module `Catalog`** créé séparément de `Commercial` pour rester réutilisable par les futurs modules Tiers (M-Clients, M-Fournisseurs, M-Prestas)
- **IDs INT IDENTITY** (cohérent avec `User`, `Role`, etc.)
- **Soft delete** via `deleted_at TIMESTAMP(0) WITHOUT TIME ZONE NULL` — *pattern introduit par ce module* (aucune autre entité Starseed ne le portait)
- **Index unique partiel Postgres** sur `(LOWER(name), category_type_id) WHERE deleted_at IS NULL` → unicité case-insensitive parmi non soft-deleted, recréation possible après suppression logique
- **Granularité permissions = `view` + `manage`** (aligné sur `core.users.view` + `core.users.manage`)
- **Référentiel `CategoryType`** = entité séparée, table vide à la livraison, valeurs seedées plus tard (HP-1)

## Règles métier (RG-1.01 → RG-1.13)

Chaque RG est numérotée, stable, testable, et constituera un critère d'acceptation côté ticket Lesstime (cf. `spec-back.md § 7`).

Couverture par catégorie :
- Autorisation : RG-1.01 (Admin only)
- Champ `name` : RG-1.02 (obligatoire), RG-1.03 (trim), RG-1.04 (longueur 2-120)
- Champ `categoryType` : RG-1.05 (obligatoire), RG-1.06 (référence valide)
- Unicité : RG-1.07 (case-insensitive sur couple, hors soft-deleted)
- Liste : RG-1.08 (exclut soft-deleted), RG-1.09 (flag `?includeDeleted=true`), RG-1.10 (tri `name ASC`)
- Détail : RG-1.11 (404 si soft-deleted)
- Suppression : RG-1.12 (soft delete), RG-1.13 (`deletedAt` non modifiable via PATCH)

## Découpe en tickets Lesstime

TaskGroup `#22 — M0 — Gestion des catégories` créé sur le projet **STARSEED**. 9 tickets en backlog :

| # | Ticket Lesstime | Effort | Tag |
|---|---|---|---|
| 0.1 | `#43` Migrer les tables Category et CategoryType | S | Backend |
| 0.2 | `#44` Créer les entités Category et CategoryType | M | Backend |
| 0.3 | `#45` Implémenter Provider et Processor Category | M | Backend |
| 0.4 | `#46` Exposer le référentiel CategoryType en lecture seule | S | Backend |
| 0.5 | `#47` Déclarer le module Catalog et synchroniser RBAC | S | Backend |
| 0.6 | `#48` Écrire les tests PHPUnit RG-1.01 à RG-1.13 | M | Backend |
| 0.7 | `#49` Créer la page Gestion des catégories (datatable + drawer) | L | Frontend |
| 0.8 | `#50` Implémenter les composables useCategoriesAdmin et useCategoryForm | M | Frontend |
| 0.9 | `#51` Écrire les tests Vitest des composables Catalog | S | Frontend |

**Total estimé** : ~15-25h (9 mini-MR de 1-4h).

## Hors-périmètre (HP)

Pas codés / pas inclus dans cette spec (cf. `spec-back.md § 9`) :

- HP-1 : CRUD du référentiel `CategoryType` (spec dédiée à venir)
- HP-2 : Référencement par les Tiers (les FK `category_id` sur Clients/Fournisseurs/Prestas viendront avec leurs modules)
- HP-3 : Restauration d'une catégorie soft-deleted
- HP-4 : Hard delete
- HP-5 : i18n du `name`
- HP-6 : Recherche serveur / filtres avancés
- HP-7 : Catégories hiérarchiques (parent / enfant)
- HP-8 : Seed des rôles métier Bureau / Compta / Commerciale / Usine

## Checklist review

Pas de code → review légère, lecture des 2 `.md` suffit.

- [ ] Frontmatter YAML cohérent (module, version, dates, validation client, taskgroup, tags)
- [ ] Décisions d'archi argumentées (`spec-back.md § 2`)
- [ ] Modèle data réaliste (SQL Postgres rédigé directement, pas de pseudo-code)
- [ ] API REST complète (codes HTTP succès + erreurs, payloads, exemples)
- [ ] RG numérotées et testables (un humain peut écrire un test PHPUnit à partir de chaque RG sans réinventer)
- [ ] Mapping rôles MALIO ↔ permissions RBAC clair (cohérent avec règle ABSOLUE n°8 — 3 sources à toucher)
- [ ] Hors-périmètre explicite

## Stratégie de merge

**Squash merge** vers `develop` (1 PR = 1 commit propre dans l'historique).

## Lien Lesstime

- TaskGroup : `#22 — M0 — Gestion des catégories` (projet ERP / Starseed)
- Tickets en backlog : `#43` → `#51`

> ⚠️ **À faire manuellement après merge** dans l'UI Lesstime : rattacher les 9 tickets au groupe #22 et leur poser leur effort + tag + priorité (bug typing MCP empêche le wire automatique sur des champs `integer` sans `type` explicite dans le schéma).

---------

Co-authored-by: Matthieu Tholot <mtholot19@gmail.com>
Reviewed-on: #12
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-05-27 09:03:54 +00:00
gitea-actions 33599db5a3 chore: bump version to v0.1.39
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 22s
2026-05-19 13:56:08 +00:00
matthieu 34e75a35fb ci : add pull_request quality gate workflow (#11)
Auto Tag Develop / tag (push) Has been cancelled
2026-05-19 13:55:59 +00:00
115 changed files with 12747 additions and 420 deletions
+71
View File
@@ -40,6 +40,27 @@ Format obligatoire : `module.resource[.subresource].action` en snake_case.
- Audit ManyToMany : trace automatiquement `{fieldName: {added: [ids], removed: [ids]}}` — aucune action supplementaire - Audit ManyToMany : trace automatiquement `{fieldName: {added: [ids], removed: [ids]}}` — aucune action supplementaire
- Spec complete : @doc/audit-log.md - Spec complete : @doc/audit-log.md
## Timestampable + Blamable (obligatoire pour entites metier)
Toute **nouvelle** entite metier sous `src/Module/*/Domain/Entity/` doit porter les 4 colonnes `created_at` / `updated_at` / `created_by` / `updated_by`, remplies automatiquement. Trois lignes a ajouter a l'entite :
```php
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
class MyEntity implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait; // porte les 4 props + getters/setters
// ... reste metier
}
```
- Le `TimestampableBlamableSubscriber` (`Shared/Infrastructure/Doctrine/`) remplit les colonnes au `prePersist` / `preUpdate`. Hors contexte HTTP (CLI, cron, migration), le blame reste `null` (libelle « Systeme » cote front).
- La migration de l'entite doit creer les 4 colonnes (`created_at` / `updated_at` NOT NULL, `created_by` / `updated_by` nullable `ON DELETE SET NULL`).
- **Garde-fou CI** : `tests/Architecture/EntitiesAreTimestampableBlamableTest` echoue si une entite oublie le pattern. Un referentiel statique justifie (ex: `CategoryType`) doit etre explicitement whiteliste dans la constante `EXCLUDED` avec un commentaire.
- Spec complete : @docs/specs/M0-categories/spec-back.md § 2.8 + § 2.8.bis
## Serialization ## Serialization
Pour embarquer une relation dans le JSON (au lieu d'un IRI Hydra), ajouter le groupe du parent sur les proprietes de l'entite cible. Pour embarquer une relation dans le JSON (au lieu d'un IRI Hydra), ajouter le groupe du parent sur les proprietes de l'entite cible.
@@ -53,3 +74,53 @@ Exemple : pour qu'`User.profile` soit embarque au lieu d'un lien IRI sous le gro
## PostgreSQL ## PostgreSQL
- Noms de colonnes toujours en **minuscules** dans le SQL brut (commun a tous les projets MALIO) - 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.
+131
View File
@@ -0,0 +1,131 @@
name: Pull Request — Quality gate
# Lance les tests + lint + build sur chaque PR ciblant develop.
# Deux jobs en parallele (backend / frontend) pour reduire le temps de feedback.
# E2E volontairement hors scope (cf. regle d'or testing.md).
on:
pull_request:
branches:
- develop
# Annule les runs obsoletes quand on repush sur la meme PR.
concurrency:
group: pr-${{ gitea.event.pull_request.number }}
cancel-in-progress: true
jobs:
backend:
name: Backend (PHP CS + PHPUnit)
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
# Doivent matcher la DATABASE_URL ci-dessous. Le suffixe `_test`
# est applique automatiquement par Doctrine en APP_ENV=test.
POSTGRES_USER: app
POSTGRES_PASSWORD: '!ChangeMe!'
POSTGRES_DB: app
# Pas de `ports:` host mapping — le runner partage l'hote avec la
# prod (Postgres deja sur 5432) et les jobs Gitea Actions tournent
# en container sur un reseau Docker dedie : le service est joignable
# via son nom (`postgres`), pas via 127.0.0.1.
options: >-
--health-cmd "pg_isready -U app"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
APP_ENV: test
APP_SECRET: ci-secret-not-used
APP_DEBUG: 0
DEFAULT_URI: http://localhost/
DATABASE_URL: postgresql://app:!ChangeMe!@postgres:5432/app?serverVersion=16&charset=utf8
JWT_SECRET_KEY: '%kernel.project_dir%/config/jwt/private.pem'
JWT_PUBLIC_KEY: '%kernel.project_dir%/config/jwt/public.pem'
JWT_PASSPHRASE: change_me_in_env_local
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP 8.4
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: pdo, pdo_pgsql, intl, opcache, zip, mbstring, sodium
coverage: none
tools: composer:v2
# Cache Composer retire : meme cause que cote front — le backend de cache
# du runner Gitea est injoignable (ETIMEDOUT) et fait timeouter le step
# ~4 min 30. A re-activer si le serveur de cache du runner est repare.
- name: Install PHP dependencies
run: composer install --no-interaction --no-progress --prefer-dist
- name: Generate JWT keypair
run: php bin/console lexik:jwt:generate-keypair --skip-if-exists --no-interaction
- name: PHP CS Fixer (dry-run)
run: vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --allow-risky=yes --dry-run --diff
- name: Bootstrap test database
# Aligne sur la cible `test-db-setup` du makefile : apres
# `schema:update --force`, on RECREE manuellement l'index unique
# partiel `uq_category_name_type_active` car Doctrine ORM ne sait
# pas exprimer les index fonctionnels partiels (LOWER(name) + WHERE
# deleted_at IS NULL) et `schema:update` les considere comme
# orphelins et les DROP — collisions non detectees, tests d'unicite
# qui attendent 409 recoivent 201.
run: |
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"
- name: Run PHPUnit
run: php -d memory_limit=512M vendor/bin/phpunit
frontend:
name: Frontend (lint + Vitest + build)
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- name: Checkout
uses: actions/checkout@v4
# Pas de `cache: npm` : le backend de cache du runner Gitea est injoignable
# (ETIMEDOUT) et chaque tentative de restauration attend ~4 min 30 avant de
# timeout — c'est ce qui plombait le job. Node 22 est deja dans le
# tool-cache du runner (install instantane), et `npm ci` a froid ne prend
# que ~30s. A re-activer si le serveur de cache du runner est repare.
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install Node dependencies
run: npm ci
- name: ESLint
run: npm run lint
- name: Unit tests (Vitest)
run: npm run test
# `nuxt build` (et non `build:dist`/`nuxt generate`) : l'app est en SSR off
# (SPA), le prerender de generate n'apporte rien a une quality gate — on
# veut seulement valider que le bundle compile.
- name: Build production (nuxt build)
run: npm run build
+1
View File
@@ -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. 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. 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. 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 ## Conventions
@.claude/rules/architecture.md @.claude/rules/architecture.md
+2
View File
@@ -1,6 +1,7 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
use App\Module\Catalog\CatalogModule;
use App\Module\Commercial\CommercialModule; use App\Module\Commercial\CommercialModule;
use App\Module\Core\CoreModule; use App\Module\Core\CoreModule;
use App\Module\Sites\SitesModule; use App\Module\Sites\SitesModule;
@@ -9,4 +10,5 @@ return [
CoreModule::class, CoreModule::class,
CommercialModule::class, CommercialModule::class,
SitesModule::class, SitesModule::class,
CatalogModule::class,
]; ];
+30
View File
@@ -33,6 +33,14 @@ doctrine:
# `App\Module\Sites\Domain\Entity\Site` dans User.php. # `App\Module\Sites\Domain\Entity\Site` dans User.php.
resolve_target_entities: resolve_target_entities:
App\Shared\Domain\Contract\SiteInterface: App\Module\Sites\Domain\Entity\Site App\Shared\Domain\Contract\SiteInterface: App\Module\Sites\Domain\Entity\Site
# Cible des ManyToOne created_by / updated_by du TimestampableBlamableTrait.
# Permet a Shared de referencer UserInterface dans ses ORM mappings sans
# importer la classe concrete du module Core (cf. spec-back M0 § 2.8).
Symfony\Component\Security\Core\User\UserInterface: App\Module\Core\Domain\Entity\User
# Cible des ManyToMany Client.categories / ClientAddress.categories (M1).
# Permet au module Commercial de referencer une Category via le contrat
# Shared sans importer la classe concrete du module Catalog (regle n°1).
App\Shared\Domain\Contract\CategoryInterface: App\Module\Catalog\Domain\Entity\Category
mappings: mappings:
Core: Core:
type: attribute type: attribute
@@ -50,6 +58,28 @@ doctrine:
dir: '%kernel.project_dir%/src/Module/Sites/Domain/Entity' dir: '%kernel.project_dir%/src/Module/Sites/Domain/Entity'
prefix: 'App\Module\Sites\Domain\Entity' prefix: 'App\Module\Sites\Domain\Entity'
alias: Sites alias: Sites
# Mapping inconditionnel du module Catalog (meme logique que Sites) :
# la structure DB (category, category_type) existe meme si
# CatalogModule::class n'est pas encore wire dans config/modules.php
# (declaration du module = ticket 0.5 / ERP-47). L'ORM doit connaitre
# les entites pour que le schema soit en phase ; l'activation
# fonctionnelle passe exclusivement par config/modules.php.
Catalog:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/Catalog/Domain/Entity'
prefix: 'App\Module\Catalog\Domain\Entity'
alias: Catalog
# Mapping inconditionnel du module Commercial (meme logique que Catalog) :
# les tables (client, sous-collections, referentiels comptables) creees
# par la migration M1 (Version20260601000000) doivent etre connues de
# l'ORM. L'activation fonctionnelle passe par config/modules.php.
Commercial:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/Commercial/Domain/Entity'
prefix: 'App\Module\Commercial\Domain\Entity'
alias: Commercial
controller_resolver: controller_resolver:
auto_mapping: false auto_mapping: false
+7
View File
@@ -83,6 +83,13 @@ return [
'module' => 'sites', 'module' => 'sites',
'permission' => 'sites.view', 'permission' => 'sites.view',
], ],
[
'label' => 'sidebar.catalog.categories',
'to' => '/admin/categories',
'icon' => 'mdi:tag-multiple-outline',
'module' => 'catalog',
'permission' => 'catalog.categories.view',
],
[ [
'label' => 'sidebar.core.audit_log', 'label' => 'sidebar.core.audit_log',
'to' => '/admin/audit-log', 'to' => '/admin/audit-log',
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.38' app.version: '0.1.54'
File diff suppressed because it is too large Load Diff
+113
View File
@@ -0,0 +1,113 @@
---
# === IDENTITÉ ===
module: M0
nom: "Gestion des catégories"
ecran: gestion-categories
owner_spec: Matthieu
backup_spec: Tristan
version: V0
date_redaction: 2026-05-22
# === LIENS ===
maquette_figma: null # pas de Figma — UI admin standard
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]
roles: [Admin, Bureau, Compta, Commerciale, Usine]
lien_spec_back: ./spec-back.md
# === VALIDATION CLIENT #1 ===
client_validation_1:
statut: validee # V0 client validée le 22/05/2026
date: 2026-05-22
canal: ecrit
valide_par: "Matthieu (CP MALIO) — validation implicite, périmètre projet"
resume: "Module 0 — Gestion des catégories. Page admin (datatable + drawer). 2 champs (Nom + Type), 3 actions (Ajouter / Consulter / Modifier). Admin only."
trace_archivee: "uploads/c4ebb6b4-M0categories.docx (V0 d'origine .docx) — restitué ci-dessous en Markdown."
# === LIEN LESSTIME ===
lesstime_taskgroup_id: 22
lesstime_project_id: 6 # ERP / Starseed
statut_global: en_dev # tickets créés en backlog Lesstime le 2026-05-26
---
# Module 0 — Gestion des catégories (V0 front)
> **Origine** : spec front V0 livrée le 22/05/2026 (`c4ebb6b4-M0categories.docx` + `f665acfb-M0categoriesV0.pdf`). Restitution Markdown fidèle pour intégration au workflow MALIO. Le contenu original n'est pas modifié — toute reformulation et précision (en particulier côté back) vit dans [`spec-back.md`](./spec-back.md).
## But
Permettre à un administrateur Starseed de gérer un référentiel de **catégories** depuis l'interface admin du logiciel. Ces catégories seront utilisées plus tard pour classifier les tiers (clients, fournisseurs, prestataires).
## Accès
- **Depuis** : menu principal → **Administration** → entrée « Gestion des catégories »
- **Rôles autorisés** : **Admin uniquement** (Bureau / Compta / Commerciale / Usine n'ont **aucun** accès, ni lecture ni écriture).
## Navigation
L'écran est la page d'entrée du Module **Administration**. Titre de la page : « **Gestion des catégories** ».
- Affichage principal : un **datatable** listant toutes les catégories existantes.
- **Clic sur une ligne** → ouverture d'un **drawer** latéral en mode **consultation / modification** (cf. § Action « Consulter »).
- **Bouton « + Ajouter »** (en haut à droite du datatable) → ouverture d'un **drawer** en mode **création** (cf. § Action « Ajouter »).
- Pas d'onglet, pas de pagination explicite (volumétrie cible faible).
## Actions
| Action | Déclencheur | Comportement |
|---|---|---|
| **Ajouter** | Clic sur le bouton « + Ajouter » | Ouvre le drawer en mode création, formulaire vide. Validation → POST → la catégorie apparaît dans le datatable. |
| **Consulter** | Clic sur une ligne du datatable | Ouvre le drawer avec les champs pré-remplis en lecture (et passage en édition si l'utilisateur modifie un champ). |
| **Modifier** | Modification d'un champ dans le drawer ouvert en consultation | Validation → PATCH → la ligne du datatable se met à jour. |
> **Note V0** : la **suppression** n'était pas mentionnée dans la V0 client. Côté workflow MALIO, suite à la revue back (cf. `spec-back.md` § Q3), un soft delete est ajouté (corbeille logique). L'UI peut intégrer ce point lors d'une V1 — au M0 le bouton « Supprimer » n'est pas obligatoire, mais doit être facilement ajoutable.
## Formulaire — Champs
Le formulaire (drawer) contient **2 champs**, tous deux obligatoires :
| Champ | Type | Obligatoire | Contenu / valeur par défaut | Règle |
|---|---|---|---|---|
| **Nom** | Texte libre | **Oui** | vide à la création | Pas de règle métier détaillée en V0. Détails côté back : RG-1.02 / RG-1.03 / RG-1.04 (obligatoire, trim, longueur 2120). |
| **Type de catégorie** | Select | **Oui** | vide à la création | Le contenu du Select n'était pas précisé en V0. Décision back : entité de référence `CategoryType` séparée (RG-1.05 / RG-1.06). Le référentiel sera alimenté plus tard (cf. HP-1 dans `spec-back.md`). |
> **Note V0** : la V0 ne précisait ni si le `Type de catégorie` est un enum hardcodé ni si c'est une autre entité. Décision tranchée côté back avant découpe en tickets : **entité de référence** (`category_types`), table créée vide au M0.
## Permissions par rôle
| Rôle | Vue (`GET`) | Création (`POST`) | Édition (`PATCH`) | Suppression (`DELETE`) |
|---|---|---|---|---|
| **Admin** | ✅ | ✅ | ✅ | ✅ (soft delete — ajout post-V0) |
| Bureau | ❌ | ❌ | ❌ | ❌ |
| Compta | ❌ | ❌ | ❌ | ❌ |
| Commerciale | ❌ | ❌ | ❌ | ❌ |
| Usine | ❌ | ❌ | ❌ | ❌ |
→ Les rôles non-Admin ne voient **pas** l'entrée de menu et reçoivent **403** sur toute requête vers les endpoints `/api/categories/*` (cf. RG-1.01 dans `spec-back.md`).
## Composants UI à utiliser (Starseed / `@malio/layer-ui`)
- **Datatable** : `<MalioDataTable>` (avec colonnes `Nom` + `Type` + actions, tri par défaut sur Nom).
- **Drawer** : drawer latéral standard `@malio/layer-ui` (à confirmer côté front avec le composant exact).
- **Input texte** : `<MalioInputText>` pour le champ Nom.
- **Select** : `<MalioSelect>` pour le champ Type de catégorie, alimenté par `GET /api/category_types`.
- **Bouton** : `<MalioButton>` (« + Ajouter », « Enregistrer », « Annuler »).
- **Toasts succès / erreur** : standards via `useApi()`.
## Points laissés ouverts par la V0 (résolus côté back)
| # | Zone d'ombre V0 | Résolution (cf. `spec-back.md`) |
|---|---|---|
| 1 | Suppression non mentionnée | **Soft delete** ajouté (RG-1.12 + RG-1.13). UI peut ajouter le bouton plus tard. |
| 2 | Unicité du nom non précisée | **Unicité sur `(name, type)` case-insensitive**, parmi non-soft-deleted (RG-1.07). |
| 3 | Nature du `Type de catégorie` (enum vs entité) | **Entité de référence** `CategoryType` (table vide au M0, créée par migration). |
| 4 | Volumétrie & pagination | **300 max** → pagination front (`<MalioDataTable>`), pas de pagination serveur. Tri serveur `name ASC` par défaut. |
| 5 | Audit / traçabilité | Pattern `#[Auditable]` Starseed standard. Trace dans la table `audit_log` (qui / quoi / quand / diff). **Pas** de colonnes `created_by` / `updated_by` sur l'entité (cohérent avec User / Role dans Starseed). Historique consultable via `/api/audit-log?entityType=Category&entityId={id}`. |
| 6 | Référencement par d'autres entités | **Aucune FK entrante au M0.** Les modules Tiers (M-Clients / M-Fournisseurs / M-Prestas) ajouteront leur propre `category_id` plus tard. |
---
## 📦 Tickets Lesstime générés
**TaskGroup Lesstime** : `#22 — M0 — Gestion des catégories` (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).
File diff suppressed because it is too large Load Diff
+289
View File
@@ -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).
+10 -2
View File
@@ -1,9 +1,17 @@
<!--
Valeurs en dur issues de la maquette Figma (design Starseed) :
- sidebar depliee : 232px (w-[232px], repli laisse par defaut 72px)
- marge horizontale du contenu sur desktop : 170px (xl:px-[170px])
- bande blanche sticky sous la navbar : 47px (h-[47px])
A faire evoluer uniquement avec une mise a jour de maquette.
-->
<template> <template>
<div class="h-screen overflow-hidden"> <div class="h-screen overflow-hidden">
<div class="flex h-full"> <div class="flex h-full">
<MalioSidebar <MalioSidebar
v-model="ui.sidebarCollapsed" v-model="ui.sidebarCollapsed"
:sections="translatedSections" :sections="translatedSections"
:sidebar-class="ui.sidebarCollapsed ? '' : 'w-[232px]'"
> >
<template #logo> <template #logo>
<img src="/LOGO_MALIO.png" alt="Malio"/> <img src="/LOGO_MALIO.png" alt="Malio"/>
@@ -16,10 +24,10 @@
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0"> <div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
<SiteSelector v-if="showSiteSelector"/> <SiteSelector v-if="showSiteSelector"/>
<main <main
class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-24 sm:px-8 lg:px-16"> class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-10 sm:px-6 lg:px-12 xl:px-[170px]">
<div <div
aria-hidden="true" aria-hidden="true"
class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12"/> class="pointer-events-none sticky top-0 z-30 h-[47px] flex-shrink-0 bg-white"/>
<slot/> <slot/>
</main> </main>
</div> </div>
+43
View File
@@ -32,6 +32,9 @@
}, },
"sites": { "sites": {
"admin": "Sites" "admin": "Sites"
},
"catalog": {
"categories": "Gestion des catégories"
} }
}, },
"dashboard": { "dashboard": {
@@ -85,12 +88,19 @@
}, },
"empty": "Aucune activité enregistrée", "empty": "Aucune activité enregistrée",
"no_results": "Aucun résultat pour ces filtres", "no_results": "Aucun résultat pour ces filtres",
"error": {
"title": "Erreur",
"message": "Impossible de charger le journal d'audit. Vérifiez les filtres ou réessayez."
},
"timeline": { "timeline": {
"empty": "Aucun historique", "empty": "Aucun historique",
"load_more": "Voir plus" "load_more": "Voir plus"
}, },
"filters": { "filters": {
"title": "Filtres",
"apply": "Voir les résultats",
"reset": "Réinitialiser", "reset": "Réinitialiser",
"date_range": "Date à date",
"date_from": "Du", "date_from": "Du",
"date_to": "Au", "date_to": "Au",
"entity_type": "Type d'entité", "entity_type": "Type d'entité",
@@ -220,6 +230,39 @@
"updated": "Site mis à jour avec succès", "updated": "Site mis à jour avec succès",
"deleted": "Site supprimé avec succès" "deleted": "Site supprimé avec succès"
} }
},
"categories": {
"title": "Gestion des catégories",
"newCategory": "Ajouter",
"editCategory": "Modifier la catégorie",
"createCategory": "Créer une catégorie",
"viewCategory": "Détail de la catégorie",
"noCategories": "Aucune catégorie pour l'instant.",
"table": {
"name": "Nom",
"type": "Type"
},
"form": {
"name": "Nom",
"type": "Type de catégorie",
"typePlaceholder": "Sélectionner un type"
},
"validation": {
"nameRequired": "Le nom est obligatoire.",
"nameLength": "Le nom doit faire entre 2 et 120 caractères.",
"typeRequired": "Le type de catégorie est obligatoire."
},
"delete": {
"title": "Supprimer la catégorie",
"message": "Êtes-vous sûr de vouloir supprimer la catégorie \"{name}\" ? Cette action est irréversible."
},
"toast": {
"created": "Catégorie créée avec succès",
"updated": "Catégorie mise à jour avec succès",
"deleted": "Catégorie supprimée avec succès",
"duplicate": "Une catégorie nommée « {name} » existe déjà pour ce type.",
"typesLoadFailed": "Impossible de charger les types de catégorie. Réessayez."
}
} }
} }
} }
@@ -0,0 +1,48 @@
<template>
<MalioModal
:model-value="modelValue"
modal-class="max-w-md"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<h3 class="text-lg font-semibold text-neutral-900">
{{ t('admin.categories.delete.title') }}
</h3>
</template>
<p class="text-sm text-neutral-600">
{{ t('admin.categories.delete.message', { name: categoryName }) }}
</p>
<template #footer>
<MalioButton
:label="t('common.cancel')"
variant="secondary"
@click="emit('update:modelValue', false)"
/>
<MalioButton
:label="t('common.delete')"
variant="danger"
icon-name="mdi:delete-outline"
icon-position="left"
:disabled="loading"
@click="emit('confirm')"
/>
</template>
</MalioModal>
</template>
<script setup lang="ts">
const { t } = useI18n()
defineProps<{
modelValue: boolean
categoryName: string
loading: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
confirm: []
}>()
</script>
@@ -0,0 +1,178 @@
<template>
<MalioDrawer
:model-value="modelValue"
drawer-class="w-full max-w-lg"
header-class="border-b border-black"
footer-class="justify-between border-t border-black p-6"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<h2 class="text-2xl font-bold">
{{ headerLabel }}
</h2>
</template>
<form class="flex flex-col gap-4 py-4" @submit.prevent="handleSave">
<!-- Nom (RG-1.02 obligatoire / RG-1.04 longueur 2-120 apres trim).
Erreur miroir client + erreurs server-side (422) mappees sur ce champ. -->
<MalioInputText
v-model="form.name.value"
:label="t('admin.categories.form.name')"
input-class="w-full"
:max-length="120"
:error="form.errors.value.name"
required
/>
<!-- Type (RG-1.05 obligatoire). MalioSelect porte la valeur en
number (categoryType id) ; conversion en IRI au moment du save
par le composable useCategoryForm. -->
<MalioSelect
v-model="form.categoryTypeId.value"
:options="typeOptions"
:label="t('admin.categories.form.type')"
:empty-option-label="t('admin.categories.form.typePlaceholder')"
:error="form.errors.value.categoryType"
:disabled="loadingTypes"
/>
<!-- Erreur transverse (typiquement reseau / 5xx) separe des
erreurs de validation par champ. -->
<p v-if="form.errors.value._global" class="text-sm text-red-600">
{{ form.errors.value._global }}
</p>
</form>
<!-- Footer fixe : depuis 1.7.1 le slot #footer est un frere du body
scrollable (shrink-0), donc reellement fige sans sticky. -->
<template #footer>
<MalioButton
v-if="canShowDelete"
:label="t('common.delete')"
variant="danger"
icon-name="mdi:delete-outline"
icon-position="left"
button-class="w-[150px]"
@click="emit('delete')"
/>
<MalioButton
v-else
:label="t('common.cancel')"
variant="tertiary"
button-class="w-[150px]"
@click="emit('update:modelValue', false)"
/>
<MalioButton
v-if="canShowSave"
:label="t('common.save')"
variant="primary"
button-class="w-[150px]"
:disabled="form.submitting.value || loadingTypes"
@click="handleSave"
/>
</template>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { Category } from '~/modules/catalog/types/category'
const { t } = useI18n()
const { can } = usePermissions()
const { types, loadingTypes, fetchTypes } = useCategoriesAdmin()
// Instance dediee de form pour ce drawer state isole (cf. useCategoryForm
// n'est pas singleton, contrairement a useCategoriesAdmin).
const form = useCategoryForm()
const props = defineProps<{
modelValue: boolean
category: Category | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
saved: []
delete: []
}>()
/**
* Mode du drawer (dérivé du composable `useCategoryForm`) :
* - 'create' : pas de category prop, formulaire vide, POST au save.
* - 'view' : category prop set, formulaire pre-rempli, save MASQUE
* jusqu'a ce que l'utilisateur modifie un champ.
* - 'edit' : category prop set et formulaire « dirty » (au moins un
* champ different de l'original), PATCH au save.
*/
type DrawerMode = 'create' | 'view' | 'edit'
const isCreateMode = computed(() => props.category === null)
const mode = computed<DrawerMode>(() => {
if (isCreateMode.value) return 'create'
return form.isDirty.value ? 'edit' : 'view'
})
const headerLabel = computed(() => {
if (mode.value === 'create') return t('admin.categories.createCategory')
if (mode.value === 'edit') return t('admin.categories.editCategory')
return t('admin.categories.viewCategory')
})
// Le bouton Supprimer n'est visible qu'en consultation/edition d'une categorie
// existante et seulement pour les users ayant la permission manage. En mode
// creation on affiche un bouton Annuler a la place.
const canShowDelete = computed(
() => !isCreateMode.value && can('catalog.categories.manage'),
)
// Save : visible en creation, ou en edition (apres modification d'un champ).
// Masque en view tant que rien n'a change.
const canShowSave = computed(
() => mode.value === 'create' || mode.value === 'edit',
)
const typeOptions = computed(() =>
types.value.map(ct => ({
label: ct.label,
value: ct.id,
})),
)
// Re-initialise le form quand la categorie selectionnee change (clic sur une
// autre ligne sans fermer le drawer entre-temps).
watch(() => props.category, (cat) => {
form.loadFrom(cat)
}, { immediate: true })
// A chaque ouverture du drawer : reload du form + refresh des types (au cas
// ou un type aurait ete ajoute en arriere-plan depuis le dernier fetch pas
// d'optimisation cache au M0, le referentiel est petit).
watch(
() => props.modelValue,
(open) => {
if (open) {
form.loadFrom(props.category)
fetchTypes()
}
},
)
/**
* Sauvegarde : delegue au composable (POST en mode create, PATCH en mode
* edit). Le toast succes + mapping erreur 409/422 est gere par le composable.
* En cas de succes, on ferme le drawer et on previent le parent pour qu'il
* refresh la liste.
*/
async function handleSave(): Promise<void> {
let result: Category | null = null
if (mode.value === 'create') {
result = await form.submitCreate()
} else if (mode.value === 'edit' && props.category) {
result = await form.submitUpdate(props.category.id)
}
if (result) {
emit('saved')
emit('update:modelValue', false)
}
}
</script>
@@ -0,0 +1,250 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { Category, CategoryType } from '~/modules/catalog/types/category'
import type { HydraCollection } from '~/shared/utils/api'
// Mock du store auth : useCategoriesAdmin s'auto-enregistre via
// `onAuthSessionCleared(...)` au chargement du module. On stubbe pour
// eviter de charger Pinia et la vraie store (pas necessaire ici).
vi.mock('~/shared/stores/auth', () => ({
onAuthSessionCleared: vi.fn(),
}))
// Le client API est un auto-import Nuxt. On le remplace par un stub
// global pour intercepter les appels et controler les reponses dans
// chaque test (cf. pattern utilise dans useCurrentSite.spec.ts).
const mockGet = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({
get: mockGet,
post: vi.fn(),
put: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
}))
// Import APRES vi.mock / vi.stubGlobal : le module n'est evalue qu'a
// ce moment-la, donc le mock auth est bien actif au top-level.
const { useCategoriesAdmin } = await import('../useCategoriesAdmin')
const TYPE_VENTE: CategoryType = { id: 1, code: 'VENTE', label: 'Vente' }
const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' }
const CAT_A: Category = {
id: 10,
name: 'Vis',
categoryType: TYPE_VENTE,
deletedAt: null,
createdAt: '2026-01-01T10:00:00+00:00',
updatedAt: '2026-01-01T10:00:00+00:00',
createdBy: null,
updatedBy: null,
}
const CAT_B: Category = {
id: 11,
name: 'Boulons',
categoryType: TYPE_VENTE,
deletedAt: null,
createdAt: '2026-01-02T10:00:00+00:00',
updatedAt: '2026-01-02T10:00:00+00:00',
createdBy: null,
updatedBy: null,
}
function makeHydra<T>(items: T[]): HydraCollection<T> {
return {
totalItems: items.length,
member: items,
}
}
describe('useCategoriesAdmin', () => {
beforeEach(() => {
mockGet.mockReset()
// Reset systematique du state singleton entre tests : sans ca,
// les categories chargees dans un test fuiteraient dans le suivant.
const { resetCategoriesAdmin } = useCategoriesAdmin()
resetCategoriesAdmin()
})
describe('fetchAll', () => {
it('appelle GET /categories avec itemsPerPage=999 par defaut', async () => {
mockGet.mockResolvedValueOnce(makeHydra<Category>([]))
const { fetchAll } = useCategoriesAdmin()
await fetchAll()
expect(mockGet).toHaveBeenCalledTimes(1)
expect(mockGet).toHaveBeenCalledWith(
'/categories',
{ itemsPerPage: 999 },
{ toast: false },
)
})
it('peuple categories.value depuis le champ Hydra member', async () => {
mockGet.mockResolvedValueOnce(makeHydra([CAT_A, CAT_B]))
const { fetchAll, categories } = useCategoriesAdmin()
await fetchAll()
expect(categories.value).toEqual([CAT_A, CAT_B])
})
it('exclut les soft-deleted par defaut (pas de query includeDeleted)', async () => {
mockGet.mockResolvedValueOnce(makeHydra<Category>([]))
const { fetchAll } = useCategoriesAdmin()
await fetchAll()
const queryArg = mockGet.mock.calls[0]?.[1] as Record<string, unknown>
expect(queryArg).not.toHaveProperty('includeDeleted')
})
it('ajoute includeDeleted=true quand demande explicitement', async () => {
mockGet.mockResolvedValueOnce(makeHydra<Category>([]))
const { fetchAll } = useCategoriesAdmin()
await fetchAll(true)
expect(mockGet).toHaveBeenCalledWith(
'/categories',
{ itemsPerPage: 999, includeDeleted: 'true' },
{ toast: false },
)
})
it('passe loading a true pendant la requete et false apres', async () => {
let resolveRequest: (v: HydraCollection<Category>) => void = () => {}
mockGet.mockImplementationOnce(
() => new Promise((resolve) => { resolveRequest = resolve }),
)
const { fetchAll, loading } = useCategoriesAdmin()
const pending = fetchAll()
expect(loading.value).toBe(true)
resolveRequest(makeHydra<Category>([]))
await pending
expect(loading.value).toBe(false)
})
it('peuple error.value et vide categories en cas d echec', async () => {
mockGet.mockRejectedValueOnce(new Error('Network down'))
const { fetchAll, categories, error, loading } = useCategoriesAdmin()
// Pre-charge volontairement quelque chose pour verifier la purge.
categories.value = [CAT_A]
await fetchAll()
expect(categories.value).toEqual([])
expect(error.value).toBe('Network down')
expect(loading.value).toBe(false)
})
it('gere une reponse sans champ member (fallback tableau vide)', async () => {
mockGet.mockResolvedValueOnce({
totalItems: 0,
} as unknown as HydraCollection<Category>)
const { fetchAll, categories } = useCategoriesAdmin()
await fetchAll()
expect(categories.value).toEqual([])
})
})
describe('fetchTypes', () => {
it('appelle GET /category_types avec itemsPerPage=999', async () => {
mockGet.mockResolvedValueOnce(makeHydra<CategoryType>([]))
const { fetchTypes } = useCategoriesAdmin()
await fetchTypes()
expect(mockGet).toHaveBeenCalledWith(
'/category_types',
{ itemsPerPage: 999 },
{ toast: false },
)
})
it('peuple types.value depuis le champ Hydra member', async () => {
mockGet.mockResolvedValueOnce(makeHydra([TYPE_VENTE, TYPE_ACHAT]))
const { fetchTypes, types } = useCategoriesAdmin()
await fetchTypes()
expect(types.value).toEqual([TYPE_VENTE, TYPE_ACHAT])
})
it('peuple error.value et vide types en cas d echec', async () => {
mockGet.mockRejectedValueOnce(new Error('500'))
const { fetchTypes, types, error, loadingTypes } = useCategoriesAdmin()
types.value = [TYPE_VENTE]
await fetchTypes()
expect(types.value).toEqual([])
expect(error.value).toContain('500')
expect(loadingTypes.value).toBe(false)
})
it('passe loadingTypes a true pendant la requete et false apres', async () => {
let resolveRequest: (v: HydraCollection<CategoryType>) => void = () => {}
mockGet.mockImplementationOnce(
() => new Promise((resolve) => { resolveRequest = resolve }),
)
const { fetchTypes, loadingTypes } = useCategoriesAdmin()
const pending = fetchTypes()
expect(loadingTypes.value).toBe(true)
resolveRequest(makeHydra<CategoryType>([]))
await pending
expect(loadingTypes.value).toBe(false)
})
})
describe('resetCategoriesAdmin', () => {
it('vide categories, types, loading, loadingTypes et error', () => {
const { resetCategoriesAdmin, categories, types, loading, loadingTypes, error }
= useCategoriesAdmin()
// Pre-peuple le state pour verifier la purge effective.
categories.value = [CAT_A]
types.value = [TYPE_VENTE]
loading.value = true
loadingTypes.value = true
error.value = 'oops'
resetCategoriesAdmin()
expect(categories.value).toEqual([])
expect(types.value).toEqual([])
expect(loading.value).toBe(false)
expect(loadingTypes.value).toBe(false)
expect(error.value).toBeNull()
})
})
describe('singleton', () => {
it('deux appels a useCategoriesAdmin() partagent la meme ref categories', () => {
const a = useCategoriesAdmin()
const b = useCategoriesAdmin()
// Les fonctions sont reinstanciees a chaque appel mais les refs
// doivent etre rigoureusement les memes (state au niveau module).
expect(a.categories).toBe(b.categories)
expect(a.types).toBe(b.types)
expect(a.loading).toBe(b.loading)
})
it('une mutation via une instance est visible depuis une autre instance', () => {
const a = useCategoriesAdmin()
const b = useCategoriesAdmin()
a.categories.value = [CAT_A]
expect(b.categories.value).toEqual([CAT_A])
})
})
})
@@ -0,0 +1,454 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { Category, CategoryType } from '~/modules/catalog/types/category'
import { useCategoryForm } from '../useCategoryForm'
// Stubs des auto-imports Nuxt consommes par le composable.
const mockGet = vi.hoisted(() => vi.fn())
const mockPost = vi.hoisted(() => vi.fn())
const mockPatch = vi.hoisted(() => vi.fn())
const mockDelete = vi.hoisted(() => vi.fn())
const mockToastSuccess = vi.hoisted(() => vi.fn())
const mockToastError = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({
get: mockGet,
post: mockPost,
put: vi.fn(),
patch: mockPatch,
delete: mockDelete,
}))
vi.stubGlobal('useToast', () => ({
success: mockToastSuccess,
error: mockToastError,
}))
// useI18n.t : on renvoie la cle telle quelle (pratique pour asserter dessus).
// Quand le composable passe des params (ex: doublon), on les serialise pour
// pouvoir verifier que l'interpolation a bien recu le bon nom.
vi.stubGlobal('useI18n', () => ({
t: (key: string, params?: Record<string, unknown>) =>
params ? `${key}::${JSON.stringify(params)}` : key,
}))
const TYPE_VENTE: CategoryType = { id: 1, code: 'VENTE', label: 'Vente' }
const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' }
const CAT: Category = {
id: 42,
name: 'Vis',
categoryType: TYPE_VENTE,
deletedAt: null,
createdAt: '2026-01-01T10:00:00+00:00',
updatedAt: '2026-01-01T10:00:00+00:00',
createdBy: null,
updatedBy: null,
}
describe('useCategoryForm', () => {
beforeEach(() => {
mockGet.mockReset()
mockPost.mockReset()
mockPatch.mockReset()
mockDelete.mockReset()
mockToastSuccess.mockReset()
mockToastError.mockReset()
})
describe('loadFrom', () => {
it('pre-remplit le formulaire depuis une categorie existante', () => {
const form = useCategoryForm()
form.loadFrom(CAT)
expect(form.name.value).toBe('Vis')
expect(form.categoryTypeId.value).toBe(1)
expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
})
it('vide le formulaire en mode creation (null)', () => {
const form = useCategoryForm()
form.name.value = 'old'
form.categoryTypeId.value = 99
form.loadFrom(null)
expect(form.name.value).toBe('')
expect(form.categoryTypeId.value).toBeNull()
})
it('reinitialise le snapshot initial → isDirty=false juste apres', () => {
const form = useCategoryForm()
form.loadFrom(CAT)
expect(form.isDirty.value).toBe(false)
})
})
describe('isDirty', () => {
it('passe a true des qu une valeur diverge du snapshot initial', () => {
const form = useCategoryForm()
form.loadFrom(CAT)
expect(form.isDirty.value).toBe(false)
form.name.value = 'Vis modifie'
expect(form.isDirty.value).toBe(true)
})
})
describe('validate', () => {
it('signale une erreur si name est vide (RG-1.02)', () => {
const form = useCategoryForm()
form.name.value = ''
form.categoryTypeId.value = 1
const ok = form.validate()
expect(ok).toBe(false)
expect(form.errors.value.name).toBe('admin.categories.validation.nameRequired')
})
it('signale erreur si name est whitespace-only (trim → vide)', () => {
const form = useCategoryForm()
form.name.value = ' '
form.categoryTypeId.value = 1
const ok = form.validate()
expect(ok).toBe(false)
expect(form.errors.value.name).toBe('admin.categories.validation.nameRequired')
})
it('signale erreur si name fait 1 caractere (< 2, RG-1.04)', () => {
const form = useCategoryForm()
form.name.value = 'A'
form.categoryTypeId.value = 1
const ok = form.validate()
expect(ok).toBe(false)
expect(form.errors.value.name).toBe('admin.categories.validation.nameLength')
})
it('signale erreur si name fait 121 caracteres (> 120, RG-1.04)', () => {
const form = useCategoryForm()
form.name.value = 'A'.repeat(121)
form.categoryTypeId.value = 1
const ok = form.validate()
expect(ok).toBe(false)
expect(form.errors.value.name).toBe('admin.categories.validation.nameLength')
})
it('signale erreur si categoryTypeId est null (RG-1.05)', () => {
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeId.value = null
const ok = form.validate()
expect(ok).toBe(false)
expect(form.errors.value.categoryType).toBe('admin.categories.validation.typeRequired')
})
it('passe quand name et categoryType sont valides', () => {
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeId.value = 1
const ok = form.validate()
expect(ok).toBe(true)
expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
})
it('reinitialise les erreurs avant chaque validation', () => {
const form = useCategoryForm()
// Erreur prealable.
form.errors.value._global = 'erreur ancienne'
form.name.value = 'Vis'
form.categoryTypeId.value = 1
form.validate()
expect(form.errors.value._global).toBe('')
})
})
describe('submitCreate', () => {
it('appelle POST /categories avec body { name trimme, categoryType en IRI }', async () => {
mockPost.mockResolvedValueOnce(CAT)
const form = useCategoryForm()
form.name.value = ' Vis '
form.categoryTypeId.value = 1
const result = await form.submitCreate()
expect(mockPost).toHaveBeenCalledWith(
'/categories',
{ name: 'Vis', categoryType: '/api/category_types/1' },
{ toast: false },
)
expect(result).toEqual(CAT)
})
it('ne declenche aucun appel API si la validation client echoue', async () => {
const form = useCategoryForm()
form.name.value = ''
form.categoryTypeId.value = 1
const result = await form.submitCreate()
expect(mockPost).not.toHaveBeenCalled()
expect(result).toBeNull()
})
it('declenche un toast de succes en cas de creation reussie', async () => {
mockPost.mockResolvedValueOnce(CAT)
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeId.value = 1
await form.submitCreate()
expect(mockToastSuccess).toHaveBeenCalledWith({
title: 'Succès',
message: 'admin.categories.toast.created',
})
})
it('mappe un 409 (RG-1.07) sur errors.name + toast erreur avec le nom', async () => {
mockPost.mockRejectedValueOnce({
response: { status: 409, _data: {} },
})
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeId.value = 1
const result = await form.submitCreate()
expect(result).toBeNull()
// La cle est interpolee avec le nom soumis : on retrouve "Vis" dans
// les params i18n (stub serialise les params).
expect(form.errors.value.name).toContain('admin.categories.toast.duplicate')
expect(form.errors.value.name).toContain('"name":"Vis"')
expect(mockToastError).toHaveBeenCalledTimes(1)
const toastArg = mockToastError.mock.calls[0]?.[0] as { message: string }
expect(toastArg.message).toContain('Vis')
})
it('mappe un 422 violations sur les champs concernes (errors.name)', async () => {
mockPost.mockRejectedValueOnce({
response: {
status: 422,
_data: {
violations: [
{ propertyPath: 'name', message: 'name should not be blank.' },
],
},
},
})
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeId.value = 1
const result = await form.submitCreate()
expect(result).toBeNull()
expect(form.errors.value.name).toBe('name should not be blank.')
// Pas de toast quand on a mappe les violations : l erreur est
// affichee inline sous le champ concerne.
expect(mockToastError).not.toHaveBeenCalled()
})
it('mappe aussi hydra:violations (negociation de format alternative)', async () => {
mockPost.mockRejectedValueOnce({
response: {
status: 422,
_data: {
'hydra:violations': [
{ propertyPath: 'categoryType', message: 'Type invalide.' },
],
},
},
})
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeId.value = 1
await form.submitCreate()
expect(form.errors.value.categoryType).toBe('Type invalide.')
})
it('fallback en erreur globale + toast si le status n est ni 409 ni 422', async () => {
mockPost.mockRejectedValueOnce({
response: { status: 500, _data: { 'hydra:description': 'Boom server' } },
})
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeId.value = 1
await form.submitCreate()
expect(form.errors.value._global).toBe('Boom server')
expect(mockToastError).toHaveBeenCalledWith({
title: 'Erreur',
message: 'Boom server',
})
})
it('passe submitting a true pendant la requete et a false apres', async () => {
let resolveRequest: (v: Category) => void = () => {}
mockPost.mockImplementationOnce(
() => new Promise((resolve) => { resolveRequest = resolve }),
)
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeId.value = 1
const pending = form.submitCreate()
expect(form.submitting.value).toBe(true)
resolveRequest(CAT)
await pending
expect(form.submitting.value).toBe(false)
})
})
describe('submitUpdate', () => {
it('appelle PATCH /categories/{id} uniquement avec les champs modifies', async () => {
mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' })
const form = useCategoryForm()
form.loadFrom(CAT)
form.name.value = 'Vis V2' // categoryTypeId inchange
await form.submitUpdate(42)
expect(mockPatch).toHaveBeenCalledWith(
'/categories/42',
{ name: 'Vis V2' }, // pas de categoryType car non modifie
{ toast: false },
)
})
it('envoie categoryType en IRI quand seul le type a change', async () => {
mockPatch.mockResolvedValueOnce({ ...CAT, categoryType: TYPE_ACHAT })
const form = useCategoryForm()
form.loadFrom(CAT)
form.categoryTypeId.value = 2
await form.submitUpdate(42)
expect(mockPatch).toHaveBeenCalledWith(
'/categories/42',
{ categoryType: '/api/category_types/2' },
{ toast: false },
)
})
it('court-circuite l appel API si aucun champ n a change', async () => {
const form = useCategoryForm()
form.loadFrom(CAT)
// Aucune modification — isDirty=false, patch payload vide.
const result = await form.submitUpdate(42)
expect(mockPatch).not.toHaveBeenCalled()
expect(result).toBeNull()
expect(form.submitting.value).toBe(false)
})
it('declenche un toast de succes au PATCH reussi', async () => {
mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' })
const form = useCategoryForm()
form.loadFrom(CAT)
form.name.value = 'Vis V2'
await form.submitUpdate(42)
expect(mockToastSuccess).toHaveBeenCalledWith({
title: 'Succès',
message: 'admin.categories.toast.updated',
})
})
it('mappe le 409 sur errors.name en mode update aussi', async () => {
mockPatch.mockRejectedValueOnce({
response: { status: 409, _data: {} },
})
const form = useCategoryForm()
form.loadFrom(CAT)
form.name.value = 'Doublon'
const result = await form.submitUpdate(42)
expect(result).toBeNull()
expect(form.errors.value.name).toContain('admin.categories.toast.duplicate')
expect(form.errors.value.name).toContain('"name":"Doublon"')
})
})
describe('submitDelete', () => {
it('appelle DELETE /categories/{id} et declenche un toast succes', async () => {
mockDelete.mockResolvedValueOnce(undefined)
const form = useCategoryForm()
const ok = await form.submitDelete(42)
expect(mockDelete).toHaveBeenCalledWith('/categories/42', {}, { toast: false })
expect(ok).toBe(true)
expect(mockToastSuccess).toHaveBeenCalledWith({
title: 'Succès',
message: 'admin.categories.toast.deleted',
})
})
it('retourne false et toast erreur en cas d echec', async () => {
mockDelete.mockRejectedValueOnce({
response: { status: 500, _data: { detail: 'down' } },
})
const form = useCategoryForm()
const ok = await form.submitDelete(42)
expect(ok).toBe(false)
expect(form.errors.value._global).toBe('down')
expect(mockToastError).toHaveBeenCalled()
})
})
describe('reset', () => {
it('vide le formulaire et les erreurs', () => {
const form = useCategoryForm()
form.loadFrom(CAT)
form.name.value = 'edit'
form.errors.value._global = 'erreur'
form.submitting.value = true
form.reset()
expect(form.name.value).toBe('')
expect(form.categoryTypeId.value).toBeNull()
expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
expect(form.submitting.value).toBe(false)
})
})
describe('isolation', () => {
it('deux instances useCategoryForm() ont des states independants', () => {
const a = useCategoryForm()
const b = useCategoryForm()
a.name.value = 'A'
b.name.value = 'B'
expect(a.name.value).toBe('A')
expect(b.name.value).toBe('B')
// Les refs sont distinctes (pas singleton — chaque drawer son state).
expect(a.name).not.toBe(b.name)
})
})
})
@@ -0,0 +1,134 @@
/**
* Composable d'administration des categories (M0 Gestion des categories).
*
* Centralise le chargement et le state des deux ressources lues par la page
* `/admin/categories` : la liste des categories et le referentiel
* CategoryType (utilise dans le select du drawer).
*
* State singleton au niveau module (meme convention que `useSidebar` /
* `useModules` / `useAuditLog`) : reset automatique au logout via
* `onAuthSessionCleared` (cf. CLAUDE.md regle frontend.md : « composables
* avec state singleton doivent etre reinitialises au logout »), et reset
* explicite expose via `resetCategoriesAdmin()` appele depuis
* `modules/core/pages/logout.vue`.
*/
import { ref } from 'vue'
import type { Category, CategoryType } from '~/modules/catalog/types/category'
import type { HydraCollection } from '~/shared/utils/api'
import { onAuthSessionCleared } from '~/shared/stores/auth'
/**
* Dette M0 : pas de pagination serveur sur les ressources Catalog (volumetrie
* cible 300). On force une page geante via `itemsPerPage` pour recuperer
* toute la liste en un coup. A basculer en pagination serveur quand la
* volumetrie reelle depassera ce plafond meme pattern que sites.vue.
*/
const HYDRA_NO_PAGINATION = 999
// State singleton — partage entre tous les composants qui appellent le
// composable dans la meme session. Les refs sont declarees au niveau module
// (pas dans la fonction `useCategoriesAdmin()`) pour eviter qu'une nouvelle
// instance soit creee a chaque appel.
const categories = ref<Category[]>([])
const types = ref<CategoryType[]>([])
const loading = ref(false)
const loadingTypes = ref(false)
const error = ref<string | null>(null)
function resetCategoriesAdminState(): void {
categories.value = []
types.value = []
loading.value = false
loadingTypes.value = false
error.value = null
}
// Auto-enregistrement singleton : purge le state sur 401/clearSession pour
// eviter qu'un user suivant (connecte sur le meme onglet) voie l'etat de
// l'ancien. Le logout volontaire (page logout.vue) appelle directement
// `resetCategoriesAdmin()` ci-dessous.
onAuthSessionCleared(resetCategoriesAdminState)
export function useCategoriesAdmin() {
const api = useApi()
/**
* Charge la liste des categories. Le serveur exclut les soft-deleted par
* defaut (RG-1.08) et trie par name ASC (RG-1.10). Pas de pagination
* serveur (volumetrie 300, pagination front via MalioDataTable).
*
* `includeDeleted=true` permet a un user avec `catalog.categories.manage`
* de voir les soft-deleted (RG-1.09) au M0 la page n'utilise pas cette
* option mais on l'expose pour la suite (corbeille future).
*
* Swallow volontaire : un 403 (user sans permission view) ne doit pas
* toaster la sidebar masque deja l'entree pour ces users, on tombe sur
* la page seulement par URL directe et on affiche un tableau vide propre.
*/
async function fetchAll(includeDeleted = false): Promise<void> {
loading.value = true
error.value = null
try {
const query: Record<string, unknown> = { itemsPerPage: HYDRA_NO_PAGINATION }
if (includeDeleted) {
query.includeDeleted = 'true'
}
const data = await api.get<HydraCollection<Category>>(
'/categories',
query,
{ toast: false },
)
categories.value = data.member ?? []
} catch (e) {
categories.value = []
error.value = (e as Error)?.message ?? 'Erreur de chargement'
} finally {
loading.value = false
}
}
/**
* Charge le referentiel CategoryType (lecture seule, RG-1.06). Appele a
* l'ouverture de la page admin pour que le select du drawer ait deja les
* options pretes au moment de la creation/edition.
*
* Toast desactive : on stocke l'erreur dans `error` plutot que de
* spammer un toast le drawer affichera l'erreur inline s'il y a lieu.
*/
async function fetchTypes(): Promise<void> {
loadingTypes.value = true
try {
const data = await api.get<HydraCollection<CategoryType>>(
'/category_types',
{ itemsPerPage: HYDRA_NO_PAGINATION },
{ toast: false },
)
types.value = data.member ?? []
} catch (e) {
types.value = []
error.value = (e as Error)?.message ?? 'Erreur de chargement des types'
} finally {
loadingTypes.value = false
}
}
/**
* Reset explicite appele depuis `logout.vue` apres `auth.logout()` pour
* garantir que la prochaine session reparte sur un state propre meme si
* `clearSession()` n'a pas ete declenche (cas logout volontaire).
*/
function resetCategoriesAdmin(): void {
resetCategoriesAdminState()
}
return {
categories,
types,
loading,
loadingTypes,
error,
fetchAll,
fetchTypes,
resetCategoriesAdmin,
}
}
@@ -0,0 +1,319 @@
/**
* Composable de formulaire categorie (M0 Gestion des categories).
*
* Centralise la logique de validation client + appels API (POST / PATCH /
* DELETE) du drawer de creation/edition. Contrairement a
* `useCategoriesAdmin` qui porte un state singleton partage entre composants,
* ce composable est instancie par formulaire (les refs vivent dans la
* fonction `useCategoryForm()`) chaque drawer ouvert a son propre state
* isole.
*
* Validations client en miroir des regles back (RG-1.02 / RG-1.04 / RG-1.05) :
* elles servent juste a eviter l'aller-retour reseau evitable. Le serveur
* revalide toujours (defense en profondeur).
*
* Mapping erreurs API :
* - 409 (RG-1.07 doublon) toast + erreur sur le champ `name`
* - 422 (violations API Platform) mapping sur les champs concernes
* - autre erreur globale `_global` + toast generique
*/
import { computed, ref } from 'vue'
import type { Category } from '~/modules/catalog/types/category'
import { extractApiErrorMessage, extractApiViolations } from '~/shared/utils/api'
/**
* Erreur HTTP capturee par ofetch. On expose juste les champs utilises ici
* (status et payload data) pour eviter de typer toute la lib.
*/
interface ApiFetchError {
response?: {
status?: number
_data?: unknown
}
}
export function useCategoryForm() {
const api = useApi()
const { t } = useI18n()
const toast = useToast()
// State local du formulaire — pas singleton, chaque appel a useCategoryForm
// cree son propre state (cohérent avec le pattern « un drawer = un form »).
const name = ref('')
const categoryTypeId = ref<number | null>(null)
// Snapshot des valeurs initiales : sert a calculer `isDirty` pour le
// pattern view → edit du drawer (le bouton Enregistrer reste masque tant
// que rien n'a change en mode consultation).
const initialName = ref('')
const initialCategoryTypeId = ref<number | null>(null)
const errors = ref<{
name: string
categoryType: string
_global: string
}>({
name: '',
categoryType: '',
_global: '',
})
const submitting = ref(false)
const isDirty = computed(
() =>
name.value !== initialName.value
|| categoryTypeId.value !== initialCategoryTypeId.value,
)
/**
* Pre-remplit le formulaire a partir d'une categorie existante (mode
* consultation/edition) ou vide (mode creation). Reinitialise les
* erreurs et le snapshot initial pour repartir d'un etat propre.
*/
function loadFrom(category: Category | null): void {
errors.value = { name: '', categoryType: '', _global: '' }
if (category) {
name.value = category.name
categoryTypeId.value = category.categoryType.id
initialName.value = category.name
initialCategoryTypeId.value = category.categoryType.id
} else {
name.value = ''
categoryTypeId.value = null
initialName.value = ''
initialCategoryTypeId.value = null
}
}
/**
* Validation client miroir des RG back. Renvoie true si tout passe et
* peuple `errors` sinon. Le trim est applique cote client (miroir RG-1.03)
* mais le serveur retrim de toute facon pas de risque de divergence.
*/
function validate(): boolean {
errors.value = { name: '', categoryType: '', _global: '' }
const trimmedName = name.value.trim()
// RG-1.02 — name obligatoire (vide / whitespace-only).
if (trimmedName === '') {
errors.value.name = t('admin.categories.validation.nameRequired')
} else if (trimmedName.length < 2 || trimmedName.length > 120) {
// RG-1.04 — longueur 2-120 apres trim.
errors.value.name = t('admin.categories.validation.nameLength')
}
// RG-1.05 — categoryType obligatoire.
if (categoryTypeId.value === null) {
errors.value.categoryType = t('admin.categories.validation.typeRequired')
}
return errors.value.name === '' && errors.value.categoryType === ''
}
/**
* Construit le payload POST a partir du state. Le `categoryType` est
* envoye en IRI Hydra (`/api/category_types/{id}`) convention API
* Platform pour referencer une ressource liee. Retourne un object literal
* compatible avec `AnyObject` de `useApi()` (un type nomme strict comme
* `CategoryCreateInput` ne serait pas assignable a `Record<string, unknown>`
* en TS strict).
*/
function buildCreatePayload(): Record<string, unknown> {
return {
name: name.value.trim(),
categoryType: `/api/category_types/${categoryTypeId.value}`,
}
}
/**
* Mappe les violations 422 d'API Platform sur les champs du formulaire.
* Renvoie true des qu'au moins une violation a ete posee false sinon
* (payload sans violations exploitables, ou tous les `propertyPath` hors
* du mapping connu). L'extraction Hydra (`violations` / `hydra:violations`)
* est centralisee dans `shared/utils/api.ts` pour rester reutilisable
* sur les futurs drawers de formulaire.
*/
function mapServerViolations(data: unknown): boolean {
const violations = extractApiViolations(data)
if (violations.length === 0) return false
let mapped = false
for (const v of violations) {
if (v.propertyPath === 'name') {
errors.value.name = v.message
mapped = true
} else if (v.propertyPath === 'categoryType') {
errors.value.categoryType = v.message
mapped = true
}
}
return mapped
}
/**
* Traite une erreur API : mappe selon le status, declenche les toasts
* appropries. Centralise la logique entre create/update.
*
* - 409 (RG-1.07) : doublon toast + errors.name avec libelle qui inclut
* le nom soumis.
* - 422 : tentative de mapping fin via les violations API Platform si au
* moins une violation est mappee, pas de toast (erreur affichee inline
* sous le champ concerne).
* - autre : message global + toast generique. Le toast natif d'useApi
* est desactive (`toast: false`) pour permettre ce mapping fin ; il faut
* donc en re-emettre un manuellement ici, sinon une 500 reste silencieuse.
*
* Retourne true si l'erreur a ete reconnue et traitee (409/422 mappes),
* false sinon (fallback generique).
*/
function handleApiError(e: unknown, attemptedName: string): boolean {
const status = (e as ApiFetchError)?.response?.status
const data = (e as ApiFetchError)?.response?._data
if (status === 409) {
const duplicateMessage = t('admin.categories.toast.duplicate', {
name: attemptedName,
})
errors.value.name = duplicateMessage
toast.error({
title: 'Erreur',
message: duplicateMessage,
})
return true
}
if (status === 422 && mapServerViolations(data)) {
return true
}
const extracted = extractApiErrorMessage(data)
errors.value._global = extracted || 'Une erreur est survenue.'
toast.error({
title: 'Erreur',
message: errors.value._global,
})
return false
}
/**
* POST /api/categories. Renvoie la categorie creee, ou `null` si la
* validation client a echoue ou si le serveur a renvoye une erreur. Le
* caller (drawer) decide quoi faire en fonction (fermer ou rester ouvert).
*/
async function submitCreate(): Promise<Category | null> {
if (!validate()) return null
submitting.value = true
errors.value._global = ''
const payload = buildCreatePayload()
try {
const created = await api.post<Category>('/categories', payload, {
toast: false,
})
toast.success({
title: 'Succès',
message: t('admin.categories.toast.created'),
})
return created
} catch (e) {
handleApiError(e, String(payload.name))
return null
} finally {
submitting.value = false
}
}
/**
* PATCH /api/categories/{id}. Envoie uniquement les champs modifies pour
* coller a la semantique merge-patch (Content-Type pose par useApi).
* Renvoie la categorie mise a jour, ou `null` en cas d'echec.
*/
async function submitUpdate(id: number): Promise<Category | null> {
if (!validate()) return null
submitting.value = true
errors.value._global = ''
const payload: Record<string, unknown> = {}
if (name.value !== initialName.value) {
payload.name = name.value.trim()
}
if (categoryTypeId.value !== initialCategoryTypeId.value) {
payload.categoryType = `/api/category_types/${categoryTypeId.value}`
}
// Garde-fou : un PATCH sans changement ne sert a rien. Theoriquement
// empeche par le drawer (bouton Enregistrer masque si !isDirty) mais
// on protege le composable contre un appel direct mal utilise.
if (Object.keys(payload).length === 0) {
submitting.value = false
return null
}
try {
const updated = await api.patch<Category>(`/categories/${id}`, payload, {
toast: false,
})
toast.success({
title: 'Succès',
message: t('admin.categories.toast.updated'),
})
return updated
} catch (e) {
const attemptedName = typeof payload.name === 'string'
? payload.name
: name.value.trim()
handleApiError(e, attemptedName)
return null
} finally {
submitting.value = false
}
}
/**
* DELETE /api/categories/{id} soft delete (RG-1.12). Le serveur pose
* `deleted_at = now()` et retourne 204. Renvoie true en cas de succes,
* false sinon (avec toast erreur deja affiche).
*/
async function submitDelete(id: number): Promise<boolean> {
submitting.value = true
errors.value._global = ''
try {
await api.delete(`/categories/${id}`, {}, { toast: false })
toast.success({
title: 'Succès',
message: t('admin.categories.toast.deleted'),
})
return true
} catch (e) {
handleApiError(e, name.value)
return false
} finally {
submitting.value = false
}
}
/**
* Reset complet du formulaire utilise par le drawer apres save ou
* fermeture pour ne pas garder de donnees stale entre deux ouvertures.
*/
function reset(): void {
name.value = ''
categoryTypeId.value = null
initialName.value = ''
initialCategoryTypeId.value = null
errors.value = { name: '', categoryType: '', _global: '' }
submitting.value = false
}
return {
// State
name,
categoryTypeId,
errors,
submitting,
isDirty,
// Methods
loadFrom,
validate,
submitCreate,
submitUpdate,
submitDelete,
reset,
}
}
+1
View File
@@ -0,0 +1 @@
export default defineNuxtConfig({})
@@ -0,0 +1,139 @@
<template>
<div>
<PageHeader>
{{ t('admin.categories.title') }}
<template #actions>
<MalioButton
v-if="canManage"
:label="t('admin.categories.newCategory')"
icon-name="mdi:add-bold"
icon-position="left"
@click="openCreateDrawer"
/>
</template>
</PageHeader>
<!-- Table des categories. Affichage exhaustif (volumetrie cible
<= 300, cf. spec § 4.1) tri 100% serveur via CategoryProvider
(name ASC, RG-1.10). La barre de pagination du MalioDataTable
reste cosmetique tant qu'aucun slice client n'est cable : a
traiter cote @malio/layer-ui le jour ou la volumetrie monte. -->
<MalioDataTable
:columns="columns"
:items="categoryItems"
:total-items="categories.length"
:row-clickable="true"
:empty-message="t('admin.categories.noCategories')"
@row-click="onRowClick"
/>
<!-- Drawer creation / consultation / edition. -->
<CategoryDrawer
v-model="drawerOpen"
:category="selectedCategory"
@saved="onCategorySaved"
@delete="onDeleteRequest"
/>
<!-- Modale de confirmation suppression (soft delete cote serveur). -->
<CategoryDeleteModal
v-model="deleteModalOpen"
:category-name="categoryToDelete?.name ?? ''"
:loading="deleting"
@confirm="handleDelete"
/>
</div>
</template>
<script setup lang="ts">
import type { Category } from '~/modules/catalog/types/category'
const { t } = useI18n()
const { can } = usePermissions()
const { categories, fetchAll, fetchTypes } = useCategoriesAdmin()
const { submitDelete } = useCategoryForm()
useHead({ title: t('admin.categories.title') })
const canManage = computed(() => can('catalog.categories.manage'))
const drawerOpen = ref(false)
const selectedCategory = ref<Category | null>(null)
const deleteModalOpen = ref(false)
const categoryToDelete = ref<Category | null>(null)
const deleting = ref(false)
// Colonnes du datatable. Le type est embarque cote API (cf. spec-back § 3.4)
// on aplatit en label lisible pour l'affichage.
const columns = [
{ key: 'name', label: t('admin.categories.table.name') },
{ key: 'typeLabel', label: t('admin.categories.table.type') },
]
const categoryItems = computed(() =>
categories.value.map(cat => ({
id: cat.id,
name: cat.name,
typeLabel: cat.categoryType?.label ?? '',
})),
)
function getCategoryById(id: number): Category | undefined {
return categories.value.find(c => c.id === id)
}
function onRowClick(item: Record<string, unknown>) {
const category = getCategoryById(item.id as number)
if (category) openEditDrawer(category)
}
function openCreateDrawer() {
selectedCategory.value = null
drawerOpen.value = true
}
function openEditDrawer(category: Category) {
selectedCategory.value = category
drawerOpen.value = true
}
function onDeleteRequest() {
if (!selectedCategory.value) return
categoryToDelete.value = selectedCategory.value
deleteModalOpen.value = true
}
/**
* Soft delete via le composable de form (qui gere toast + erreur). Refresh
* de la liste a la fin pour retirer la ligne. L'index unique partiel
* autorise une recreation ulterieure avec le meme couple (name, type)
* RG-1.07.
*/
async function handleDelete(): Promise<void> {
if (!categoryToDelete.value) return
deleting.value = true
try {
const ok = await submitDelete(categoryToDelete.value.id)
if (ok) {
deleteModalOpen.value = false
categoryToDelete.value = null
drawerOpen.value = false
await fetchAll()
}
} finally {
deleting.value = false
}
}
function onCategorySaved() {
fetchAll()
}
// Chargement initial des deux ressources (liste + referentiel des types).
// Le referentiel est pre-charge ici (et pas dans le drawer) pour que le
// select soit pret au moment ou l'utilisateur clique sur « + Ajouter ».
onMounted(() => {
fetchAll()
fetchTypes()
})
</script>
@@ -0,0 +1,71 @@
/**
* Types front du module Catalog (M0 Gestion des categories).
*
* Contrats API consommes :
* - GET /api/categories HydraCollection<Category>
* - GET /api/categories/{id} Category
* - POST /api/categories body { name, categoryType: IRI }
* - PATCH /api/categories/{id} body partiel { name?, categoryType?: IRI }
* - DELETE /api/categories/{id} 204 (soft delete via CategoryProcessor)
* - GET /api/category_types HydraCollection<CategoryType>
*
* Notes :
* - Les IRI sont envoyes en POST/PATCH (ex. "/api/category_types/3").
* - `categoryType` est embarque (groupe Serializer `category:read` sur les
* proprietes de CategoryType, cf. spec-back § 3.4).
* - `createdBy` / `updatedBy` peuvent etre `null` (hors contexte HTTP,
* ON DELETE SET NULL en BDD). Affichage : libelle "Systeme" si null.
*/
/**
* Reference legere d'un user, telle qu'embarquee dans Category.createdBy /
* updatedBy. Volontairement minimaliste : on n'a besoin que de l'identifiant
* et de l'username pour l'affichage courant.
*/
export interface User {
id: number
username: string
}
/**
* Reference du referentiel CategoryType (lecture seule au M0).
*/
export interface CategoryType {
id: number
code: string
label: string
}
/**
* Categorie metier telle qu'elle est lue depuis l'API. L'entite porte le
* pattern Timestampable+Blamable (cf. spec-back § 2.8).
*/
export interface Category {
id: number
name: string
categoryType: CategoryType
/** Soft delete : null = active, valeur = supprimee logiquement le {date}. */
deletedAt: string | null
createdAt: string
updatedAt: string
createdBy: User | null
updatedBy: User | null
}
/**
* Payload accepte en POST /api/categories. `categoryType` est envoye en
* IRI Hydra (ex. `/api/category_types/3`).
*/
export interface CategoryCreateInput {
name: string
categoryType: string
}
/**
* Payload accepte en PATCH /api/categories/{id}. Tous les champs sont
* optionnels (modification partielle).
*/
export interface CategoryUpdateInput {
name?: string
categoryType?: string
}
@@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('commercial.title') }}</h1> <PageHeader>{{ $t('commercial.title') }}</PageHeader>
<p class="mt-4 text-neutral-500">{{ $t('commercial.welcome') }}</p> <p class="text-neutral-500">{{ $t('commercial.welcome') }}</p>
</div> </div>
</template> </template>
@@ -0,0 +1,71 @@
<template>
<!-- Accordeon de permissions groupees par module : un panneau par module,
avec compteur (selectionnees/total) dans le titre, case "Tout selectionner"
et liste des permissions individuelles. Source unique de cette UX, utilisee
par RoleDrawer (permissions du role) et UserRbacDrawer (permissions directes). -->
<MalioAccordion v-model="openModules">
<MalioAccordionItem
v-for="group in groupsByModule"
:key="group.module"
:value="group.module"
:title="`${group.module} (${selectedCountFor(group)}/${group.permissions.length})`"
header-class="capitalize"
>
<div class="flex flex-col gap-3">
<!-- Tout selectionner pour ce module -->
<MalioCheckbox
:id="`${idPrefix}-group-${group.module}`"
:label="t('admin.roles.permissions.selectAll')"
:model-value="allSelectedFor(group)"
label-class="font-semibold text-sm text-neutral-700"
@update:model-value="(val: boolean) => emit('toggle-all', group.module, val)"
/>
<div class="flex flex-col gap-2">
<MalioCheckbox
v-for="perm in group.permissions"
:id="`${idPrefix}-perm-${perm.id}`"
:key="perm.id"
:label="perm.label"
:model-value="selectedIds.has(perm.id)"
label-class="text-sm text-neutral-600"
@update:model-value="(val: boolean) => emit('toggle', perm.id, val)"
/>
</div>
</div>
</MalioAccordionItem>
</MalioAccordion>
</template>
<script setup lang="ts">
import type { PermissionModule } from '~/shared/types/rbac'
const { t } = useI18n()
const props = defineProps<{
/** Groupes de permissions a afficher, un par module. */
groupsByModule: PermissionModule[]
/** Ids des permissions actuellement selectionnees. */
selectedIds: Set<number>
/** Prefixe pour les ids HTML : evite les collisions si plusieurs accordeons coexistent (ex: "role" vs "direct"). */
idPrefix: string
}>()
const emit = defineEmits<{
toggle: [permissionId: number, selected: boolean]
'toggle-all': [module: string, selected: boolean]
}>()
// Modules ouverts dans l'accordeon (mode multiple). Etat local : chaque instance
// du composant garde sa propre liste, pas de partage entre drawers.
const openModules = ref<string[]>([])
// Nombre de permissions selectionnees pour un module donne.
function selectedCountFor(group: PermissionModule): number {
return group.permissions.filter(p => props.selectedIds.has(p.id)).length
}
// Vrai si toutes les permissions du module sont selectionnees.
function allSelectedFor(group: PermissionModule): boolean {
return group.permissions.length > 0 && selectedCountFor(group) === group.permissions.length
}
</script>
@@ -1,66 +0,0 @@
<template>
<div class="rounded-lg border border-neutral-200 overflow-hidden">
<!-- En-tete du groupe avec checkbox "tout selectionner" -->
<div class="flex items-center gap-3 bg-neutral-50 px-4 py-3 border-b border-neutral-200">
<MalioCheckbox
:id="`group-${module}`"
:label="moduleLabel"
:model-value="allSelected"
label-class="font-semibold text-sm text-neutral-700 capitalize"
@update:model-value="toggleAll"
/>
<span class="ml-auto text-xs text-neutral-400">
{{ selectedCount }}/{{ permissions.length }}
</span>
</div>
<!-- Liste des permissions individuelles -->
<div class="grid grid-cols-1 gap-1 p-3 sm:grid-cols-2">
<MalioCheckbox
v-for="perm in permissions"
:key="perm.id"
:id="`perm-${perm.id}`"
:label="perm.label"
:model-value="selectedIds.has(perm.id)"
label-class="text-sm text-neutral-600"
@update:model-value="(val: boolean) => togglePermission(perm.id, val)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import type { Permission } from '~/shared/types/rbac'
const props = defineProps<{
module: string
moduleLabel: string
permissions: Permission[]
selectedIds: Set<number>
}>()
const emit = defineEmits<{
toggle: [permissionId: number, selected: boolean]
toggleAll: [module: string, selected: boolean]
}>()
// Nombre de permissions selectionnees dans ce groupe
const selectedCount = computed(() =>
props.permissions.filter(p => props.selectedIds.has(p.id)).length
)
// Vrai si toutes les permissions du groupe sont selectionnees
const allSelected = computed(() =>
props.permissions.length > 0 && selectedCount.value === props.permissions.length
)
// Emet l'evenement de bascule pour une permission individuelle
function togglePermission(id: number, selected: boolean) {
emit('toggle', id, selected)
}
// Emet l'evenement de bascule pour toutes les permissions du groupe
function toggleAll(selected: boolean) {
emit('toggleAll', props.module, selected)
}
</script>
+22 -20
View File
@@ -1,11 +1,17 @@
<template> <template>
<MalioDrawer <MalioDrawer
:model-value="modelValue" :model-value="modelValue"
:title="isEditMode ? t('admin.roles.editRole') : t('admin.roles.createRole')"
drawer-class="w-full max-w-lg" drawer-class="w-full max-w-lg"
header-class="border-b border-black"
footer-class="justify-between border-t border-black p-6"
@update:model-value="emit('update:modelValue', $event)" @update:model-value="emit('update:modelValue', $event)"
> >
<form class="flex flex-col gap-6 p-4" @submit.prevent="handleSave"> <template #header>
<h2 class="text-[24px] font-bold">
{{ isEditMode ? t('admin.roles.editRole') : t('admin.roles.createRole') }}
</h2>
</template>
<form class="flex flex-col gap-4 py-4" @submit.prevent="handleSave">
<!-- Champs du role --> <!-- Champs du role -->
<MalioInputText <MalioInputText
v-model="form.label" v-model="form.label"
@@ -44,28 +50,28 @@
<div v-else-if="permissionsByModule.length === 0" class="text-sm text-neutral-400"> <div v-else-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
{{ t('admin.roles.permissions.noPermissions') }} {{ t('admin.roles.permissions.noPermissions') }}
</div> </div>
<div class="flex flex-col gap-4"> <PermissionAccordion
<PermissionGroup v-else
v-for="group in permissionsByModule" :groups-by-module="permissionsByModule"
:key="group.module"
:module="group.module"
:module-label="group.module"
:permissions="group.permissions"
:selected-ids="selectedPermissionIds" :selected-ids="selectedPermissionIds"
id-prefix="role"
@toggle="handleTogglePermission" @toggle="handleTogglePermission"
@toggle-all="handleToggleAll" @toggle-all="handleToggleAll"
/> />
</div> </div>
</div>
<!-- Boutons --> </form>
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
<!-- Footer fixe : depuis la 1.7.1 le slot #footer est un frere du body
scrollable (shrink-0), donc reellement fige sans sticky. -->
<template #footer>
<MalioButton <MalioButton
v-if="isEditMode" v-if="isEditMode"
:label="t('common.delete')" :label="t('common.delete')"
variant="danger" variant="danger"
icon-name="mdi:delete-outline" icon-name="mdi:delete-outline"
icon-position="left" icon-position="left"
button-class="w-[150px]"
:disabled="role?.isSystem" :disabled="role?.isSystem"
@click="emit('delete')" @click="emit('delete')"
/> />
@@ -73,26 +79,22 @@
v-else v-else
:label="t('common.cancel')" :label="t('common.cancel')"
variant="tertiary" variant="tertiary"
button-class="w-[150px]"
@click="emit('update:modelValue', false)" @click="emit('update:modelValue', false)"
/> />
<MalioButton <MalioButton
:label="t('common.save')" :label="t('common.save')"
variant="primary" variant="primary"
button-class="w-[150px]"
:disabled="saving || permissionsLoadFailed" :disabled="saving || permissionsLoadFailed"
@click="handleSave" @click="handleSave"
/> />
</div> </template>
</form>
</MalioDrawer> </MalioDrawer>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Permission, Role } from '~/shared/types/rbac' import type { Permission, PermissionModule, Role } from '~/shared/types/rbac'
interface PermissionModule {
module: string
permissions: Permission[]
}
const { t } = useI18n() const { t } = useI18n()
const api = useApi() const api = useApi()
@@ -1,11 +1,17 @@
<template> <template>
<MalioDrawer <MalioDrawer
:model-value="modelValue" :model-value="modelValue"
:title="t('admin.users.drawer.title', { username: user?.username ?? '' })" drawer-class="w-full max-w-[450px]"
drawer-class="w-full max-w-lg" header-class="border-b border-black"
footer-class="justify-between border-t border-black p-6"
@update:model-value="emit('update:modelValue', $event)" @update:model-value="emit('update:modelValue', $event)"
> >
<div class="flex flex-col gap-6 p-4"> <template #header>
<h2 class="text-[24px] font-bold">
{{ t('admin.users.drawer.title', { username: user?.username ?? '' }) }}
</h2>
</template>
<div class="flex flex-col gap-4 py-4">
<!-- Etat d'erreur de chargement des referentiels : bloque la <!-- Etat d'erreur de chargement des referentiels : bloque la
sauvegarde pour empecher un ecrasement silencieux des droits. --> sauvegarde pour empecher un ecrasement silencieux des droits. -->
<div <div
@@ -60,19 +66,15 @@
<div v-if="permissionsByModule.length === 0" class="text-sm text-neutral-400"> <div v-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
{{ t('admin.roles.permissions.noPermissions') }} {{ t('admin.roles.permissions.noPermissions') }}
</div> </div>
<div class="flex flex-col gap-4"> <PermissionAccordion
<PermissionGroup v-else
v-for="group in permissionsByModule" :groups-by-module="permissionsByModule"
:key="group.module"
:module="group.module"
:module-label="group.module"
:permissions="group.permissions"
:selected-ids="selectedDirectPermissionIds" :selected-ids="selectedDirectPermissionIds"
id-prefix="direct"
@toggle="handleTogglePermission" @toggle="handleTogglePermission"
@toggle-all="handleToggleAll" @toggle-all="handleToggleAll"
/> />
</div> </div>
</div>
<!-- Section Sites autorises (ticket 2 module Sites) --> <!-- Section Sites autorises (ticket 2 module Sites) -->
<div> <div>
@@ -103,33 +105,32 @@
<EffectivePermissions :permissions="effectivePermissions" /> <EffectivePermissions :permissions="effectivePermissions" />
</div> </div>
<!-- Boutons --> </div>
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
<!-- Footer fixe : depuis la 1.7.1 le slot #footer est un frere du body
scrollable (shrink-0), donc reellement fige sans sticky. -->
<template #footer>
<MalioButton <MalioButton
:label="t('common.cancel')" :label="t('common.cancel')"
variant="tertiary" variant="tertiary"
button-class="w-[150px]"
@click="emit('update:modelValue', false)" @click="emit('update:modelValue', false)"
/> />
<MalioButton <MalioButton
:label="t('common.save')" :label="t('common.save')"
variant="primary" variant="primary"
button-class="w-[150px]"
:disabled="saving || loadFailed" :disabled="saving || loadFailed"
@click="handleSave" @click="handleSave"
/> />
</div> </template>
</div>
</MalioDrawer> </MalioDrawer>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Permission, Role, UserListItem, UserRbacDetail, EffectivePermission } from '~/shared/types/rbac' import type { Permission, PermissionModule, Role, UserListItem, UserRbacDetail, EffectivePermission } from '~/shared/types/rbac'
import type { Site } from '~/shared/types/sites' import type { Site } from '~/shared/types/sites'
interface PermissionModule {
module: string
permissions: Permission[]
}
const { t } = useI18n() const { t } = useI18n()
const api = useApi() const api = useApi()
const auth = useAuthStore() const auth = useAuthStore()
+184 -178
View File
@@ -1,95 +1,22 @@
<template> <template>
<div> <div>
<div class="flex items-center justify-between"> <PageHeader>
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
{{ t('admin.auditLog.title') }} {{ t('admin.auditLog.title') }}
</h1> <template #actions>
</div>
<!-- Filtres -->
<section class="mt-4 rounded border border-gray-200 bg-white p-4">
<!-- Labels uniformes au-dessus : les composants Malio sont utilises sans
leur `label` flottant interne pour ne pas mixer deux patterns de label.
A revoir une fois le composant calendar Malio développé -->
<div class="grid grid-cols-1 items-start gap-3 md:grid-cols-5">
<!-- TODO(malio-ui): remplacer par un composant Malio quand la lib
exposera un datetime picker. Cf. exception documentee dans
CLAUDE.md (section "Composants formulaires"). -->
<div>
<label class="mb-1 block text-xs font-medium text-gray-600">
{{ t('audit.filters.date_from') }}
</label>
<input
v-model="filters.performedAtAfter"
type="datetime-local"
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
>
</div>
<!-- TODO(malio-ui): idem ci-dessus. -->
<div>
<label class="mb-1 block text-xs font-medium text-gray-600">
{{ t('audit.filters.date_to') }}
</label>
<input
v-model="filters.performedAtBefore"
type="datetime-local"
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600">
{{ t('audit.filters.entity_type') }}
</label>
<div class="[&>div>div]:!mt-0">
<MalioSelectCheckbox
v-model="selectedEntityTypes"
:options="entityTypeOptions"
:display-select-all="true"
:display-tag="true"
min-width="w-full"
text-field="text-sm"
text-value="text-sm"
/>
</div>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600">
{{ t('audit.filters.user') }}
</label>
<MalioInputText
v-model="performedByInput"
icon-name="mdi:account-search"
input-class="text-sm"
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600">
{{ t('audit.filters.action') }}
</label>
<div class="[&>div>div]:!mt-0">
<MalioSelect
v-model="actionValue"
:options="actionOptions"
text-field="text-sm"
text-value="text-sm"
/>
</div>
</div>
</div>
<div class="mt-3 flex justify-end">
<MalioButton <MalioButton
variant="tertiary" variant="tertiary"
:label="t('audit.filters.reset')" :label="t('audit.filters.title')"
button-class="text-xs" icon-name="mdi:tune"
@click="resetFilters" icon-position="left"
icon-size="24"
button-class="w-[184px] justify-start gap-4 text-black"
@click="openFilters"
/> />
</div> </template>
</section> </PageHeader>
<!-- Tableau --> <!-- Tableau -->
<MalioDataTable <MalioDataTable
class="mt-4"
:columns="columns" :columns="columns"
:items="rows" :items="rows"
:total-items="totalItems" :total-items="totalItems"
@@ -123,12 +50,99 @@
</template> </template>
</MalioDataTable> </MalioDataTable>
<!-- Drawer de filtres : etat brouillon, applique uniquement au clic sur
"Voir les resultats". `body-class="p-0"` pour que l'accordeon aille
bord a bord (les items portent leur propre px-7). -->
<MalioDrawer
v-model="filterDrawerOpen"
drawer-class="max-w-[450px]"
body-class="p-0"
footer-class="justify-between border-t border-black p-6"
>
<template #header>
<h2 class="text-[24px] font-bold uppercase">{{ t('audit.filters.title') }}</h2>
</template>
<MalioAccordion>
<!-- Dates : deux champs date+heure Du / Au (champs datetime a l'origine) -->
<MalioAccordionItem :title="t('audit.filters.date_range')" value="dates">
<div class="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-4">
<span>{{ t('audit.filters.date_from') }}</span>
<!-- Borne le picker "Du" par la valeur "Au" pour interdire une plage
inversee a la saisie (le backend renverrait silencieusement 0 ligne). -->
<MalioDateTime
v-model="draftDateFrom"
:max="draftDateTo ?? undefined"
/>
<span>{{ t('audit.filters.date_to') }}</span>
<MalioDateTime
v-model="draftDateTo"
:min="draftDateFrom ?? undefined"
/>
</div>
</MalioAccordionItem>
<!-- Type d'entite : cases a cocher (multi-selection) -->
<MalioAccordionItem :title="t('audit.filters.entity_type')" value="entity">
<div class="flex flex-col gap-4">
<MalioCheckbox
v-for="opt in entityTypeOptions"
:id="`filter-entity-${opt.value}`"
:key="opt.value"
:label="opt.label"
:model-value="draftEntityTypes.includes(opt.value)"
@update:model-value="(val: boolean) => toggleEntity(opt.value, val)"
/>
</div>
</MalioAccordionItem>
<!-- Action : boutons radio (selection unique, '' = toutes) -->
<MalioAccordionItem :title="t('audit.filters.action')" value="action">
<MalioRadioButton
v-for="opt in actionOptions"
:key="opt.value"
v-model="draftAction"
name="audit-action"
:value="opt.value"
:label="opt.label"
/>
</MalioAccordionItem>
<!-- Utilisateur : recherche texte (ILIKE partiel cote backend) -->
<MalioAccordionItem :title="t('audit.filters.user')" value="user">
<MalioInputText
v-model="draftPerformedBy"
icon-name="mdi:account-search"
/>
</MalioAccordionItem>
</MalioAccordion>
<template #footer>
<MalioButton
variant="tertiary"
:label="t('audit.filters.reset')"
button-class="w-[150px]"
@click="resetFilters"
/>
<MalioButton
variant="primary"
:label="t('audit.filters.apply')"
button-class="w-[170px]"
@click="applyFilters"
/>
</template>
</MalioDrawer>
<!-- Drawer detail : diff courant + timeline complete de l'entite --> <!-- Drawer detail : diff courant + timeline complete de l'entite -->
<MalioDrawer <MalioDrawer
v-model="drawerOpen" v-model="drawerOpen"
:title="drawerTitle"
drawer-class="max-w-2xl" drawer-class="max-w-2xl"
> >
<template #header>
<h2 class="text-[24px] font-bold">
{{ drawerTitle }}
</h2>
</template>
<div v-if="selectedEntry"> <div v-if="selectedEntry">
<AuditLogDetail :entry="selectedEntry" /> <AuditLogDetail :entry="selectedEntry" />
<div class="mt-4 border-t border-gray-200 pt-3"> <div class="mt-4 border-t border-gray-200 pt-3">
@@ -149,12 +163,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import type { AuditLogEntry, AuditLogFilters } from '~/shared/types' import type { AuditLogEntry, AuditLogFilters } from '~/shared/types'
const { t, te } = useI18n() const { t, te } = useI18n()
const { can } = usePermissions() const { can } = usePermissions()
const { fetchLogsCached, fetchEntityTypes } = useAuditLog() const { fetchLogsCached, fetchEntityTypes } = useAuditLog()
const toast = useToast()
// Traduit un identifiant `module.Entity` (ex: `core.User`, `sites.Site`) en // Traduit un identifiant `module.Entity` (ex: `core.User`, `sites.Site`) en
// libelle lisible via la cle i18n `audit.entity.<module>_<entity>`. Si aucune // libelle lisible via la cle i18n `audit.entity.<module>_<entity>`. Si aucune
@@ -173,8 +188,11 @@ if (!can('core.audit_log.view')) {
useHead({ title: t('admin.auditLog.title') }) useHead({ title: t('admin.auditLog.title') })
// Etat des filtres : local uniquement, JAMAIS persiste dans l'URL (cf. regle // Etat des filtres APPLIQUES : pilote `loadEntries`. Local uniquement, JAMAIS
// CLAUDE.md "Tableau : pas de persistance URL"). // persiste dans l'URL (cf. regle CLAUDE.md "Tableau : pas de persistance URL").
// `performedAtAfter`/`performedAtBefore` stockent une date+heure ISO naive
// (`YYYY-MM-DDTHH:MM:00`, fournie par MalioDateTime), convertie en ISO UTC
// au moment du fetch.
const filters = reactive<AuditLogFilters>({ const filters = reactive<AuditLogFilters>({
performedAtAfter: undefined, performedAtAfter: undefined,
performedAtBefore: undefined, performedAtBefore: undefined,
@@ -185,26 +203,23 @@ const filters = reactive<AuditLogFilters>({
itemsPerPage: 10, itemsPerPage: 10,
}) })
// Multi-selection entity_type : bind dedie au MalioSelectCheckbox. // Etat BROUILLON du drawer de filtres : edite librement, recopie dans `filters`
// Attention : les composants Malio attendent `{ label, value }` (pas `{ text }`). // uniquement au clic sur "Voir les resultats". Permet d'annuler une saisie en
const selectedEntityTypes = ref<(string | number)[]>([]) // fermant le drawer sans relancer de requete.
const filterDrawerOpen = ref(false)
const draftDateFrom = ref<string | null>(null)
const draftDateTo = ref<string | null>(null)
const draftEntityTypes = ref<string[]>([])
const draftAction = ref<string>('')
const draftPerformedBy = ref<string>('')
// Liste des entity types (distincts) pour alimenter les cases a cocher.
const entityTypes = ref<string[]>([]) const entityTypes = ref<string[]>([])
// On garde l'identifiant technique comme `value` pour l'envoi API, mais on
// affiche le libelle traduit quand il existe (fallback: identifiant brut).
const entityTypeOptions = computed(() => const entityTypeOptions = computed(() =>
entityTypes.value.map(type => ({ value: type, label: formatEntityType(type) })), entityTypes.value.map(type => ({ value: type, label: formatEntityType(type) })),
) )
// Bind champ performedBy : MalioInputText attend `string | null`, on ne peut // Actions : '' = "toutes". Sert d'options aux boutons radio.
// pas binder directement un `string | undefined` reactive.
const performedByInput = ref<string>('')
// Action : '' = "toutes les actions". On declare l'option dans `actionOptions`
// plutot que via `emptyOptionLabel` (qui n'inclut pas l'option vide dans
// `props.options`, donc `selectedLabel` reste vide). On evite aussi `value: null`
// car MalioSelect grise visuellement les options dont la valeur est `null`
// (Select.vue:137) on utilise donc une chaine vide comme sentinelle.
const actionValue = ref<string>('')
const actionOptions = [ const actionOptions = [
{ value: '', label: t('audit.filters.all_actions') }, { value: '', label: t('audit.filters.all_actions') },
{ value: 'create', label: t('audit.action.create') }, { value: 'create', label: t('audit.action.create') },
@@ -259,29 +274,55 @@ const isFiltered = computed(() =>
// (reseau lent) n'ecrase les resultats d'une requete ulterieure. // (reseau lent) n'ecrase les resultats d'une requete ulterieure.
let requestToken = 0 let requestToken = 0
// Pendant un reset, on suspend temporairement les watchers pour ne pas // Ouvre le drawer en recopiant l'etat applique vers le brouillon, pour que la
// declencher 4 fetchs paralleles (un par champ mute). Les watchers Vue 3 // reouverture reflete les filtres actifs.
// sont asynchrones (microtask) : il faut attendre un `nextTick` avant de function openFilters(): void {
// les relacher, sinon le flag est deja `false` au moment ou ils s'executent draftDateFrom.value = filters.performedAtAfter ?? null
// et les fetchs partent quand meme. Un seul loadEntries() est appele draftDateTo.value = filters.performedAtBefore ?? null
// explicitement apres la liberation. draftEntityTypes.value = Array.isArray(filters.entityType)
let watchersSuspended = false ? [...filters.entityType]
: (filters.entityType ? [filters.entityType] : [])
draftAction.value = filters.action ?? ''
draftPerformedBy.value = filters.performedBy ?? ''
filterDrawerOpen.value = true
}
// Bascule un type d'entite dans le brouillon (multi-selection). Les valeurs
// sont uniques par construction (v-for sur entityTypeOptions), pas besoin de Set.
function toggleEntity(value: string, selected: boolean): void {
draftEntityTypes.value = selected
? [...draftEntityTypes.value, value]
: draftEntityTypes.value.filter(v => v !== value)
}
// "Reinitialiser" : vide le brouillon ET les filtres actifs, puis recharge.
// La remise a zero s'applique immediatement (la table revient a la liste
// complete) ; le drawer reste ouvert pour montrer le formulaire vide.
function resetFilters(): void {
draftDateFrom.value = null
draftDateTo.value = null
draftEntityTypes.value = []
draftAction.value = ''
draftPerformedBy.value = ''
async function resetFilters(): Promise<void> {
watchersSuspended = true
filters.performedAtAfter = undefined filters.performedAtAfter = undefined
filters.performedAtBefore = undefined filters.performedAtBefore = undefined
filters.entityType = undefined filters.entityType = undefined
filters.performedBy = undefined
filters.action = undefined filters.action = undefined
filters.performedBy = undefined
filters.page = 1 filters.page = 1
selectedEntityTypes.value = [] loadEntries()
performedByInput.value = '' }
actionValue.value = ''
// Les watchers mute de Vue 3 se planifient en microtask : on attend // "Voir les resultats" : applique le brouillon, recharge et ferme le drawer.
// leur execution avec le flag `true`, puis on libere. function applyFilters(): void {
await nextTick() filters.performedAtAfter = draftDateFrom.value ?? undefined
watchersSuspended = false filters.performedAtBefore = draftDateTo.value ?? undefined
filters.entityType = draftEntityTypes.value.length > 0 ? [...draftEntityTypes.value] : undefined
filters.action = draftAction.value === '' ? undefined : draftAction.value
filters.performedBy = draftPerformedBy.value.trim() === '' ? undefined : draftPerformedBy.value.trim()
filters.page = 1
filterDrawerOpen.value = false
loadEntries() loadEntries()
} }
@@ -291,7 +332,8 @@ async function loadEntries(): Promise<void> {
try { try {
const data = await fetchLogsCached({ const data = await fetchLogsCached({
...filters, ...filters,
// Convertit datetime-local (YYYY-MM-DDTHH:MM) en ISO pour l'API. // MalioDateTime fournit une date+heure sans fuseau (heure locale) ;
// on la convertit en ISO UTC pour l'API (bornes exactes, intervalle inclusif).
performedAtAfter: filters.performedAtAfter ? toIso(filters.performedAtAfter) : undefined, performedAtAfter: filters.performedAtAfter ? toIso(filters.performedAtAfter) : undefined,
performedAtBefore: filters.performedAtBefore ? toIso(filters.performedAtBefore) : undefined, performedAtBefore: filters.performedAtBefore ? toIso(filters.performedAtBefore) : undefined,
}) })
@@ -300,13 +342,19 @@ async function loadEntries(): Promise<void> {
if (token !== requestToken) return if (token !== requestToken) return
entries.value = data.member ?? [] entries.value = data.member ?? []
totalItems.value = data.totalItems ?? 0 totalItems.value = data.totalItems ?? 0
} catch { } catch (err) {
// En cas d'echec (reseau, 403, 500...), on reset l'etat pour ne pas // useAuditLog appelle useApi avec { toast: false } pour ne pas multiplier
// laisser l'utilisateur croire que les donnees affichees sont a jour. // les toasts, donc c'est ici qu'on fait remonter l'erreur. Sans ce log+toast,
// Le toast d'erreur est deja emis par `useApi()` via useAuditLog. // une RangeError de `toIso` (date invalide) ou une 500 API laissait l'utilisateur
// devant une table vide indistinguable d'un filtre a zero resultat.
if (token === requestToken) { if (token === requestToken) {
entries.value = [] entries.value = []
totalItems.value = 0 totalItems.value = 0
console.error('[audit-log] loadEntries failed', err)
toast.error({
title: t('audit.error.title'),
message: t('audit.error.message'),
})
} }
} finally { } finally {
if (token === requestToken) { if (token === requestToken) {
@@ -315,14 +363,9 @@ async function loadEntries(): Promise<void> {
} }
} }
// Debounce auto-importe depuis `frontend/shared/utils/debounce.ts` : evite
// un refetch a chaque frappe sur le champ texte performedBy (reseau + SQL)
// et laisse l'utilisateur finir sa saisie avant de lancer la requete.
const debouncedReload = debounce(() => loadEntries(), 300)
function toIso(localDateTime: string): string { function toIso(localDateTime: string): string {
// datetime-local n'a pas de timezone : on assume heure locale et on // MalioDateTime emet une date+heure sans fuseau (heure murale locale) ;
// laisse le navigateur generer l'ISO via Date(). // on laisse Date() generer l'ISO UTC correspondant pour l'API.
return new Date(localDateTime).toISOString() return new Date(localDateTime).toISOString()
} }
@@ -368,53 +411,16 @@ function onPerPageChange(value: number): void {
loadEntries() loadEntries()
} }
// Sync MalioSelectCheckbox -> filters.entityType + reset page 1 + reload.
watch(selectedEntityTypes, values => {
if (watchersSuspended) return
filters.entityType = values.length > 0 ? values.map(v => String(v)) : undefined
filters.page = 1
loadEntries()
})
// Sync MalioSelect action -> filters.action.
watch(actionValue, value => {
if (watchersSuspended) return
filters.action = value === '' ? undefined : value
filters.page = 1
loadEntries()
})
// Sync performedBy : frappe utilisateur -> debounce 300ms pour eviter un
// refetch par caractere. Le reset passe par debouncedReload egalement pour
// coalescer si plusieurs watchers tirent en meme temps.
watch(performedByInput, value => {
if (watchersSuspended) return
filters.performedBy = value === '' ? undefined : value
filters.page = 1
debouncedReload()
})
// Synchronisation reactive : tout changement de dates declenche un fetch +
// reset de la pagination a la page 1.
watch(
() => [filters.performedAtAfter, filters.performedAtBefore],
() => {
if (watchersSuspended) return
filters.page = 1
loadEntries()
},
)
onMounted(async () => { onMounted(async () => {
// Charge les entity types en parallele de la liste principale : un // Charge les entity types ET la liste principale en parallele (TTFD divise
// echec du premier endpoint (ex: reseau flaky) ne doit pas empecher // par 2 sur un backend lent). Le `.catch` du premier garantit qu'un echec
// le tableau d'audit de s'afficher. En cas d'erreur, on laisse le // de /audit-log-entity-types ne bloque pas l'affichage du tableau
// filtre vide l'utilisateur pourra quand meme consulter le journal. // l'utilisateur perd juste le filtre, pas la page entiere.
try { await Promise.all([
entityTypes.value = await fetchEntityTypes() fetchEntityTypes()
} catch { .then(types => { entityTypes.value = types })
entityTypes.value = [] .catch(() => { entityTypes.value = [] }),
} loadEntries(),
await loadEntries() ])
}) })
</script> </script>
+4 -6
View File
@@ -1,10 +1,8 @@
<template> <template>
<div> <div>
<!-- En-tete --> <PageHeader>
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
{{ t('admin.roles.title') }} {{ t('admin.roles.title') }}
</h1> <template #actions>
<MalioButton <MalioButton
v-if="can('core.roles.manage')" v-if="can('core.roles.manage')"
:label="t('admin.roles.newRole')" :label="t('admin.roles.newRole')"
@@ -12,11 +10,11 @@
icon-position="left" icon-position="left"
@click="openCreateDrawer" @click="openCreateDrawer"
/> />
</div> </template>
</PageHeader>
<!-- Table des roles --> <!-- Table des roles -->
<MalioDataTable <MalioDataTable
class="mt-6"
:columns="columns" :columns="columns"
:items="roleItems" :items="roleItems"
:total-items="roles.length" :total-items="roles.length"
+1 -7
View File
@@ -1,15 +1,9 @@
<template> <template>
<div> <div>
<!-- En-tete --> <PageHeader>{{ t('admin.users.title') }}</PageHeader>
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
{{ t('admin.users.title') }}
</h1>
</div>
<!-- Table des utilisateurs --> <!-- Table des utilisateurs -->
<MalioDataTable <MalioDataTable
class="mt-6"
:columns="columns" :columns="columns"
:items="userItems" :items="userItems"
:total-items="users.length" :total-items="users.length"
+2 -2
View File
@@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('dashboard.title') }}</h1> <PageHeader>{{ $t('dashboard.title') }}</PageHeader>
<p class="mt-4 text-neutral-500">{{ $t('dashboard.welcome') }}</p> <p class="text-neutral-500">{{ $t('dashboard.welcome') }}</p>
</div> </div>
</template> </template>
+2
View File
@@ -12,6 +12,7 @@ const { resetSidebar } = useSidebar()
const { resetModules } = useModules() const { resetModules } = useModules()
const { resetCurrentSite } = useCurrentSite() const { resetCurrentSite } = useCurrentSite()
const { resetAuditLog } = useAuditLog() const { resetAuditLog } = useAuditLog()
const { resetCategoriesAdmin } = useCategoriesAdmin()
onMounted(async () => { onMounted(async () => {
try { try {
@@ -27,6 +28,7 @@ onMounted(async () => {
resetModules() resetModules()
resetCurrentSite() resetCurrentSite()
resetAuditLog() resetAuditLog()
resetCategoriesAdmin()
await navigateTo('/login') await navigateTo('/login')
} }
}) })
@@ -1,11 +1,17 @@
<template> <template>
<MalioDrawer <MalioDrawer
:model-value="modelValue" :model-value="modelValue"
:title="isEditMode ? t('admin.sites.editSite') : t('admin.sites.createSite')"
drawer-class="w-full max-w-lg" drawer-class="w-full max-w-lg"
header-class="border-b border-black"
footer-class="justify-between border-t border-black p-6"
@update:model-value="emit('update:modelValue', $event)" @update:model-value="emit('update:modelValue', $event)"
> >
<form class="flex flex-col gap-6 p-4" @submit.prevent="handleSave"> <template #header>
<h2 class="text-[24px] font-bold">
{{ isEditMode ? t('admin.sites.editSite') : t('admin.sites.createSite') }}
</h2>
</template>
<form class="flex flex-col gap-4 py-4" @submit.prevent="handleSave">
<MalioInputText <MalioInputText
v-model="form.name" v-model="form.name"
:label="t('admin.sites.form.name')" :label="t('admin.sites.form.name')"
@@ -70,30 +76,35 @@
</p> </p>
</div> </div>
<!-- Boutons --> </form>
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
<!-- Footer fixe : depuis la 1.7.1 le slot #footer est un frere du body
scrollable (shrink-0), donc reellement fige sans sticky. -->
<template #footer>
<MalioButton <MalioButton
v-if="isEditMode" v-if="isEditMode"
:label="t('common.delete')" :label="t('common.delete')"
variant="danger" variant="danger"
icon-name="mdi:delete-outline" icon-name="mdi:delete-outline"
icon-position="left" icon-position="left"
button-class="w-[150px]"
@click="emit('delete')" @click="emit('delete')"
/> />
<MalioButton <MalioButton
v-else v-else
:label="t('common.cancel')" :label="t('common.cancel')"
variant="tertiary" variant="tertiary"
button-class="w-[150px]"
@click="emit('update:modelValue', false)" @click="emit('update:modelValue', false)"
/> />
<MalioButton <MalioButton
:label="t('common.save')" :label="t('common.save')"
variant="primary" variant="primary"
button-class="w-[150px]"
:disabled="saving || !isValidHex" :disabled="saving || !isValidHex"
@click="handleSave" @click="handleSave"
/> />
</div> </template>
</form>
</MalioDrawer> </MalioDrawer>
</template> </template>
+4 -6
View File
@@ -1,10 +1,8 @@
<template> <template>
<div> <div>
<!-- En-tete --> <PageHeader>
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
{{ t('admin.sites.title') }} {{ t('admin.sites.title') }}
</h1> <template #actions>
<MalioButton <MalioButton
v-if="can('sites.manage')" v-if="can('sites.manage')"
:label="t('admin.sites.newSite')" :label="t('admin.sites.newSite')"
@@ -12,11 +10,11 @@
icon-position="left" icon-position="left"
@click="openCreateDrawer" @click="openCreateDrawer"
/> />
</div> </template>
</PageHeader>
<!-- Table des sites --> <!-- Table des sites -->
<MalioDataTable <MalioDataTable
class="mt-6"
:columns="columns" :columns="columns"
:items="siteItems" :items="siteItems"
:total-items="sites.length" :total-items="sites.length"
+4 -4
View File
@@ -7,7 +7,7 @@
"name": "starseed-frontend", "name": "starseed-frontend",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@malio/layer-ui": "^1.5.0", "@malio/layer-ui": "^1.7.1",
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3", "@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
@@ -1866,9 +1866,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@malio/layer-ui": { "node_modules/@malio/layer-ui": {
"version": "1.5.0", "version": "1.7.1",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.5.0/layer-ui-1.5.0.tgz", "resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.1/layer-ui-1.7.1.tgz",
"integrity": "sha512-uVuG8kRakWgpWYQCMUf1LFD+gjx0iRFfNJn/jlqjxiZmZyGZMckcMW2qA9hGZBiheBsTJWw1pRR4ufuyAYPY0A==", "integrity": "sha512-RYMMappWt/fgjD+BM7//h2O6kxD6WH9Fui8hoC29xtKySRQsqD61XKTdR7BRRkpktbxKmV39q/hblyAFBqV5yw==",
"dependencies": { "dependencies": {
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
+1 -1
View File
@@ -17,7 +17,7 @@
"test:e2e:ui": "playwright test --ui" "test:e2e:ui": "playwright test --ui"
}, },
"dependencies": { "dependencies": {
"@malio/layer-ui": "^1.5.0", "@malio/layer-ui": "^1.7.1",
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3", "@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
@@ -0,0 +1,12 @@
<template>
<!-- Entete de page standard : source unique du style des titres.
Slot par defaut = texte du titre, slot #actions = boutons a droite. -->
<div class="mb-[44px] flex items-center justify-between gap-4">
<h1 class="text-[32px] font-semibold text-primary-500">
<slot/>
</h1>
<div v-if="$slots.actions" class="shrink-0">
<slot name="actions"/>
</div>
</div>
</template>
+3 -18
View File
@@ -1,5 +1,6 @@
import type { FetchOptions , FetchError } from 'ofetch' import type { FetchOptions , FetchError } from 'ofetch'
import { $fetch } from 'ofetch' import { $fetch } from 'ofetch'
import { extractApiErrorMessage } from '~/shared/utils/api'
export type AnyObject = Record<string, unknown> export type AnyObject = Record<string, unknown>
@@ -41,24 +42,8 @@ export function useApi(): ApiClient {
function extractErrorMessage(error: unknown, responseData?: unknown): string { function extractErrorMessage(error: unknown, responseData?: unknown): string {
const data = responseData ?? (error as FetchError)?.data const data = responseData ?? (error as FetchError)?.data
const msg = extractApiErrorMessage(data)
if (typeof data === 'string') { if (msg) return msg
return data
}
if (data && typeof data === 'object') {
const record = data as Record<string, unknown>
return (
(record['hydra:description'] as string) ||
(record.detail as string) ||
(record.message as string) ||
(record.error as string) ||
(record.title as string) ||
(record['hydra:title'] as string) ||
''
)
}
return (error as FetchError)?.message ?? 'Erreur inconnue.' return (error as FetchError)?.message ?? 'Erreur inconnue.'
} }
+9
View File
@@ -43,3 +43,12 @@ export interface EffectivePermission {
module: string module: string
sources: string[] sources: string[]
} }
/**
* Groupement de permissions par module pour l'affichage en accordeon.
* Construit cote consommateur a partir de la liste plate /api/permissions.
*/
export interface PermissionModule {
module: string
permissions: Permission[]
}
+59
View File
@@ -31,3 +31,62 @@ export interface HydraCollection<T> {
export function extractHydraMembers<T>(collection: HydraCollection<T>): T[] { export function extractHydraMembers<T>(collection: HydraCollection<T>): T[] {
return collection.member ?? [] return collection.member ?? []
} }
/**
* Une violation de contrainte API Platform (reponse 422). Le `propertyPath`
* pointe le champ concerne, `message` est le libelle a afficher.
*/
export interface ApiViolation {
propertyPath: string
message: string
}
/**
* Extrait les violations d'un payload d'erreur 422 d'API Platform 4. Supporte
* les deux formats de negociation (`violations` ou `hydra:violations`) et
* renvoie un tableau vide si le payload n'en contient pas d'exploitables.
*
* Utilise par useCategoryForm et tout futur composable de formulaire qui
* doit mapper les violations serveur sur ses champs.
*/
export function extractApiViolations(data: unknown): ApiViolation[] {
if (!data || typeof data !== 'object') return []
const record = data as Record<string, unknown>
const raw = record.violations ?? record['hydra:violations']
if (!Array.isArray(raw)) return []
const out: ApiViolation[] = []
for (const v of raw) {
if (!v || typeof v !== 'object') continue
const obj = v as Record<string, unknown>
out.push({
propertyPath: String(obj.propertyPath ?? ''),
message: String(obj.message ?? ''),
})
}
return out
}
/**
* Extrait un message d'erreur lisible depuis un payload Hydra / JSON
* d'erreur API Platform. Essaie les champs courants dans l'ordre :
* `hydra:description` `detail` `description` `message` `error`
* `title` `hydra:title`. Renvoie '' si rien d'exploitable.
*
* Si `data` est une string, la renvoie telle quelle (cas des erreurs
* Symfony en text/plain ou des messages bruts).
*/
export function extractApiErrorMessage(data: unknown): string {
if (typeof data === 'string') return data
if (!data || typeof data !== 'object') return ''
const record = data as Record<string, unknown>
return (
(record['hydra:description'] as string)
?? (record.detail as string)
?? (record.description as string)
?? (record.message as string)
?? (record.error as string)
?? (record.title as string)
?? (record['hydra:title'] as string)
?? ''
)
}
+6 -4
View File
@@ -35,7 +35,7 @@ export interface Persona {
// sidebar-visibility pour driver la matrice. Les valeurs correspondent // sidebar-visibility pour driver la matrice. Les valeurs correspondent
// aux slugs de route (`/admin/<slug>`), volontairement stables quand // aux slugs de route (`/admin/<slug>`), volontairement stables quand
// la copie/i18n change. // la copie/i18n change.
expectedAdminLinks: Array<'users' | 'roles' | 'sites' | 'audit-log'> expectedAdminLinks: Array<'users' | 'roles' | 'sites' | 'audit-log' | 'categories'>
} }
const SHARED_PASSWORD = 'e2e-secret' const SHARED_PASSWORD = 'e2e-secret'
@@ -47,7 +47,7 @@ export const personas: Record<PersonaKey, Persona> = {
password: SHARED_PASSWORD, password: SHARED_PASSWORD,
isAdmin: true, isAdmin: true,
permissions: [], permissions: [],
expectedAdminLinks: ['users', 'roles', 'sites', 'audit-log'], expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
}, },
'user-full': { 'user-full': {
key: 'user-full', key: 'user-full',
@@ -63,8 +63,10 @@ export const personas: Record<PersonaKey, Persona> = {
'sites.view', 'sites.view',
'sites.manage', 'sites.manage',
'sites.bypass_scope', 'sites.bypass_scope',
'catalog.categories.view',
'catalog.categories.manage',
], ],
expectedAdminLinks: ['users', 'roles', 'sites', 'audit-log'], expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
}, },
'user-readonly': { 'user-readonly': {
key: 'user-readonly', key: 'user-readonly',
@@ -109,4 +111,4 @@ export function getPersona(key: PersonaKey): Persona {
return personas[key] return personas[key]
} }
export const ALL_ADMIN_LINKS = ['users', 'roles', 'sites', 'audit-log'] as const export const ALL_ADMIN_LINKS = ['users', 'roles', 'sites', 'categories', 'audit-log'] as const
@@ -1,6 +1,6 @@
import type { Locator, Page } from '@playwright/test' import type { Locator, Page } from '@playwright/test'
export type AdminLinkSlug = 'users' | 'roles' | 'sites' | 'audit-log' export type AdminLinkSlug = 'users' | 'roles' | 'sites' | 'categories' | 'audit-log'
/** /**
* Page Object de la sidebar (MalioSidebar), scope sur les items "admin". * Page Object de la sidebar (MalioSidebar), scope sur les items "admin".
+22
View File
@@ -70,6 +70,7 @@ help:
@printf " \033[36m%-28s\033[0m %s\n" "install-e2e-deps" "One-time : Chromium + libs systeme (sudo)" @printf " \033[36m%-28s\033[0m %s\n" "install-e2e-deps" "One-time : Chromium + libs systeme (sudo)"
@printf "\n \033[1;33mQualite code\033[0m\n" @printf "\n \033[1;33mQualite code\033[0m\n"
@printf " \033[36m%-28s\033[0m %s\n" "php-cs-fixer-allow-risky" "Fix code style PHP (utilise par le pre-commit)" @printf " \033[36m%-28s\033[0m %s\n" "php-cs-fixer-allow-risky" "Fix code style PHP (utilise par le pre-commit)"
@printf " \033[36m%-28s\033[0m %s\n" "php-cs-fixer-check" "Dry-run du fixer (CI / verif avant push)"
@printf "\n Plus de details : \033[4mREADME.md\033[0m, \033[4mCLAUDE.md\033[0m\n\n" @printf "\n Plus de details : \033[4mREADME.md\033[0m, \033[4mCLAUDE.md\033[0m\n\n"
env-init: env-init:
@@ -199,12 +200,28 @@ migration-migrate:
# en DB, le purger crash. # en DB, le purger crash.
# 3. fixtures -> sync-permissions : fixtures:load purge la table permission, # 3. fixtures -> sync-permissions : fixtures:load purge la table permission,
# donc sync doit passer apres. # donc sync doit passer apres.
# 4. recreation des index partiels uniques : schema:update drop les index
# orphelins du mapping ORM. Les index partiels (LOWER + WHERE) ne sont pas
# exprimables via les attributs Doctrine ORM (fonctionnel + partiel), donc
# ils disparaissent apres schema:update. On les recree par dbal:run-sql :
# - `uq_category_name_type_active` (M0 Catalog) : tests RG-1.07.
# - `uq_client_company_name_active` (M1 Commercial) : unicite nom societe
# parmi actifs non archives/non supprimes (RG-1.16), tests ERP-55.
# Sans ces restores, 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: test-db-setup:
$(SYMFONY_CONSOLE) doctrine:database:create --env=test --if-not-exists $(SYMFONY_CONSOLE) doctrine:database:create --env=test --if-not-exists
$(SYMFONY_CONSOLE) doctrine:migrations:migrate --env=test --no-interaction $(SYMFONY_CONSOLE) doctrine:migrations:migrate --env=test --no-interaction
$(SYMFONY_CONSOLE) doctrine:schema:update --env=test --force $(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 doctrine:fixtures:load
$(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions $(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL"
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_client_company_name_active ON client (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
fixtures: fixtures:
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load $(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
@@ -258,6 +275,11 @@ php-cs-fixer-allow-risky:
@echo "Fixing files: $(FILES)" @echo "Fixing files: $(FILES)"
$(EXEC_PHP_CS_FIXER) fix --config=.php-cs-fixer.dist.php --allow-risky=yes $(FILES) $(EXEC_PHP_CS_FIXER) fix --config=.php-cs-fixer.dist.php --allow-risky=yes $(FILES)
# Dry-run du fixer : echec si au moins un fichier n'est pas conforme.
# Utilise par la CI (Gitea pull_request) et avant un push manuel.
php-cs-fixer-check:
$(EXEC_PHP_CS_FIXER) fix --config=.php-cs-fixer.dist.php --allow-risky=yes --dry-run --diff $(FILES)
test: test:
$(EXEC_PHP) php -d memory_limit="512M" vendor/bin/phpunit $(FILES) $(EXEC_PHP) php -d memory_limit="512M" vendor/bin/phpunit $(FILES)
+90
View File
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* M0 Catalog : creation des tables `category_type` (referentiel) et `category`.
*
* Le referentiel `category_type` est cree vide ; ses valeurs seront seedees
* ulterieurement (cf. spec-back M0 § 9 HP-1).
*
* Index unique partiel sur (LOWER(name), category_type_id) WHERE deleted_at
* IS NULL : permet la recreation d'une categorie apres suppression logique
* (cf. RG-1.07). Postgres supporte nativement le `CREATE UNIQUE INDEX ... WHERE`.
*
* Les 4 colonnes Timestampable/Blamable (`created_at`, `updated_at`,
* `created_by`, `updated_by`) materialisent le pattern Shared (cf. ERP-52,
* spec-back M0 § 2.8) : NOT NULL pour les dates (remplies par le subscriber),
* nullable + ON DELETE SET NULL pour les FK user (creation hors contexte HTTP
* et suppression d'un user sans bloquer les categories existantes).
*
* Migration placee au namespace racine `DoctrineMigrations` (regle ABSOLUE
* Starseed n°11) : avec plusieurs migrations_paths, Doctrine Migrations 3.x
* trie par FQCN alphabetique et non par version timestamp l'init des tables
* d'un module doit vivre au namespace racine pour garantir l'ordre sur base
* vide.
*/
final class Version20260527164000 extends AbstractMigration
{
public function getDescription(): string
{
return 'M0 Catalog : tables category_type et category, index unique partiel.';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE category_type (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
code VARCHAR(40) NOT NULL,
label VARCHAR(120) NOT NULL,
PRIMARY KEY (id)
)
SQL);
$this->addSql('CREATE UNIQUE INDEX uq_category_type_code ON category_type (code)');
$this->addSql(<<<'SQL'
CREATE TABLE category (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
name VARCHAR(120) NOT NULL,
category_type_id INT NOT 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_category_type
FOREIGN KEY (category_type_id) REFERENCES category_type (id) ON DELETE RESTRICT,
CONSTRAINT fk_category_created_by
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
CONSTRAINT fk_category_updated_by
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
)
SQL);
// Unicite (name, type) case-insensitive, seulement sur les non-soft-deleted.
$this->addSql(<<<'SQL'
CREATE UNIQUE INDEX uq_category_name_type_active
ON category (LOWER(name), category_type_id)
WHERE deleted_at IS NULL
SQL);
$this->addSql('CREATE INDEX idx_category_deleted_at ON category (deleted_at)');
$this->addSql('CREATE INDEX idx_category_type_id ON category (category_type_id)');
$this->addSql('CREATE INDEX idx_category_created_by ON category (created_by)');
$this->addSql('CREATE INDEX idx_category_updated_by ON category (updated_by)');
}
public function down(Schema $schema): void
{
// Ordre important : `category` porte les FK vers `category_type`.
$this->addSql('DROP TABLE category');
$this->addSql('DROP TABLE category_type');
}
}
+85
View File
@@ -0,0 +1,85 @@
<?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
{
// Ne commente que les tables deja presentes a ce stade de la chaine de
// migrations. Les modules crees plus tard (ex: M1 Commercial, 06-01)
// figurent desormais dans le catalogue partage mais leurs tables
// n'existent pas encore ici : elles posent leurs propres COMMENT dans
// leur migration dediee (regle ABSOLUE n°12). Garde-fou indispensable,
// sinon l'ajout d'un module au catalogue casse ce retrofit avec un
// "relation X does not exist".
$existingTables = array_values(array_filter(
array_keys(ColumnCommentsCatalog::comments()),
static fn (string $table): bool => $schema->hasTable($table),
));
foreach (ColumnCommentsCatalog::toSqlStatements($existingTables) as $sql) {
$this->addSql($sql);
}
}
public function down(Schema $schema): void
{
foreach (ColumnCommentsCatalog::comments() as $table => $entries) {
// Symetrie avec up() : on n'efface que les commentaires des tables
// presentes (les tables des modules ulterieurs sont gerees par leur
// propre migration).
if (!$schema->hasTable($table)) {
continue;
}
$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).'"',
));
}
}
}
}
+538
View File
@@ -0,0 +1,538 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* M1 Repertoire clients (ERP-53) : creation de toute la structure BDD du
* module Commercial (clients + sous-collections + referentiels comptables).
*
* Tables creees :
* - Referentiels comptables (statiques, seedes ici) : tva_mode, payment_delay,
* payment_type, bank.
* - Table principale : client (formulaire + Information + Comptabilite +
* archive + soft-delete + Timestampable/Blamable).
* - Sous-collections : client_category (M2M), client_contact (1:n),
* client_address (1:n), client_rib (1:n).
* - Jointures de client_address : client_address_site, client_address_contact,
* client_address_category.
*
* Seed `category_type` (extension M0) : DISTRIBUTEUR / COURTIER / SECTEUR /
* AUTRE, en `ON CONFLICT (code) DO NOTHING` (idempotent la table peut deja
* porter des donnees en prod). En dev/test, les fixtures purgent et re-seedent
* ces 4 types (cf. CategoryTypeFixtures) ; ce seed migration couvre la prod ou
* les fixtures ne tournent pas.
*
* Namespace racine `DoctrineMigrations` (regle ABSOLUE Starseed n°11) et NON
* `App\Module\Commercial\...` : avec plusieurs migrations_paths, Doctrine
* Migrations 3.x trie par FQCN alphabetique (AlphabeticalComparator strcmp).
* Un namespace `App\Module\Commercial\...` trierait AVANT `DoctrineMigrations\...`
* et la migration 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 la migration M0 (Version20260527164000) plutot que sur
* le pseudo-SQL de la spec § 3.2 : `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-54).
*
* Decision Q4 (29/05/2026) : unicite metier sur le NOM DE SOCIETE uniquement.
* Pas d'index unique sur siren ni email (RG-1.15 / RG-1.17 supprimees).
*
* Chaque colonne porte un `COMMENT ON COLUMN` (regle ABSOLUE n°12, garde-fou
* ColumnsHaveSqlCommentTest). Les tables n'etant pas encore mappees par l'ORM,
* ces commentaires survivent au `schema:update --force` du setup de test.
*/
final class Version20260601000000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-53 (M1) : tables client + sous-collections + referentiels comptables + seed category_type.';
}
public function up(Schema $schema): void
{
$this->createAccountingReferentials();
$this->createClientTable();
$this->createClientCategory();
$this->createClientContact();
$this->createClientAddress();
$this->createClientAddressJoinTables();
$this->createClientRib();
$this->seedCategoryTypes();
}
public function down(Schema $schema): void
{
// Ordre inverse des dependances FK : on supprime d'abord les jointures
// et sous-collections, puis client, puis les referentiels.
$this->addSql('DROP TABLE client_address_category');
$this->addSql('DROP TABLE client_address_contact');
$this->addSql('DROP TABLE client_address_site');
$this->addSql('DROP TABLE client_rib');
$this->addSql('DROP TABLE client_address');
$this->addSql('DROP TABLE client_contact');
$this->addSql('DROP TABLE client_category');
$this->addSql('DROP TABLE client');
$this->addSql('DROP TABLE bank');
$this->addSql('DROP TABLE payment_type');
$this->addSql('DROP TABLE payment_delay');
$this->addSql('DROP TABLE tva_mode');
// Retire uniquement les 4 types seedes par cette migration. Les autres
// types eventuels (CRUD futur) sont preserves.
$this->addSql(<<<'SQL'
DELETE FROM category_type WHERE code IN ('DISTRIBUTEUR', 'COURTIER', 'SECTEUR', 'AUTRE')
SQL);
}
// =================================================================
// Referentiels comptables (4 tables statiques, memes colonnes)
// =================================================================
private function createAccountingReferentials(): void
{
$referentials = [
'tva_mode' => 'Referentiel des modes de TVA appliques a un client (France, Export, Intracom).',
'payment_delay' => 'Referentiel des delais de reglement (15 jours, 30 jours, a reception).',
'payment_type' => 'Referentiel des types de reglement (virement, LCR, cheque, non soumise).',
'bank' => 'Referentiel des banques selectionnables pour le reglement par virement.',
];
foreach ($referentials as $table => $tableComment) {
$this->addSql(sprintf(<<<'SQL'
CREATE TABLE %s (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
code VARCHAR(30) NOT NULL,
label VARCHAR(120) NOT NULL,
position INT DEFAULT 0 NOT NULL,
PRIMARY KEY (id)
)
SQL, $table));
$this->addSql(sprintf('CREATE UNIQUE INDEX uq_%s_code ON %s (code)', $table, $table));
$this->comment($table, '_table', $tableComment);
$this->comment($table, 'id', 'Identifiant interne auto-incremente.');
$this->comment($table, 'code', 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.');
$this->comment($table, 'label', 'Libelle affichable (FR, ≤ 120 caracteres).');
$this->comment($table, 'position', 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).');
}
// Seed initial (cf. spec § 3.2). Tables fraichement creees donc vides :
// INSERT direct sans ON CONFLICT.
$this->addSql(<<<'SQL'
INSERT INTO tva_mode (code, label, position) VALUES
('FRANCE_VENTES', 'France (ventes)', 10),
('EXPORT_VENTES', 'Export (ventes)', 20),
('INTRACOM_VENTES', 'Intracom (ventes)', 30)
SQL);
$this->addSql(<<<'SQL'
INSERT INTO payment_delay (code, label, position) VALUES
('J15', '15 jours', 10),
('J30', '30 jours', 20),
('A_RECEPTION', 'À réception', 30)
SQL);
$this->addSql(<<<'SQL'
INSERT INTO payment_type (code, label, position) VALUES
('VIREMENT', 'Virement', 10),
('LCR', 'LCR', 20),
('NON_SOUMISE', 'Non soumise', 30),
('CHEQUE', 'Chèque', 40)
SQL);
$this->addSql(<<<'SQL'
INSERT INTO bank (code, label, position) VALUES
('SG', 'Société Générale', 10),
('CIC', 'CIC', 20),
('CA', 'Crédit Agricole', 30)
SQL);
}
// =================================================================
// Table principale `client`
// =================================================================
private function createClientTable(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE client (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
company_name VARCHAR(180) NOT NULL,
first_name VARCHAR(120) DEFAULT NULL,
last_name VARCHAR(120) DEFAULT NULL,
phone_primary VARCHAR(20) NOT NULL,
phone_secondary VARCHAR(20) DEFAULT NULL,
email VARCHAR(180) NOT NULL,
distributor_id INT DEFAULT NULL,
broker_id INT DEFAULT NULL,
triage_service BOOLEAN DEFAULT FALSE NOT NULL,
description TEXT DEFAULT NULL,
competitors VARCHAR(255) DEFAULT NULL,
founded_at DATE DEFAULT NULL,
employees_count INT DEFAULT NULL,
revenue_amount NUMERIC(15, 2) DEFAULT NULL,
director_name VARCHAR(120) DEFAULT NULL,
profit_amount NUMERIC(15, 2) DEFAULT 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 chk_client_distrib_or_broker
CHECK (NOT (distributor_id IS NOT NULL AND broker_id IS NOT NULL)),
CONSTRAINT fk_client_distributor
FOREIGN KEY (distributor_id) REFERENCES client (id) ON DELETE SET NULL,
CONSTRAINT fk_client_broker
FOREIGN KEY (broker_id) REFERENCES client (id) ON DELETE SET NULL,
CONSTRAINT fk_client_tva_mode
FOREIGN KEY (tva_mode_id) REFERENCES tva_mode (id) ON DELETE RESTRICT,
CONSTRAINT fk_client_payment_delay
FOREIGN KEY (payment_delay_id) REFERENCES payment_delay (id) ON DELETE RESTRICT,
CONSTRAINT fk_client_payment_type
FOREIGN KEY (payment_type_id) REFERENCES payment_type (id) ON DELETE RESTRICT,
CONSTRAINT fk_client_bank
FOREIGN KEY (bank_id) REFERENCES bank (id) ON DELETE RESTRICT,
CONSTRAINT fk_client_created_by
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
CONSTRAINT fk_client_updated_by
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
)
SQL);
$this->addSql('CREATE INDEX idx_client_is_archived ON client (is_archived)');
$this->addSql('CREATE INDEX idx_client_deleted_at ON client (deleted_at)');
$this->addSql('CREATE INDEX idx_client_distributor_id ON client (distributor_id)');
$this->addSql('CREATE INDEX idx_client_broker_id ON client (broker_id)');
$this->addSql('CREATE INDEX idx_client_created_by ON client (created_by)');
$this->addSql('CREATE INDEX idx_client_updated_by ON client (updated_by)');
// Unicite metier partielle (Q4) : nom de societe insensible a la casse,
// parmi les non-archives ET non soft-deletes uniquement. Pas d'index
// unique sur siren ni email.
$this->addSql(<<<'SQL'
CREATE UNIQUE INDEX uq_client_company_name_active
ON client (LOWER(company_name))
WHERE is_archived = FALSE AND deleted_at IS NULL
SQL);
$this->comment('client', '_table', 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).');
$this->comment('client', 'id', 'Identifiant interne auto-incremente.');
$this->comment('client', 'company_name', 'Raison sociale (stockee en MAJUSCULES, RG-1.18). Unique case-insensitive parmi les actifs non archives/non supprimes (RG-1.16, uq_client_company_name_active).');
$this->comment('client', 'first_name', 'Prenom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).');
$this->comment('client', 'last_name', 'Nom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).');
$this->comment('client', 'phone_primary', 'Telephone principal — stocke en chiffres uniquement (RG-1.20). Obligatoire.');
$this->comment('client', 'phone_secondary', 'Telephone secondaire optionnel — chiffres uniquement (RG-1.20).');
$this->comment('client', 'email', 'Email principal (lowercase serveur, RG-1.21). NON unique (RG-1.17 supprimee, Q4).');
$this->comment('client', 'distributor_id', 'FK auto-referente vers un client porteur de la categorie DISTRIBUTEUR — exclusive avec broker_id (RG-1.03, chk_client_distrib_or_broker). FK -> client.id, ON DELETE SET NULL.');
$this->comment('client', 'broker_id', 'FK auto-referente vers un client porteur de la categorie COURTIER — exclusive avec distributor_id (RG-1.03). FK -> client.id, ON DELETE SET NULL.');
$this->comment('client', 'triage_service', 'Drapeau service triage active pour le client. Faux par defaut.');
$this->comment('client', 'description', 'Onglet Information : description libre. Obligatoire pour le role Commerciale (RG-1.04), optionnel sinon.');
$this->comment('client', 'competitors', 'Onglet Information : concurrents identifies (texte libre ≤ 255). Obligatoire role Commerciale (RG-1.04).');
$this->comment('client', 'founded_at', 'Onglet Information : date de creation de l entreprise. Obligatoire role Commerciale (RG-1.04).');
$this->comment('client', 'employees_count', 'Onglet Information : effectif (entier >= 0). Obligatoire role Commerciale (RG-1.04).');
$this->comment('client', 'revenue_amount', 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).');
$this->comment('client', 'director_name', 'Onglet Information : nom du dirigeant. Obligatoire role Commerciale (RG-1.04).');
$this->comment('client', 'profit_amount', 'Onglet Information : resultat / benefice (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).');
$this->comment('client', 'siren', 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-1.15 supprimee, Q4).');
$this->comment('client', 'account_number', 'Onglet Comptabilite : numero de compte comptable du client.');
$this->comment('client', 'tva_mode_id', 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id, ON DELETE RESTRICT.');
$this->comment('client', 'n_tva', 'Onglet Comptabilite : numero de TVA intracommunautaire.');
$this->comment('client', 'payment_delay_id', 'Onglet Comptabilite : delai de reglement — FK -> payment_delay.id, ON DELETE RESTRICT.');
$this->comment('client', 'payment_type_id', 'Onglet Comptabilite : type de reglement — FK -> payment_type.id, ON DELETE RESTRICT. Code LCR impose >= 1 RIB (RG-1.13), VIREMENT impose une banque (RG-1.12).');
$this->comment('client', 'bank_id', 'Onglet Comptabilite : banque — FK -> bank.id, ON DELETE RESTRICT. Obligatoire si payment_type = VIREMENT (RG-1.12).');
$this->comment('client', 'is_archived', 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission commercial.clients.archive (RG-1.22/23).');
$this->comment('client', 'archived_at', 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration (RG-1.22/23).');
$this->comment('client', 'deleted_at', 'Horodatage du soft-delete technique (HP M2) — non expose par l API au M1. Null = ligne active.');
$this->addTimestampableBlamableComments('client');
}
// =================================================================
// M2M client <-> category
// =================================================================
private function createClientCategory(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE client_category (
client_id INT NOT NULL,
category_id INT NOT NULL,
PRIMARY KEY (client_id, category_id),
CONSTRAINT fk_client_category_client
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE,
CONSTRAINT fk_client_category_category
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT
)
SQL);
$this->addSql('CREATE INDEX idx_client_category_category ON client_category (category_id)');
$this->comment('client_category', '_table', 'Jointure M2M client <-> category (Catalog) — categories metier du client (au moins une obligatoire).');
$this->comment('client_category', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client porteur de la categorie.');
$this->comment('client_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie rattachee au client.');
}
// =================================================================
// Sous-collection : contacts (1:n)
// =================================================================
private function createClientContact(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE client_contact (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
client_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_client_contact_name
CHECK (first_name IS NOT NULL OR last_name IS NOT NULL),
CONSTRAINT fk_client_contact_client
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE,
CONSTRAINT fk_client_contact_created_by
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
CONSTRAINT fk_client_contact_updated_by
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
)
SQL);
$this->addSql('CREATE INDEX idx_client_contact_client ON client_contact (client_id)');
$this->comment('client_contact', '_table', 'Contacts d un client (1:n) — au moins firstName OU lastName par contact (RG-1.05).');
$this->comment('client_contact', 'id', 'Identifiant interne auto-incremente.');
$this->comment('client_contact', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client proprietaire du contact.');
$this->comment('client_contact', 'first_name', 'Prenom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-1.05, chk_client_contact_name).');
$this->comment('client_contact', 'last_name', 'Nom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-1.05, chk_client_contact_name).');
$this->comment('client_contact', 'job_title', 'Fonction / intitule de poste du contact (≤ 120 caracteres).');
$this->comment('client_contact', 'phone_primary', 'Telephone principal du contact — chiffres uniquement (RG-1.20).');
$this->comment('client_contact', 'phone_secondary', 'Telephone secondaire du contact — chiffres uniquement (RG-1.20).');
$this->comment('client_contact', 'email', 'Email du contact (lowercase serveur, RG-1.21).');
$this->comment('client_contact', 'position', 'Ordre d affichage du contact dans la liste du client (croissant).');
$this->addTimestampableBlamableComments('client_contact');
}
// =================================================================
// Sous-collection : adresses (1:n)
// =================================================================
private function createClientAddress(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE client_address (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
client_id INT NOT NULL,
is_prospect BOOLEAN DEFAULT FALSE NOT NULL,
is_delivery BOOLEAN DEFAULT FALSE NOT NULL,
is_billing BOOLEAN DEFAULT FALSE 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,
billing_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_client_address_prospect_exclusive
CHECK (NOT (is_prospect = TRUE AND (is_delivery = TRUE OR is_billing = TRUE))),
CONSTRAINT chk_client_address_billing_email
CHECK ((is_billing = FALSE AND billing_email IS NULL)
OR (is_billing = TRUE AND billing_email IS NOT NULL)),
CONSTRAINT fk_client_address_client
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE,
CONSTRAINT fk_client_address_created_by
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
CONSTRAINT fk_client_address_updated_by
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
)
SQL);
$this->addSql('CREATE INDEX idx_client_address_client ON client_address (client_id)');
$this->comment('client_address', '_table', 'Adresses d un client (1:n) — prospect exclusif de livraison/facturation (RG-1.06/07/08), >= 1 site rattache (RG-1.10).');
$this->comment('client_address', 'id', 'Identifiant interne auto-incremente.');
$this->comment('client_address', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client proprietaire de l adresse.');
$this->comment('client_address', 'is_prospect', 'Adresse de prospection — exclusive de is_delivery/is_billing (RG-1.06/07/08, chk_client_address_prospect_exclusive). Faux par defaut.');
$this->comment('client_address', 'is_delivery', 'Adresse de livraison. Exclusive de is_prospect. Faux par defaut.');
$this->comment('client_address', 'is_billing', 'Adresse de facturation. Exclusive de is_prospect. Impose billing_email (RG-1.11). Faux par defaut.');
$this->comment('client_address', 'country', 'Pays de l adresse — defaut France.');
$this->comment('client_address', 'postal_code', 'Code postal (4-5 chiffres attendus, RG-1.09).');
$this->comment('client_address', 'city', 'Ville — preremplie depuis le code postal via API BAN cote front (RG-1.09).');
$this->comment('client_address', 'street', 'Numero et voie de l adresse.');
$this->comment('client_address', 'street_complement', 'Complement d adresse (etage, batiment...) — optionnel.');
$this->comment('client_address', 'billing_email', 'Email de facturation — obligatoire si is_billing, null sinon (RG-1.11, chk_client_address_billing_email).');
$this->comment('client_address', 'position', 'Ordre d affichage de l adresse dans la liste du client (croissant).');
$this->addTimestampableBlamableComments('client_address');
}
// =================================================================
// Jointures de client_address (M2M)
// =================================================================
private function createClientAddressJoinTables(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE client_address_site (
client_address_id INT NOT NULL,
site_id INT NOT NULL,
PRIMARY KEY (client_address_id, site_id),
CONSTRAINT fk_client_address_site_address
FOREIGN KEY (client_address_id) REFERENCES client_address (id) ON DELETE CASCADE,
CONSTRAINT fk_client_address_site_site
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT
)
SQL);
$this->comment('client_address_site', '_table', 'Jointure M2M client_address <-> site (Sites) — sites desservis par l adresse (>= 1 obligatoire, RG-1.10).');
$this->comment('client_address_site', 'client_address_id', 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.');
$this->comment('client_address_site', 'site_id', 'FK -> site.id, ON DELETE RESTRICT — site rattache a l adresse.');
$this->addSql(<<<'SQL'
CREATE TABLE client_address_contact (
client_address_id INT NOT NULL,
client_contact_id INT NOT NULL,
PRIMARY KEY (client_address_id, client_contact_id),
CONSTRAINT fk_client_address_contact_address
FOREIGN KEY (client_address_id) REFERENCES client_address (id) ON DELETE CASCADE,
CONSTRAINT fk_client_address_contact_contact
FOREIGN KEY (client_contact_id) REFERENCES client_contact (id) ON DELETE CASCADE
)
SQL);
$this->comment('client_address_contact', '_table', 'Jointure M2M client_address <-> client_contact — contacts associes a une adresse.');
$this->comment('client_address_contact', 'client_address_id', 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.');
$this->comment('client_address_contact', 'client_contact_id', 'FK -> client_contact.id, ON DELETE CASCADE — contact associe a l adresse.');
$this->addSql(<<<'SQL'
CREATE TABLE client_address_category (
client_address_id INT NOT NULL,
category_id INT NOT NULL,
PRIMARY KEY (client_address_id, category_id),
CONSTRAINT fk_client_address_category_address
FOREIGN KEY (client_address_id) REFERENCES client_address (id) ON DELETE CASCADE,
CONSTRAINT fk_client_address_category_category
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT
)
SQL);
$this->comment('client_address_category', '_table', 'Jointure M2M client_address <-> category — categories d adresse (types SECTEUR/AUTRE uniquement, RG-1.29).');
$this->comment('client_address_category', 'client_address_id', 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.');
$this->comment('client_address_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse (type SECTEUR ou AUTRE, RG-1.29).');
}
// =================================================================
// Sous-collection : RIB (1:n)
// =================================================================
private function createClientRib(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE client_rib (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
client_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_client_rib_client
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE,
CONSTRAINT fk_client_rib_created_by
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
CONSTRAINT fk_client_rib_updated_by
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
)
SQL);
$this->addSql('CREATE INDEX idx_client_rib_client ON client_rib (client_id)');
$this->comment('client_rib', '_table', 'Coordonnees bancaires d un client (1:n) — >= 1 RIB obligatoire si payment_type = LCR (RG-1.13). Tous les champs audites (pas d AuditIgnore).');
$this->comment('client_rib', 'id', 'Identifiant interne auto-incremente.');
$this->comment('client_rib', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client proprietaire du RIB.');
$this->comment('client_rib', 'label', 'Libelle du RIB (ex: compte principal).');
$this->comment('client_rib', 'bic', 'Code BIC/SWIFT de la banque (8 ou 11 caracteres).');
$this->comment('client_rib', 'iban', 'IBAN du compte (≤ 34 caracteres).');
$this->comment('client_rib', 'position', 'Ordre d affichage du RIB dans la liste du client (croissant).');
$this->addTimestampableBlamableComments('client_rib');
}
// =================================================================
// Seed extension category_type (M0)
// =================================================================
private function seedCategoryTypes(): void
{
// Idempotent : la table category_type peut deja porter des donnees en
// prod. ON CONFLICT (code) s appuie sur l index unique uq_category_type_code.
// NB : la table M0 n a pas de colonne `position` (id/code/label seulement),
// contrairement au pseudo-SQL de la spec § 3.3.
$this->addSql(<<<'SQL'
INSERT INTO category_type (code, label) VALUES
('DISTRIBUTEUR', 'Distributeur'),
('COURTIER', 'Courtier'),
('SECTEUR', 'Secteur'),
('AUTRE', 'Autre')
ON CONFLICT (code) DO NOTHING
SQL);
}
// =================================================================
// Helpers
// =================================================================
/**
* Pose les 4 commentaires standardises Timestampable/Blamable sur une table,
* en reutilisant le catalogue partage (source unique, cf. ERP-67).
*/
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,
));
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog;
final class CatalogModule
{
public const string ID = 'catalog';
public const string LABEL = 'Catalogue';
// REQUIRED = true : Category sera FK NOT NULL cote futurs modules Tiers
// (M-Clients, M-Fournisseurs, M-Prestataires). Desactiver Catalog casserait
// tout le metier au boot Doctrine. Cf. review Tristan MR #12 + spec M0 § 2.1.
public const bool REQUIRED = true;
/**
* Liste declarative des permissions RBAC exposees par le module Catalog.
*
* 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 Core (view + manage), pas view/create/edit/delete
* (cf. spec M0 § 2.7).
*
* @return array<int, array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'catalog.categories.view', 'label' => 'Voir les categories'],
['code' => 'catalog.categories.manage', 'label' => 'Gerer les categories (creer, editer, supprimer)'],
];
}
}
@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Processor\CategoryProcessor;
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\CategoryProvider;
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Categorie : referentiel metier classifiant les futurs tiers (clients,
* fournisseurs, prestataires). Porte un `name` libre et un `categoryType`
* (FK vers le referentiel statique CategoryType).
*
* - Soft delete via `deletedAt` (pas de hard delete) : la liste exclut par
* defaut les categories supprimees (cf. CategoryProvider, ticket 0.3).
* - Timestampable + Blamable via le Trait Shared : les 4 colonnes
* created_at / updated_at / created_by / updated_by sont remplies
* automatiquement par le TimestampableBlamableSubscriber.
* - `#[Auditable]` : chaque create / update / delete (soft) est trace dans
* audit_log par l'AuditListener du module Core.
*
* Provider (filtre soft-delete + ?includeDeleted + tri name ASC + 404 sur
* soft-deleted) et Processor (trim, 409 sur doublon, soft delete) branches
* au ticket 0.3 (ERP-45).
*/
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('catalog.categories.view')",
normalizationContext: ['groups' => ['category:read', 'default:read']],
provider: CategoryProvider::class,
),
new Get(
security: "is_granted('catalog.categories.view')",
normalizationContext: ['groups' => ['category:read', 'default:read']],
provider: CategoryProvider::class,
),
new Post(
security: "is_granted('catalog.categories.manage')",
normalizationContext: ['groups' => ['category:read', 'default:read']],
denormalizationContext: ['groups' => ['category:write']],
processor: CategoryProcessor::class,
),
new Patch(
security: "is_granted('catalog.categories.manage')",
normalizationContext: ['groups' => ['category:read', 'default:read']],
denormalizationContext: ['groups' => ['category:write']],
provider: CategoryProvider::class,
processor: CategoryProcessor::class,
),
new Delete(
security: "is_granted('catalog.categories.manage')",
provider: CategoryProvider::class,
processor: CategoryProcessor::class,
),
],
)]
#[ORM\Entity(repositoryClass: DoctrineCategoryRepository::class)]
#[ORM\Table(name: 'category')]
// Index nommes pour matcher la migration (cf. Role/Permission/Site). L'index
// unique partiel `uq_category_name_type_active` (LOWER(name), category_type_id
// WHERE deleted_at IS NULL) reste possede par la seule migration : Doctrine ORM
// ne sait pas exprimer un index fonctionnel + partiel via attribut.
#[ORM\Index(name: 'idx_category_deleted_at', columns: ['deleted_at'])]
#[ORM\Index(name: 'idx_category_type_id', columns: ['category_type_id'])]
#[ORM\Index(name: 'idx_category_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_category_updated_by', columns: ['updated_by'])]
#[Auditable]
class Category implements TimestampableInterface, BlamableInterface, CategoryInterface
{
// === Timestampable + Blamable ===
// Les 4 colonnes (created_at, updated_at, created_by, updated_by) + leurs
// getters/setters viennent du Trait Shared. Le TimestampableBlamableSubscriber
// les remplit automatiquement au prePersist / preUpdate. Aucune redeclaration
// manuelle de ces proprietes ici.
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['category:read'])]
private ?int $id = null;
// RG-1.02 + RG-1.03 : un name compose uniquement d'espaces doit declencher
// NotBlank. Le normalizer 'trim' fait le menage avant validation, alignant
// le comportement sur le trim cote Processor (qui s'applique apres) : ainsi
// POST {name: " "} -> 422 et POST {name: " Vis "} -> 201 avec "Vis"
// persiste, sans contradiction entre l'ordre Validate / Process.
#[ORM\Column(length: 120)]
#[Assert\NotBlank(message: 'Le nom est obligatoire.', normalizer: 'trim')]
#[Assert\Length(min: 2, max: 120, normalizer: 'trim')]
#[Groups(['category:read', 'category:write'])]
private ?string $name = null;
#[ORM\ManyToOne(targetEntity: CategoryType::class)]
#[ORM\JoinColumn(name: 'category_type_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
#[Assert\NotNull(message: 'Type de catégorie obligatoire.')]
#[Groups(['category:read', 'category:write'])]
private ?CategoryType $categoryType = null;
/**
* Soft delete : null = active, valeur = supprimee logiquement le {date}.
* Pas exposee en ecriture (groupe category:write exclu) : seul le DELETE,
* via le CategoryProcessor (ticket 0.3), pose la valeur.
*/
#[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
#[Groups(['category:read'])]
private ?DateTimeImmutable $deletedAt = null;
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getCategoryType(): ?CategoryType
{
return $this->categoryType;
}
public function setCategoryType(?CategoryType $categoryType): static
{
$this->categoryType = $categoryType;
return $this;
}
/**
* Implemente CategoryInterface : code du type rattache (ou null). Permet
* aux modules tiers de filtrer/valider par type metier sans dependre de
* Catalog.
*/
public function getCategoryTypeCode(): ?string
{
return $this->categoryType?->getCode();
}
public function getDeletedAt(): ?DateTimeImmutable
{
return $this->deletedAt;
}
public function setDeletedAt(?DateTimeImmutable $deletedAt): static
{
$this->deletedAt = $deletedAt;
return $this;
}
}
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryTypeRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Type de categorie : referentiel statique classifiant les Category
* (ex: MATIERE, PRODUIT, SERVICE). Cree vide par la migration M0 ; son seed
* initial et son CRUD admin sont hors perimetre M0 (cf. spec-back § 9 HP-1).
*
* Lecture seule au M0 : seules les operations GetCollection et Get sont
* exposees, sous la meme permission que Category (catalog.categories.view).
*
* Referentiel statique : pas de Timestampable/Blamable (whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe `category:read`
* est ajoute sur chaque propriete pour que le type soit embarque dans la
* reponse d'une Category (cf. .claude/rules/backend.md § Serialization).
*/
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('catalog.categories.view')",
normalizationContext: ['groups' => ['category_type:read']],
// Tri par defaut requis par la spec M0 § 4.6 : ordre alphabetique
// stable pour alimenter le <MalioSelect> du formulaire Category.
order: ['label' => 'ASC'],
),
new Get(
security: "is_granted('catalog.categories.view')",
normalizationContext: ['groups' => ['category_type:read']],
),
],
)]
#[ORM\Entity(repositoryClass: DoctrineCategoryTypeRepository::class)]
#[ORM\Table(name: 'category_type')]
// Contrainte d'unicite nommee pour matcher la migration (cf. Role/Permission).
#[ORM\UniqueConstraint(name: 'uq_category_type_code', columns: ['code'])]
class CategoryType
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['category_type:read', 'category:read'])]
private ?int $id = null;
#[ORM\Column(length: 40)]
#[Groups(['category_type:read', 'category:read'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
#[Groups(['category_type:read', 'category:read'])]
private ?string $label = null;
public function getId(): ?int
{
return $this->id;
}
public function getCode(): ?string
{
return $this->code;
}
public function setCode(string $code): static
{
$this->code = $code;
return $this;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(string $label): static
{
$this->label = $label;
return $this;
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Domain\Repository;
use App\Module\Catalog\Domain\Entity\Category;
use Doctrine\ORM\QueryBuilder;
interface CategoryRepositoryInterface
{
public function findById(int $id): ?Category;
public function save(Category $category): void;
/**
* Construit un QueryBuilder de liste avec filtre soft-delete et tri par defaut.
* - $includeDeleted = false : exclut les categories soft-deleted (RG-1.08)
* - Tri : name ASC (RG-1.10).
*/
public function createListQueryBuilder(bool $includeDeleted = false): QueryBuilder;
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Domain\Repository;
use App\Module\Catalog\Domain\Entity\CategoryType;
interface CategoryTypeRepositoryInterface
{
public function findById(int $id): ?CategoryType;
/**
* @return list<CategoryType>
*/
public function findAllOrderedByLabel(): array;
}
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Catalog\Domain\Entity\Category;
use DateTimeImmutable;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\HttpException;
/**
* Processor Category : applique les regles de gestion en ecriture.
*
* - POST / PATCH : trim du nom (RG-1.03) puis delegation au persist_processor
* Doctrine ORM. Toute UniqueConstraintViolationException remontee par Postgres
* (collision sur l'index partiel uq_category_name_type_active) est traduite
* en HTTP 409 avec le message attendu par la spec (RG-1.07).
* - DELETE : soft delete (RG-1.12). On NE delegue PAS au remove_processor ;
* on pose deletedAt = now() puis on delegue au persist_processor pour que
* le UPDATE Doctrine parte et que le TimestampableBlamableSubscriber mette
* a jour updatedAt / updatedBy (RG-1.16) en plus de l'AuditListener.
*
* @implements ProcessorInterface<Category, null|Category>
*/
final class CategoryProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof Category) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
// RG-1.12 : soft delete au lieu d'un remove physique. On bascule la DELETE
// en UPDATE en posant deletedAt, puis on persiste via le persist_processor.
if ($operation instanceof DeleteOperationInterface) {
$data->setDeletedAt(new DateTimeImmutable());
try {
$this->persistProcessor->process($data, $operation, $uriVariables, $context);
} catch (UniqueConstraintViolationException $e) {
// Par construction, le soft delete ne peut pas violer l'index
// partiel (il LIBERE le couple (name, type) au lieu de le creer).
// On laisse remonter en 500 pour signaler une anomalie reelle.
throw $e;
}
return null;
}
// POST / PATCH : trim du nom avant validation et persistance (RG-1.03).
if (null !== $data->getName()) {
$data->setName(trim($data->getName()));
}
try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} catch (UniqueConstraintViolationException $e) {
// RG-1.07 : doublon (LOWER(name), category_type_id) parmi les non-soft-deleted.
throw new HttpException(
409,
sprintf('Une catégorie nommée "%s" existe déjà pour ce type.', $data->getName() ?? ''),
$e,
);
}
}
}
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Module\Catalog\Domain\Entity\Category;
use App\Module\Catalog\Domain\Repository\CategoryRepositoryInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provider Category : applique le filtre soft-delete par defaut (RG-1.08),
* accepte ?includeDeleted=true pour inclure les soft-deleted (RG-1.09),
* trie par name ASC (RG-1.10), et renvoie 404 sur Get d'une soft-deleted
* sans le flag (RG-1.11, via retour null).
*
* Choix d'implementation : QueryBuilder via le repository custom plutot
* qu'un filtre Doctrine global (cf. spec § 2.3 et arbitrage ticket 0.3).
* Avantage : pas de magie globale, lisibilite directe du code, controle
* fin du flag includeDeleted par requete.
*
* @implements ProviderInterface<Category>
*/
final class CategoryProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryRepository')]
private readonly CategoryRepositoryInterface $repository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Category|iterable|null
{
$includeDeleted = $this->readIncludeDeleted($context);
if ($operation instanceof CollectionOperationInterface) {
return $this->repository
->createListQueryBuilder($includeDeleted)
->getQuery()
->getResult()
;
}
// Get unitaire : recharger l'entite, puis appliquer le filtre soft-delete.
$id = $uriVariables['id'] ?? null;
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
return null;
}
$category = $this->repository->findById((int) $id);
if (null === $category) {
return null;
}
// RG-1.11 : 404 si soft-deleted et pas de flag includeDeleted.
if (!$includeDeleted && null !== $category->getDeletedAt()) {
return null;
}
return $category;
}
/**
* Lit le flag includeDeleted depuis les filtres API Platform.
* Accepte "true" / "1" / true (booleen).
*/
private function readIncludeDeleted(array $context): bool
{
$raw = $context['filters']['includeDeleted'] ?? false;
if (is_bool($raw)) {
return $raw;
}
if (is_string($raw)) {
return in_array(strtolower($raw), ['true', '1'], true);
}
return false;
}
}
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Infrastructure\DataFixtures;
use App\Module\Catalog\Domain\Entity\CategoryType;
use App\Module\Catalog\Domain\Repository\CategoryTypeRepositoryInterface;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
/**
* Fixtures du module Catalog : seed des types de categorie metier (M1).
*
* La table `category_type` est creee vide au M0 ; le M1 la peuple avec les 4
* types DISTRIBUTEUR / COURTIER / SECTEUR / AUTRE (cf. spec M1 § 3.3).
*
* Pourquoi une fixture EN PLUS du seed de la migration (Version20260601000000) :
* `category_type` est une entite managee par l ORM, donc le purger Doctrine la
* vide avant chaque `doctrine:fixtures:load`. Sans cette fixture, les 4 types
* seedes par la migration disparaitraient apres `make db-reset` / setup de test.
* Le seed migration couvre la prod (ou les fixtures ne tournent pas) ; cette
* fixture re-aligne dev et test. Les deux chemins produisent un etat identique.
*
* Idempotence : lookup par `code` parmi les types existants avant insertion,
* sur le modele d AppFixtures::ensureSystemRole. Rejouable sans doublon meme
* si le purger est desactive.
*/
class CategoryTypeFixtures extends Fixture
{
/**
* Source unique des 4 types metier : code technique => libelle FR.
* Doit rester aligne sur le seed de la migration Version20260601000000.
*/
private const TYPES = [
'DISTRIBUTEUR' => 'Distributeur',
'COURTIER' => 'Courtier',
'SECTEUR' => 'Secteur',
'AUTRE' => 'Autre',
];
public function __construct(
private readonly CategoryTypeRepositoryInterface $categoryTypeRepository,
) {}
public function load(ObjectManager $manager): void
{
// Index des types deja presents par code, pour ne pas creer de doublon.
$existingByCode = [];
foreach ($this->categoryTypeRepository->findAllOrderedByLabel() as $type) {
$existingByCode[$type->getCode()] = $type;
}
foreach (self::TYPES as $code => $label) {
$type = $existingByCode[$code] ?? new CategoryType();
$type->setCode($code);
$type->setLabel($label);
$manager->persist($type);
}
$manager->flush();
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Infrastructure\Doctrine;
use App\Module\Catalog\Domain\Entity\Category;
use App\Module\Catalog\Domain\Repository\CategoryRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Category>
*/
class DoctrineCategoryRepository extends ServiceEntityRepository implements CategoryRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Category::class);
}
public function findById(int $id): ?Category
{
return $this->find($id);
}
public function save(Category $category): void
{
$this->getEntityManager()->persist($category);
$this->getEntityManager()->flush();
}
public function createListQueryBuilder(bool $includeDeleted = false): QueryBuilder
{
$qb = $this->createQueryBuilder('c')
->orderBy('c.name', 'ASC')
;
if (!$includeDeleted) {
$qb->andWhere('c.deletedAt IS NULL');
}
return $qb;
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Infrastructure\Doctrine;
use App\Module\Catalog\Domain\Entity\CategoryType;
use App\Module\Catalog\Domain\Repository\CategoryTypeRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<CategoryType>
*/
class DoctrineCategoryTypeRepository extends ServiceEntityRepository implements CategoryTypeRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, CategoryType::class);
}
public function findById(int $id): ?CategoryType
{
return $this->find($id);
}
/**
* @return list<CategoryType>
*/
public function findAllOrderedByLabel(): array
{
return $this->findBy([], ['label' => 'ASC']);
}
}
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Application\Service;
/**
* Normalisation serveur des champs texte d'un Client / ClientContact, appliquee
* par le ClientProcessor (et plus tard le ClientContactProcessor) AVANT
* persistance. Cf. spec-back M1 § 2.9 + RG-1.18 a RG-1.21.
*
* - companyName : UPPERCASE integral (RG-1.18)
* - firstName / lastName (personnes) : Title Case (RG-1.19)
* - phone* : chiffres uniquement, ex "06.12.34.56.78" -> "0612345678" (RG-1.20).
* Le formatage d'affichage "XX XX XX XX XX" est de la responsabilite du front.
* - email : lowercase integral (RG-1.21)
*
* Toutes les methodes sont null-safe et trim-ent l'entree ; une chaine vide
* apres trim devient null (evite de persister "" dans des colonnes nullable).
*/
final class ClientFieldNormalizer
{
/**
* Nom de societe en majuscules (RG-1.18). Conserve null tel quel ; une
* chaine non vide est trim + upper. Une chaine vide reste "" (champ
* obligatoire : c'est l'Assert\NotBlank qui rejette, pas le normalizer).
*/
public function normalizeCompanyName(?string $value): ?string
{
if (null === $value) {
return null;
}
return mb_strtoupper(trim($value), 'UTF-8');
}
/**
* Nom/prenom de personne en Title Case (RG-1.19) : "JEAN dupont" ->
* "Jean Dupont". Une chaine vide apres trim devient null.
*/
public function normalizePersonName(?string $value): ?string
{
if (null === $value) {
return null;
}
$value = trim($value);
return '' === $value ? null : mb_convert_case($value, MB_CASE_TITLE, 'UTF-8');
}
/**
* Email en minuscules (RG-1.21). Une chaine vide apres trim devient null.
*/
public function normalizeEmail(?string $value): ?string
{
if (null === $value) {
return null;
}
$value = trim($value);
return '' === $value ? null : mb_strtolower($value, 'UTF-8');
}
/**
* Telephone reduit aux chiffres (RG-1.20) : "06.12.34.56.78" ->
* "0612345678". Une valeur sans aucun chiffre devient null.
*/
public function normalizePhone(?string $value): ?string
{
if (null === $value) {
return null;
}
$digits = preg_replace('/\D+/', '', $value) ?? '';
return '' === $digits ? null : $digits;
}
}
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Application\Validator;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Commercial\Domain\Entity\Client;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Validator metier RG-1.04 : pour un utilisateur portant le role metier
* Commerciale, TOUS les champs de l'onglet Information deviennent obligatoires
* lors d'un PATCH touchant le groupe `client:write:information`.
*
* Invoque par le ClientProcessor UNIQUEMENT quand les deux conditions sont
* reunies (role Commerciale + payload touchant l'onglet Information). Pour les
* autres roles, ces champs restent optionnels le validator n'est pas appele.
*
* Tant qu'aucun user ne porte le role `commerciale` (seede par ERP-74,
* cf. App\Shared\Domain\Security\BusinessRoles::COMMERCIALE), cette regle reste
* DORMANTE : aucun appelant ne la declenche.
*
* Leve une ValidationException (HTTP 422) listant chaque champ manquant, par
* coherence avec les violations Symfony rendues par API Platform.
*/
final class ClientInformationCompletenessValidator
{
public function validate(Client $client): void
{
// Map champ -> valeur courante de l'onglet Information.
$fields = [
'description' => $client->getDescription(),
'competitors' => $client->getCompetitors(),
'foundedAt' => $client->getFoundedAt(),
'employeesCount' => $client->getEmployeesCount(),
'revenueAmount' => $client->getRevenueAmount(),
'directorName' => $client->getDirectorName(),
'profitAmount' => $client->getProfitAmount(),
];
$violations = new ConstraintViolationList();
foreach ($fields as $property => $value) {
if ($this->isMissing($value)) {
$violations->add(new ConstraintViolation(
sprintf('Ce champ est obligatoire pour le role Commerciale (champ "%s").', $property),
null,
[],
$client,
$property,
$value,
));
}
}
if (count($violations) > 0) {
throw new ValidationException($violations);
}
}
/**
* Une valeur est manquante si null ou, pour une chaine, vide apres trim.
* Les zeros numeriques (employeesCount = 0, profitAmount = "0.00") sont des
* valeurs valides : on ne les considere pas manquants.
*/
private function isMissing(mixed $value): bool
{
if (null === $value) {
return true;
}
return is_string($value) && '' === trim($value);
}
}
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Entity;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineBankRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Banque selectionnable pour le reglement par virement (Societe Generale,
* CIC, Credit Agricole) : referentiel statique seede par la migration M1 et
* re-seede en dev/test par CommercialReferentialFixtures.
*
* Lecture seule au M1 (HP-M2-2). Pas de Timestampable/Blamable (referentiel
* statique whiteliste dans EntitiesAreTimestampableBlamableTest::EXCLUDED). Le
* groupe `client:read:accounting` permet l'embarquement dans la reponse Client.
*/
#[ORM\Entity(repositoryClass: DoctrineBankRepository::class)]
#[ORM\Table(name: 'bank')]
#[ORM\UniqueConstraint(name: 'uq_bank_code', columns: ['code'])]
class Bank
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['bank:read', 'client:read:accounting'])]
private ?int $id = null;
#[ORM\Column(length: 30)]
#[Groups(['bank:read', 'client:read:accounting'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
#[Groups(['bank:read', 'client:read:accounting'])]
private ?string $label = null;
#[ORM\Column(options: ['default' => 0])]
#[Groups(['bank:read'])]
private int $position = 0;
public function getId(): ?int
{
return $this->id;
}
public function getCode(): ?string
{
return $this->code;
}
public function setCode(string $code): static
{
$this->code = $code;
return $this;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(string $label): static
{
$this->label = $label;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
}
@@ -0,0 +1,711 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\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\Infrastructure\ApiPlatform\State\Processor\ClientProcessor;
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Provider\ClientProvider;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\CategoryInterface;
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;
/**
* Client (M1 Commercial) entite racine du repertoire clients. Porte le
* formulaire principal, l'onglet Information, l'onglet Comptabilite, le
* mecanisme d'archivage (is_archived / archived_at) et le soft-delete technique
* prepare mais non expose au M1 (deleted_at, HP-M2-1).
*
* Decisions structurantes :
* - Audit complet (#[Auditable]) sur tous les champs (M2M categories audite
* automatiquement). Timestampable/Blamable via le trait Shared.
* - PAS de #[ORM\UniqueConstraint] : l'unicite du nom de societe (RG-1.16) est
* portee par l'index partiel fonctionnel uq_client_company_name_active
* (LOWER(company_name) WHERE is_archived = FALSE AND deleted_at IS NULL),
* inexprimable en attribut ORM, donc possede par la seule migration. Le SIREN
* et l'email NE SONT PAS uniques (RG-1.15/1.17 supprimees, decision Q4).
* - distributor / broker : 2 FK auto-referentes mutuellement exclusives
* (RG-1.03, CHECK chk_client_distrib_or_broker en base).
* - categories : M2M vers Category (module Catalog) via le contrat
* CategoryInterface + resolve_target_entities (regle n°1, pas d'import direct).
*
* Operations API (Provider + Processor) branchees en ERP-55 :
* - GetCollection / Get : security commercial.clients.view. La liste expose le
* groupe client:read ; le detail embarque en plus contacts/adresses/ribs
* (groupe client:item:read). Les champs comptables (client:read:accounting)
* sont ajoutes DYNAMIQUEMENT par ClientReadGroupContextBuilder si l'user a
* la permission accounting.view (§ 2.7 / § 4.1 / § 4.2).
* - Post / Patch : security commercial.clients.manage ; le ClientProcessor
* applique normalisation, gating accounting/archive et regles metier.
* - Pas de Delete au M1 (HP-M2-1) : l'archivage passe par PATCH isArchived.
*/
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('commercial.clients.view')",
normalizationContext: ['groups' => ['client:read', 'default:read']],
provider: ClientProvider::class,
),
new Get(
security: "is_granted('commercial.clients.view')",
// Detail : client + sous-collections embarquees. Le groupe
// client:read:accounting est ajoute par le context builder selon la
// permission, donc absent ici volontairement.
normalizationContext: ['groups' => [
'client:read',
'client:item:read',
'client_contact:read',
'client_address:read',
'client_rib:read',
'default:read',
]],
provider: ClientProvider::class,
),
new Post(
security: "is_granted('commercial.clients.manage')",
normalizationContext: ['groups' => ['client:read', 'default:read']],
denormalizationContext: ['groups' => ['client:write:main']],
processor: ClientProcessor::class,
),
new Patch(
security: "is_granted('commercial.clients.manage')",
// Le ClientProcessor inspecte les champs reellement envoyes pour
// autoriser/refuser onglet par onglet (RG-1.22 / RG-1.28) : les
// champs accounting exigent accounting.manage, isArchived exige
// archive.
normalizationContext: ['groups' => ['client:read', 'default:read']],
denormalizationContext: ['groups' => [
'client:write:main',
'client:write:information',
'client:write:accounting',
'client:write:archive',
]],
provider: ClientProvider::class,
processor: ClientProcessor::class,
),
],
)]
#[ORM\Entity(repositoryClass: DoctrineClientRepository::class)]
#[ORM\Table(name: 'client')]
// Index nommes pour matcher la migration (Version20260601000000). L'index
// unique partiel uq_client_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] (decision Q4).
#[ORM\Index(name: 'idx_client_is_archived', columns: ['is_archived'])]
#[ORM\Index(name: 'idx_client_deleted_at', columns: ['deleted_at'])]
#[ORM\Index(name: 'idx_client_distributor_id', columns: ['distributor_id'])]
#[ORM\Index(name: 'idx_client_broker_id', columns: ['broker_id'])]
#[ORM\Index(name: 'idx_client_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_client_updated_by', columns: ['updated_by'])]
#[Auditable]
class Client implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['client:read'])]
private ?int $id = null;
// === Formulaire principal ===
#[ORM\Column(length: 180)]
#[Assert\NotBlank(message: 'Le nom de l\'entreprise est obligatoire.', normalizer: 'trim')]
#[Assert\Length(min: 2, max: 180, normalizer: 'trim')]
#[Groups(['client:read', 'client:write:main'])]
private ?string $companyName = null;
// RG-1.01 : firstName OU lastName obligatoire (validation au futur Processor).
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, normalizer: 'trim')]
#[Groups(['client:read', 'client:write:main'])]
private ?string $firstName = null;
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, normalizer: 'trim')]
#[Groups(['client:read', 'client:write:main'])]
private ?string $lastName = null;
#[ORM\Column(length: 20)]
#[Assert\NotBlank]
#[Groups(['client:read', 'client:write:main'])]
private ?string $phonePrimary = null;
#[ORM\Column(length: 20, nullable: true)]
#[Groups(['client:read', 'client:write:main'])]
private ?string $phoneSecondary = null;
#[ORM\Column(length: 180)]
#[Assert\NotBlank]
#[Assert\Email]
#[Groups(['client:read', 'client:write:main'])]
private ?string $email = null;
// RG-1.03 : distributor / broker auto-references mutuellement exclusives
// (CHECK chk_client_distrib_or_broker en base).
#[ORM\ManyToOne(targetEntity: self::class)]
#[ORM\JoinColumn(name: 'distributor_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['client:read', 'client:write:main'])]
private ?Client $distributor = null;
#[ORM\ManyToOne(targetEntity: self::class)]
#[ORM\JoinColumn(name: 'broker_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['client:read', 'client:write:main'])]
private ?Client $broker = null;
#[ORM\Column(name: 'triage_service', options: ['default' => false])]
#[Groups(['client:read', 'client:write:main'])]
private bool $triageService = false;
// RG : au moins une categorie (Count min 1). M2M vers Category via le contrat
// CategoryInterface (resolve_target_entities -> Category).
/** @var Collection<int, CategoryInterface> */
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
#[ORM\JoinTable(name: 'client_category')]
#[ORM\JoinColumn(name: 'client_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(['client:read', 'client:write:main'])]
private Collection $categories;
// === Onglet Information ===
#[ORM\Column(type: 'text', nullable: true)]
#[Groups(['client:read', 'client:write:information'])]
private ?string $description = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['client:read', 'client:write:information'])]
private ?string $competitors = null;
#[ORM\Column(type: 'date_immutable', nullable: true)]
#[Groups(['client:read', 'client:write:information'])]
private ?DateTimeImmutable $foundedAt = null;
#[ORM\Column(nullable: true)]
#[Assert\PositiveOrZero]
#[Groups(['client:read', 'client:write:information'])]
private ?int $employeesCount = null;
#[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)]
#[Groups(['client:read', 'client:write:information'])]
private ?string $revenueAmount = null;
#[ORM\Column(length: 120, nullable: true)]
#[Groups(['client:read', 'client:write:information'])]
private ?string $directorName = null;
#[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)]
#[Groups(['client:read', 'client:write:information'])]
private ?string $profitAmount = null;
// === Onglet Comptabilite ===
// Lecture conditionnee via le groupe `client:read:accounting` (ajoute par le
// futur Provider si l'user a la permission accounting.view). Ecriture via
// `client:write:accounting` (le futur Processor exige accounting.manage).
#[ORM\Column(length: 20, nullable: true)]
#[Groups(['client:read:accounting', 'client:write:accounting'])]
private ?string $siren = null;
#[ORM\Column(length: 40, nullable: true)]
#[Groups(['client:read:accounting', 'client:write:accounting'])]
private ?string $accountNumber = null;
#[ORM\ManyToOne(targetEntity: TvaMode::class)]
#[ORM\JoinColumn(name: 'tva_mode_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['client:read:accounting', 'client:write:accounting'])]
private ?TvaMode $tvaMode = null;
#[ORM\Column(length: 40, nullable: true)]
#[Groups(['client:read:accounting', 'client:write:accounting'])]
private ?string $nTva = null;
#[ORM\ManyToOne(targetEntity: PaymentDelay::class)]
#[ORM\JoinColumn(name: 'payment_delay_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['client:read:accounting', 'client:write:accounting'])]
private ?PaymentDelay $paymentDelay = null;
#[ORM\ManyToOne(targetEntity: PaymentType::class)]
#[ORM\JoinColumn(name: 'payment_type_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['client:read:accounting', 'client:write:accounting'])]
private ?PaymentType $paymentType = null;
#[ORM\ManyToOne(targetEntity: Bank::class)]
#[ORM\JoinColumn(name: 'bank_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['client:read:accounting', 'client:write:accounting'])]
private ?Bank $bank = null;
// === Sous-collections (exposees via sous-ressources API dediees, ulterieur) ===
/** @var Collection<int, ClientContact> */
#[ORM\OneToMany(mappedBy: 'client', targetEntity: ClientContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $contacts;
/** @var Collection<int, ClientAddress> */
#[ORM\OneToMany(mappedBy: 'client', targetEntity: ClientAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $addresses;
/** @var Collection<int, ClientRib> */
#[ORM\OneToMany(mappedBy: 'client', targetEntity: ClientRib::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" (meme pattern que User::isAdmin
// et Role::isSystem).
#[ORM\Column(name: 'is_archived', options: ['default' => false])]
#[Groups(['client:write:archive'])]
private bool $isArchived = false;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
#[Groups(['client:read'])]
private ?DateTimeImmutable $archivedAt = null;
// Soft delete technique (HP-M2-1) : non expose en lecture/ecriture au M1.
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?DateTimeImmutable $deletedAt = null;
public function __construct()
{
$this->categories = new ArrayCollection();
$this->contacts = new ArrayCollection();
$this->addresses = new ArrayCollection();
$this->ribs = new ArrayCollection();
}
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;
}
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 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 getDistributor(): ?Client
{
return $this->distributor;
}
public function setDistributor(?Client $distributor): static
{
$this->distributor = $distributor;
return $this;
}
public function getBroker(): ?Client
{
return $this->broker;
}
public function setBroker(?Client $broker): static
{
$this->broker = $broker;
return $this;
}
public function isTriageService(): bool
{
return $this->triageService;
}
public function setTriageService(bool $triageService): static
{
$this->triageService = $triageService;
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;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
public function getCompetitors(): ?string
{
return $this->competitors;
}
public function setCompetitors(?string $competitors): static
{
$this->competitors = $competitors;
return $this;
}
public function getFoundedAt(): ?DateTimeImmutable
{
return $this->foundedAt;
}
public function setFoundedAt(?DateTimeImmutable $foundedAt): static
{
$this->foundedAt = $foundedAt;
return $this;
}
public function getEmployeesCount(): ?int
{
return $this->employeesCount;
}
public function setEmployeesCount(?int $employeesCount): static
{
$this->employeesCount = $employeesCount;
return $this;
}
public function getRevenueAmount(): ?string
{
return $this->revenueAmount;
}
public function setRevenueAmount(?string $revenueAmount): static
{
$this->revenueAmount = $revenueAmount;
return $this;
}
public function getDirectorName(): ?string
{
return $this->directorName;
}
public function setDirectorName(?string $directorName): static
{
$this->directorName = $directorName;
return $this;
}
public function getProfitAmount(): ?string
{
return $this->profitAmount;
}
public function setProfitAmount(?string $profitAmount): static
{
$this->profitAmount = $profitAmount;
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, ClientContact> */
#[Groups(['client:item:read'])]
public function getContacts(): Collection
{
return $this->contacts;
}
public function addContact(ClientContact $contact): static
{
if (!$this->contacts->contains($contact)) {
$this->contacts->add($contact);
$contact->setClient($this);
}
return $this;
}
public function removeContact(ClientContact $contact): static
{
if ($this->contacts->removeElement($contact) && $contact->getClient() === $this) {
$contact->setClient(null);
}
return $this;
}
/** @return Collection<int, ClientAddress> */
#[Groups(['client:item:read'])]
public function getAddresses(): Collection
{
return $this->addresses;
}
public function addAddress(ClientAddress $address): static
{
if (!$this->addresses->contains($address)) {
$this->addresses->add($address);
$address->setClient($this);
}
return $this;
}
public function removeAddress(ClientAddress $address): static
{
if ($this->addresses->removeElement($address) && $address->getClient() === $this) {
$address->setClient(null);
}
return $this;
}
/** @return Collection<int, ClientRib> */
#[Groups(['client:item:read'])]
public function getRibs(): Collection
{
return $this->ribs;
}
public function addRib(ClientRib $rib): static
{
if (!$this->ribs->contains($rib)) {
$this->ribs->add($rib);
$rib->setClient($this);
}
return $this;
}
public function removeRib(ClientRib $rib): static
{
if ($this->ribs->removeElement($rib) && $rib->getClient() === $this) {
$rib->setClient(null);
}
return $this;
}
// Groupe de lecture + nom serialise explicite : sans SerializedName, Symfony
// exposerait la cle "archived" (strip du prefixe "is" sur les getters).
#[Groups(['client: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,337 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Entity;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientAddressRepository;
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;
/**
* Adresse d'un client (1:n) onglet Adresse. Une adresse de prospection
* (isProspect) est exclusive d'une adresse de livraison/facturation
* (RG-1.06/07/08, CHECK BDD). Un email de facturation est obligatoire ssi
* isBilling (RG-1.11, CHECK BDD). Au moins un site doit etre rattache
* (RG-1.10, Assert\Count).
*
* Relations M2M :
* - sites : SiteInterface (module Sites) via resolve_target_entities
* - contacts : ClientContact (meme module)
* - categories : CategoryInterface (module Catalog) via resolve_target_entities
* limitees aux types SECTEUR/AUTRE cote validation (RG-1.29, futur Processor)
*
* Audite (#[Auditable]) + Timestampable/Blamable. Aucun ApiResource au M1.1
* (sous-ressources branchees a un ticket dedie).
*/
#[ORM\Entity(repositoryClass: DoctrineClientAddressRepository::class)]
#[ORM\Table(name: 'client_address')]
#[ORM\Index(name: 'idx_client_address_client', columns: ['client_id'])]
#[Auditable]
class ClientAddress implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['client_address:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Client::class, inversedBy: 'addresses')]
#[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ?Client $client = null;
#[ORM\Column(name: 'is_prospect', options: ['default' => false])]
#[Groups(['client_address:read', 'client_address:write'])]
private bool $isProspect = false;
#[ORM\Column(name: 'is_delivery', options: ['default' => false])]
#[Groups(['client_address:read', 'client_address:write'])]
private bool $isDelivery = false;
#[ORM\Column(name: 'is_billing', options: ['default' => false])]
#[Groups(['client_address:read', 'client_address:write'])]
private bool $isBilling = false;
#[ORM\Column(length: 80, options: ['default' => 'France'])]
#[Groups(['client_address:read', 'client_address:write'])]
private string $country = 'France';
// RG-1.09 : code postal a 4 ou 5 chiffres (pas de controle CP/ville serveur).
#[ORM\Column(length: 20)]
#[Assert\NotBlank]
#[Assert\Regex(pattern: '/^[0-9]{4,5}$/', message: 'Le code postal doit comporter 4 ou 5 chiffres.')]
#[Groups(['client_address:read', 'client_address:write'])]
private ?string $postalCode = null;
#[ORM\Column(length: 120)]
#[Assert\NotBlank]
#[Groups(['client_address:read', 'client_address:write'])]
private ?string $city = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank]
#[Groups(['client_address:read', 'client_address:write'])]
private ?string $street = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['client_address:read', 'client_address:write'])]
private ?string $streetComplement = null;
// RG-1.11 : obligatoire ssi isBilling (CHECK BDD + futur Processor).
#[ORM\Column(length: 180, nullable: true)]
#[Assert\Email]
#[Groups(['client_address:read', 'client_address:write'])]
private ?string $billingEmail = null;
#[ORM\Column(options: ['default' => 0])]
#[Groups(['client_address:read', 'client_address:write'])]
private int $position = 0;
// RG-1.10 : au moins un site rattache a chaque adresse.
/** @var Collection<int, SiteInterface> */
#[ORM\ManyToMany(targetEntity: SiteInterface::class)]
#[ORM\JoinTable(name: 'client_address_site')]
#[ORM\JoinColumn(name: 'client_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(['client_address:read', 'client_address:write'])]
private Collection $sites;
/** @var Collection<int, ClientContact> */
#[ORM\ManyToMany(targetEntity: ClientContact::class)]
#[ORM\JoinTable(name: 'client_address_contact')]
#[ORM\JoinColumn(name: 'client_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'client_contact_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[Groups(['client_address:read', 'client_address:write'])]
private Collection $contacts;
// RG-1.29 : categories de type SECTEUR/AUTRE uniquement (filtre au Processor).
/** @var Collection<int, CategoryInterface> */
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
#[ORM\JoinTable(name: 'client_address_category')]
#[ORM\JoinColumn(name: 'client_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
#[Groups(['client_address:read', 'client_address:write'])]
private Collection $categories;
public function __construct()
{
$this->sites = new ArrayCollection();
$this->contacts = new ArrayCollection();
$this->categories = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getClient(): ?Client
{
return $this->client;
}
public function setClient(?Client $client): static
{
$this->client = $client;
return $this;
}
public function isProspect(): bool
{
return $this->isProspect;
}
public function setIsProspect(bool $isProspect): static
{
$this->isProspect = $isProspect;
return $this;
}
public function isDelivery(): bool
{
return $this->isDelivery;
}
public function setIsDelivery(bool $isDelivery): static
{
$this->isDelivery = $isDelivery;
return $this;
}
public function isBilling(): bool
{
return $this->isBilling;
}
public function setIsBilling(bool $isBilling): static
{
$this->isBilling = $isBilling;
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 getBillingEmail(): ?string
{
return $this->billingEmail;
}
public function setBillingEmail(?string $billingEmail): static
{
$this->billingEmail = $billingEmail;
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, ClientContact> */
public function getContacts(): Collection
{
return $this->contacts;
}
public function addContact(ClientContact $contact): static
{
if (!$this->contacts->contains($contact)) {
$this->contacts->add($contact);
}
return $this;
}
public function removeContact(ClientContact $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,178 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Entity;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientContactRepository;
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 client (1:n) onglet Contact. Au moins firstName OU lastName
* doit etre renseigne (RG-1.05) : la contrainte est portee par un CHECK BDD
* (chk_client_contact_name) et validee dans le futur ClientContactProcessor ;
* l'entite reste permissive (les deux champs sont nullable).
*
* Audite (#[Auditable]) + Timestampable/Blamable (pattern Shared standard).
* Les operations CRUD (sous-ressources POST/PATCH/DELETE) sont branchees au
* ticket dedie des sous-ressources aucun ApiResource au M1.1 (ERP-54).
*/
#[ORM\Entity(repositoryClass: DoctrineClientContactRepository::class)]
#[ORM\Table(name: 'client_contact')]
#[ORM\Index(name: 'idx_client_contact_client', columns: ['client_id'])]
#[Auditable]
class ClientContact implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['client_contact:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Client::class, inversedBy: 'contacts')]
#[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ?Client $client = null;
// RG-1.05 : firstName OU lastName obligatoire (CHECK BDD + Processor). Les
// deux restent nullable au niveau ORM.
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, normalizer: 'trim')]
#[Groups(['client_contact:read', 'client_contact:write'])]
private ?string $firstName = null;
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, normalizer: 'trim')]
#[Groups(['client_contact:read', 'client_contact:write'])]
private ?string $lastName = null;
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, normalizer: 'trim')]
#[Groups(['client_contact:read', 'client_contact:write'])]
private ?string $jobTitle = null;
#[ORM\Column(length: 20, nullable: true)]
#[Groups(['client_contact:read', 'client_contact:write'])]
private ?string $phonePrimary = null;
#[ORM\Column(length: 20, nullable: true)]
#[Groups(['client_contact:read', 'client_contact:write'])]
private ?string $phoneSecondary = null;
#[ORM\Column(length: 180, nullable: true)]
#[Assert\Email]
#[Groups(['client_contact:read', 'client_contact:write'])]
private ?string $email = null;
#[ORM\Column(options: ['default' => 0])]
#[Groups(['client_contact:read', 'client_contact:write'])]
private int $position = 0;
public function getId(): ?int
{
return $this->id;
}
public function getClient(): ?Client
{
return $this->client;
}
public function setClient(?Client $client): static
{
$this->client = $client;
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,134 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Entity;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRibRepository;
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 client (1:n) onglet Comptabilite. Au moins un
* RIB est obligatoire si le type de reglement du client est LCR (RG-1.13,
* verifie au futur Processor).
*
* Audit (#[Auditable]) : TOUS les champs sont audites, y compris `iban` et
* `bic` AUCUN #[AuditIgnore] (decision Matthieu en revue MR 29/05/2026 :
* l'audit etant admin-only, la tracabilite RIB est necessaire pour le suivi
* comptable et la conformite, cf. spec § 2.5 / § 6.1).
*
* Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony au M1
* (HP-M2-14 : pas de controle externe banque reelle). Timestampable/Blamable
* standard. Aucun ApiResource au M1.1 (sous-ressource branchee ulterieurement).
*/
#[ORM\Entity(repositoryClass: DoctrineClientRibRepository::class)]
#[ORM\Table(name: 'client_rib')]
#[ORM\Index(name: 'idx_client_rib_client', columns: ['client_id'])]
#[Auditable]
class ClientRib implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['client_rib:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Client::class, inversedBy: 'ribs')]
#[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ?Client $client = null;
#[ORM\Column(length: 120)]
#[Assert\NotBlank]
#[Assert\Length(max: 120, normalizer: 'trim')]
#[Groups(['client_rib:read', 'client_rib:write'])]
private ?string $label = null;
#[ORM\Column(length: 20)]
#[Assert\NotBlank]
#[Assert\Bic]
#[Groups(['client_rib:read', 'client_rib:write'])]
private ?string $bic = null;
#[ORM\Column(length: 34)]
#[Assert\NotBlank]
#[Assert\Iban]
#[Groups(['client_rib:read', 'client_rib:write'])]
private ?string $iban = null;
#[ORM\Column(options: ['default' => 0])]
#[Groups(['client_rib:read', 'client_rib:write'])]
private int $position = 0;
public function getId(): ?int
{
return $this->id;
}
public function getClient(): ?Client
{
return $this->client;
}
public function setClient(?Client $client): static
{
$this->client = $client;
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\Commercial\Domain\Entity;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrinePaymentDelayRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Delai de reglement applique a un client (15 jours, 30 jours, a reception) :
* referentiel statique seede par la migration M1 et re-seede en dev/test par
* CommercialReferentialFixtures.
*
* Lecture seule au M1 (HP-M2-2). Pas de Timestampable/Blamable (referentiel
* statique whiteliste dans EntitiesAreTimestampableBlamableTest::EXCLUDED). Le
* groupe `client:read:accounting` permet l'embarquement dans la reponse Client.
*/
#[ORM\Entity(repositoryClass: DoctrinePaymentDelayRepository::class)]
#[ORM\Table(name: 'payment_delay')]
#[ORM\UniqueConstraint(name: 'uq_payment_delay_code', columns: ['code'])]
class PaymentDelay
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['payment_delay:read', 'client:read:accounting'])]
private ?int $id = null;
#[ORM\Column(length: 30)]
#[Groups(['payment_delay:read', 'client:read:accounting'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
#[Groups(['payment_delay:read', 'client:read:accounting'])]
private ?string $label = null;
#[ORM\Column(options: ['default' => 0])]
#[Groups(['payment_delay:read'])]
private int $position = 0;
public function getId(): ?int
{
return $this->id;
}
public function getCode(): ?string
{
return $this->code;
}
public function setCode(string $code): static
{
$this->code = $code;
return $this;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(string $label): static
{
$this->label = $label;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
}
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Entity;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrinePaymentTypeRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Type de reglement applique a un client (virement, LCR, cheque, non soumise) :
* referentiel statique seede par la migration M1 et re-seede en dev/test par
* CommercialReferentialFixtures.
*
* Le `code` porte une semantique metier : VIREMENT impose une banque (RG-1.12),
* LCR impose au moins un RIB (RG-1.13).
*
* Lecture seule au M1 (HP-M2-2). Pas de Timestampable/Blamable (referentiel
* statique whiteliste dans EntitiesAreTimestampableBlamableTest::EXCLUDED). Le
* groupe `client:read:accounting` permet l'embarquement dans la reponse Client.
*/
#[ORM\Entity(repositoryClass: DoctrinePaymentTypeRepository::class)]
#[ORM\Table(name: 'payment_type')]
#[ORM\UniqueConstraint(name: 'uq_payment_type_code', columns: ['code'])]
class PaymentType
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['payment_type:read', 'client:read:accounting'])]
private ?int $id = null;
#[ORM\Column(length: 30)]
#[Groups(['payment_type:read', 'client:read:accounting'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
#[Groups(['payment_type:read', 'client:read:accounting'])]
private ?string $label = null;
#[ORM\Column(options: ['default' => 0])]
#[Groups(['payment_type:read'])]
private int $position = 0;
public function getId(): ?int
{
return $this->id;
}
public function getCode(): ?string
{
return $this->code;
}
public function setCode(string $code): static
{
$this->code = $code;
return $this;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(string $label): static
{
$this->label = $label;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
}
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Entity;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineTvaModeRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Mode de TVA applique a un client (France ventes, Export, Intracom) :
* referentiel statique seede par la migration M1 (Version20260601000000) et
* re-seede en dev/test par CommercialReferentialFixtures.
*
* Lecture seule au M1 : pas de POST/PATCH/DELETE (HP-M2-2). L'ApiResource
* (GetCollection + Get, tri position ASC) est branche au ticket dedie des
* referentiels lecture seule.
*
* Referentiel statique : pas de Timestampable/Blamable (whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED, comme CategoryType). Le
* groupe `client:read:accounting` permet d'embarquer le mode dans la reponse
* d'un Client (onglet Comptabilite) au lieu d'un IRI.
*/
#[ORM\Entity(repositoryClass: DoctrineTvaModeRepository::class)]
#[ORM\Table(name: 'tva_mode')]
#[ORM\UniqueConstraint(name: 'uq_tva_mode_code', columns: ['code'])]
class TvaMode
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['tva_mode:read', 'client:read:accounting'])]
private ?int $id = null;
#[ORM\Column(length: 30)]
#[Groups(['tva_mode:read', 'client:read:accounting'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
#[Groups(['tva_mode:read', 'client:read:accounting'])]
private ?string $label = null;
#[ORM\Column(options: ['default' => 0])]
#[Groups(['tva_mode:read'])]
private int $position = 0;
public function getId(): ?int
{
return $this->id;
}
public function getCode(): ?string
{
return $this->code;
}
public function setCode(string $code): static
{
$this->code = $code;
return $this;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(string $label): static
{
$this->label = $label;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Repository;
use App\Module\Commercial\Domain\Entity\Bank;
interface BankRepositoryInterface
{
public function findById(int $id): ?Bank;
/**
* Retourne toutes les banques triees position ASC puis label ASC.
*
* @return list<Bank>
*/
public function findAllOrdered(): array;
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Repository;
use App\Module\Commercial\Domain\Entity\ClientAddress;
interface ClientAddressRepositoryInterface
{
public function findById(int $id): ?ClientAddress;
public function save(ClientAddress $address): void;
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Repository;
use App\Module\Commercial\Domain\Entity\ClientContact;
interface ClientContactRepositoryInterface
{
public function findById(int $id): ?ClientContact;
public function save(ClientContact $contact): void;
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Repository;
use App\Module\Commercial\Domain\Entity\Client;
use Doctrine\ORM\QueryBuilder;
interface ClientRepositoryInterface
{
public function findById(int $id): ?Client;
public function save(Client $client): void;
/**
* Construit un QueryBuilder de liste pour le repertoire clients.
* - Exclut toujours les clients soft-deletes (deleted_at IS NOT NULL, RG-1.24).
* - Exclut les archives sauf si $includeArchived = true (RG-1.25).
* - Tri par defaut : companyName ASC (RG-1.26).
*/
public function createListQueryBuilder(bool $includeArchived = false): QueryBuilder;
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Repository;
use App\Module\Commercial\Domain\Entity\ClientRib;
interface ClientRibRepositoryInterface
{
public function findById(int $id): ?ClientRib;
public function save(ClientRib $rib): void;
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Repository;
use App\Module\Commercial\Domain\Entity\PaymentDelay;
interface PaymentDelayRepositoryInterface
{
public function findById(int $id): ?PaymentDelay;
/**
* Retourne tous les delais de reglement tries position ASC puis label ASC.
*
* @return list<PaymentDelay>
*/
public function findAllOrdered(): array;
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Repository;
use App\Module\Commercial\Domain\Entity\PaymentType;
interface PaymentTypeRepositoryInterface
{
public function findById(int $id): ?PaymentType;
/**
* Retourne tous les types de reglement tries position ASC puis label ASC.
*
* @return list<PaymentType>
*/
public function findAllOrdered(): array;
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Repository;
use App\Module\Commercial\Domain\Entity\TvaMode;
interface TvaModeRepositoryInterface
{
public function findById(int $id): ?TvaMode;
/**
* Retourne tous les modes de TVA tries position ASC puis label ASC
* (ordre des selecteurs, reutilise par la fixture de re-seed).
*
* @return list<TvaMode>
*/
public function findAllOrdered(): array;
}
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\ApiPlatform\Serializer;
use ApiPlatform\Metadata\IriConverterInterface;
use App\Shared\Domain\Contract\CategoryInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
/**
* Denormalise un IRI (`/api/categories/{id}`) vers la Category concrete quand la
* propriete cible est type-hintee par le contrat CategoryInterface (ex:
* Client::$categories, ClientAddress::$categories).
*
* Pourquoi ce denormalizer : API Platform deduit le type de l'element de
* collection depuis le phpdoc `@var Collection<int, CategoryInterface>`, donc
* l'INTERFACE. Or le serializer ne sait pas denormaliser un IRI vers une
* interface (« Could not denormalize object of type CategoryInterface[] ») : il
* lui faut une classe-ressource concrete. On resout donc l'IRI via l'IriConverter
* (qui retourne la Category mappee a la route) sans importer Category la regle
* ABSOLUE n°1 reste respectee (dependance au seul contrat Shared + API Platform).
*
* En lecture (normalisation), aucun probleme : l'objet reel EST une Category,
* resource a part entiere, serialisee en IRI par le normalizer standard.
*/
final class CategoryReferenceDenormalizer implements DenormalizerInterface
{
public function __construct(
private readonly IriConverterInterface $iriConverter,
) {}
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): ?CategoryInterface
{
if (!is_string($data) || '' === $data) {
return null;
}
// getResourceFromIri leve une exception sur IRI invalide -> 400, ce qui
// est le comportement attendu pour une reference cassee.
$resource = $this->iriConverter->getResourceFromIri($data);
return $resource instanceof CategoryInterface ? $resource : null;
}
public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
{
// Support base sur le seul type cible : l'ArrayDenormalizer (collection
// `CategoryInterface[]`) interroge le support en passant le TABLEAU
// complet comme $data avant de deleguer element par element. Tester
// is_string($data) ici casserait donc la chaine pour les collections.
return CategoryInterface::class === $type;
}
/**
* @return array<class-string|string, bool>
*/
public function getSupportedTypes(?string $format): array
{
return [CategoryInterface::class => true];
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\ApiPlatform\Serializer;
use ApiPlatform\State\SerializerContextBuilderInterface;
use App\Module\Commercial\Domain\Entity\Client;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
use Symfony\Component\HttpFoundation\Request;
/**
* Decore le context builder de serialisation d'API Platform pour ajouter
* DYNAMIQUEMENT le groupe de lecture `client:read:accounting` sur les ressources
* Client, uniquement si l'utilisateur courant a la permission
* `commercial.clients.accounting.view` (cf. spec-back M1 § 2.7 / § 4.1 / § 4.2).
*
* Pourquoi un context builder et pas le Provider : un Provider retourne des
* donnees mais ne peut pas influencer les groupes de serialisation. Le contexte
* de normalisation est construit ici, en amont du serializer c'est le point
* d'extension idiomatique d'API Platform pour conditionner un groupe selon
* l'utilisateur. Realise l'intention « ajout conditionnel du groupe accounting »
* de la spec.
*
* S'applique aux operations de LECTURE (normalization) sur Client : liste ET
* detail. Sans la permission, les champs comptables (siren, accountNumber,
* tvaMode, nTva, paymentDelay, paymentType, bank) ne sont jamais serialises.
*/
#[AsDecorator('api_platform.serializer.context_builder')]
final readonly class ClientReadGroupContextBuilder implements SerializerContextBuilderInterface
{
public function __construct(
#[AutowireDecorated]
private SerializerContextBuilderInterface $decorated,
private Security $security,
) {}
public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array
{
$context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes);
// Uniquement en lecture, sur la ressource Client, avec la permission.
if (!$normalization) {
return $context;
}
if (Client::class !== ($context['resource_class'] ?? null)) {
return $context;
}
if (!$this->security->isGranted('commercial.clients.accounting.view')) {
return $context;
}
$groups = $context['groups'] ?? [];
if (!in_array('client:read:accounting', $groups, true)) {
$groups[] = 'client:read:accounting';
}
$context['groups'] = $groups;
return $context;
}
}
@@ -0,0 +1,362 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Commercial\Application\Service\ClientFieldNormalizer;
use App\Module\Commercial\Application\Validator\ClientInformationCompletenessValidator;
use App\Module\Commercial\Domain\Entity\Client;
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Security\BusinessRoles;
use DateTimeImmutable;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use JsonException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Processor d'ecriture du repertoire clients (M1). Cf. spec-back M1 § 2.8 /
* § 2.9 / § 4.3 / § 4.4 + RG-1.01 a RG-1.28.
*
* Sequence (POST / PATCH) :
* 1. Autorisation additionnelle par groupe d'onglet (le `security` de
* l'operation a deja exige commercial.clients.manage) :
* - champ comptable dans le payload -> exige accounting.manage (RG-1.28, 403) ;
* - champ isArchived dans le payload -> exige archive (RG-1.22, 403) et
* interdit toute autre modification dans la meme requete (RG-1.22, 422).
* 2. Normalisation serveur (RG-1.18 a 1.21) via ClientFieldNormalizer.
* 3. Regles metier : RG-1.01 (prenom/nom), RG-1.03 (distributor/broker
* exclusifs + type de categorie), RG-1.12 (Virement -> banque),
* RG-1.13 (LCR -> >= 1 RIB), RG-1.04 (completude Information pour le role
* Commerciale).
* 4. Pose / retrait de archivedAt (RG-1.22 true=now, RG-1.23 false=null).
* 5. Persistance via le persist_processor Doctrine, avec traduction des
* collisions d'unicite en 409 (RG-1.16 doublon de nom ; RG-1.23 conflit de
* restauration).
*
* Note : la validation Symfony (Assert\NotBlank, Assert\Email, Assert\Count sur
* categories...) est jouee par API Platform AVANT ce processor ; on n'y traite
* donc que les regles non exprimables en simples contraintes d'attribut.
*
* @implements ProcessorInterface<Client, Client>
*/
final class ClientProcessor implements ProcessorInterface
{
/** Champs de l'onglet principal (groupe client:write:main). */
private const array MAIN_FIELDS = [
'companyName', 'firstName', 'lastName', 'phonePrimary', 'phoneSecondary',
'email', 'distributor', 'broker', 'triageService', 'categories',
];
/** Champs de l'onglet Information (groupe client:write:information). */
private const array INFORMATION_FIELDS = [
'description', 'competitors', 'foundedAt', 'employeesCount',
'revenueAmount', 'directorName', 'profitAmount',
];
/** Champs de l'onglet Comptabilite (groupe client:write:accounting). */
private const array ACCOUNTING_FIELDS = [
'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay',
'paymentType', 'bank',
];
/** Champ d'archivage (groupe client:write:archive). */
private const string ARCHIVE_FIELD = 'isArchived';
private const string PERM_ACCOUNTING_MANAGE = 'commercial.clients.accounting.manage';
private const string PERM_ARCHIVE = 'commercial.clients.archive';
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
private readonly ClientFieldNormalizer $normalizer,
private readonly ClientInformationCompletenessValidator $informationValidator,
private readonly Security $security,
private readonly RequestStack $requestStack,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof Client) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
$payloadKeys = $this->payloadKeys();
$isArchiveRequest = $this->guardArchive($data, $payloadKeys);
$this->guardAccounting($payloadKeys);
$this->normalize($data);
$this->validateMainContact($data);
$this->validateDistributorBroker($data);
$this->validateAccountingConsistency($data);
$this->validateInformationCompleteness($data, $payloadKeys);
try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} catch (UniqueConstraintViolationException $e) {
// Le seul index unique partiel est uq_client_company_name_active
// (LOWER(company_name) parmi non-archives/non-deletes — decision Q4).
if ($isArchiveRequest && false === $data->isArchived()) {
// RG-1.23 : restauration en conflit avec un homonyme actif.
throw new ConflictHttpException(
'Restauration impossible : un autre client a pris le nom entre-temps.',
$e,
);
}
// RG-1.16 : doublon de nom de societe.
throw new ConflictHttpException(
sprintf('Un client nommé "%s" existe déjà.', (string) $data->getCompanyName()),
$e,
);
}
}
/**
* RG-1.22 / RG-1.23 : si le payload porte isArchived, exige la permission
* archive (403), interdit toute autre modification (422) et pose/retire
* archivedAt. Retourne true si la requete est une requete d'archivage.
*
* @param list<string> $payloadKeys
*/
private function guardArchive(Client $data, array $payloadKeys): bool
{
if (!in_array(self::ARCHIVE_FIELD, $payloadKeys, true)) {
return false;
}
if (!$this->security->isGranted(self::PERM_ARCHIVE)) {
throw new AccessDeniedHttpException(sprintf(
'Le champ "%s" requiert la permission "%s".',
self::ARCHIVE_FIELD,
self::PERM_ARCHIVE,
));
}
// RG-1.22 : une requete d'archivage ne modifie aucun autre champ.
if ([] !== array_diff($payloadKeys, [self::ARCHIVE_FIELD])) {
throw new UnprocessableEntityHttpException(
'Une requête d\'archivage ne peut modifier aucun autre champ que "isArchived".',
);
}
// RG-1.22 (true -> now) / RG-1.23 (false -> null).
$data->setArchivedAt($data->isArchived() ? new DateTimeImmutable() : null);
return true;
}
/**
* RG-1.28 : un champ comptable dans le payload exige accounting.manage,
* sinon 403 sur l'ensemble du payload (mode strict, pas de filtrage
* silencieux). Le message precise le premier champ fautif.
*
* @param list<string> $payloadKeys
*/
private function guardAccounting(array $payloadKeys): void
{
$touched = array_values(array_intersect($payloadKeys, self::ACCOUNTING_FIELDS));
if ([] === $touched) {
return;
}
if (!$this->security->isGranted(self::PERM_ACCOUNTING_MANAGE)) {
throw new AccessDeniedHttpException(sprintf(
'Le champ "%s" requiert la permission "%s".',
$touched[0],
self::PERM_ACCOUNTING_MANAGE,
));
}
}
/**
* Normalisation serveur (RG-1.18 a 1.21). Les setters non-nullables
* (companyName, email, phonePrimary) ne sont touches que si une valeur est
* presente, pour ne jamais ecraser l'existant lors d'un PATCH partiel.
*/
private function normalize(Client $data): void
{
if (null !== $data->getCompanyName()) {
$data->setCompanyName((string) $this->normalizer->normalizeCompanyName($data->getCompanyName()));
}
if (null !== $data->getEmail()) {
$data->setEmail((string) $this->normalizer->normalizeEmail($data->getEmail()));
}
if (null !== $data->getPhonePrimary()) {
$data->setPhonePrimary((string) $this->normalizer->normalizePhone($data->getPhonePrimary()));
}
$data->setFirstName($this->normalizer->normalizePersonName($data->getFirstName()));
$data->setLastName($this->normalizer->normalizePersonName($data->getLastName()));
$data->setPhoneSecondary($this->normalizer->normalizePhone($data->getPhoneSecondary()));
}
/**
* RG-1.01 : au moins le prenom OU le nom du contact principal.
*/
private function validateMainContact(Client $data): void
{
if (null === $data->getFirstName() && null === $data->getLastName()) {
$this->throwViolation(
'firstName',
'Le prénom ou le nom du contact principal est obligatoire.',
$data,
);
}
}
/**
* RG-1.03 : distributor et broker mutuellement exclusifs ; un distributor
* doit referencer un client de categorie DISTRIBUTEUR (idem broker ->
* COURTIER).
*/
private function validateDistributorBroker(Client $data): void
{
$distributor = $data->getDistributor();
$broker = $data->getBroker();
if (null !== $distributor && null !== $broker) {
$this->throwViolation(
'distributor',
'Un client ne peut pas être rattaché à la fois à un distributeur et à un courtier.',
$data,
);
}
if (null !== $distributor && !$this->hasCategoryType($distributor, 'DISTRIBUTEUR')) {
$this->throwViolation(
'distributor',
'Le distributeur référencé doit être un client de catégorie DISTRIBUTEUR.',
$data,
);
}
if (null !== $broker && !$this->hasCategoryType($broker, 'COURTIER')) {
$this->throwViolation(
'broker',
'Le courtier référencé doit être un client de catégorie COURTIER.',
$data,
);
}
}
/**
* RG-1.12 : Virement -> banque obligatoire. RG-1.13 : LCR -> au moins un RIB.
*/
private function validateAccountingConsistency(Client $data): void
{
$paymentCode = $data->getPaymentType()?->getCode();
if ('VIREMENT' === $paymentCode && null === $data->getBank()) {
$this->throwViolation(
'bank',
'La banque est obligatoire pour le type de règlement Virement.',
$data,
);
}
if ('LCR' === $paymentCode && $data->getRibs()->isEmpty()) {
$this->throwViolation(
'paymentType',
'Au moins un RIB est obligatoire pour le type de règlement LCR.',
$data,
);
}
}
/**
* RG-1.04 : si l'utilisateur porte le role metier Commerciale ET que le
* payload touche l'onglet Information, tous les champs Information sont
* obligatoires. Dormant tant qu'aucun user ne porte le role `commerciale`.
*
* @param list<string> $payloadKeys
*/
private function validateInformationCompleteness(Client $data, array $payloadKeys): void
{
$touchesInformation = [] !== array_intersect($payloadKeys, self::INFORMATION_FIELDS);
if ($touchesInformation && $this->currentUserIsCommerciale()) {
$this->informationValidator->validate($data);
}
}
/**
* Vrai si au moins une categorie du client porte le type donne. S'appuie
* sur CategoryInterface::getCategoryTypeCode() (pas d'import de Category).
*/
private function hasCategoryType(Client $client, string $typeCode): bool
{
foreach ($client->getCategories() as $category) {
if ($category instanceof CategoryInterface && $category->getCategoryTypeCode() === $typeCode) {
return true;
}
}
return false;
}
private function currentUserIsCommerciale(): bool
{
$user = $this->security->getUser();
return $user instanceof BusinessRoleAwareInterface
&& $user->hasBusinessRole(BusinessRoles::COMMERCIALE);
}
/**
* Cles de premier niveau effectivement envoyees par le client (payload JSON
* brut). Pour un PATCH merge-patch+json, ce sont les seuls champs modifies ;
* c'est ce qui permet le gating par onglet (RG-1.22 / RG-1.28) et le
* declenchement conditionnel de RG-1.04.
*
* @return list<string>
*/
private function payloadKeys(): array
{
$request = $this->requestStack->getCurrentRequest();
if (null === $request) {
return [];
}
$content = $request->getContent();
if ('' === $content) {
return [];
}
try {
$decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return [];
}
return is_array($decoded) ? array_values(array_filter(array_keys($decoded), 'is_string')) : [];
}
/**
* Leve une ValidationException (HTTP 422) portant une violation unique sur
* la propriete visee meme rendu Hydra que les contraintes Symfony.
*
* @return never
*/
private function throwViolation(string $property, string $message, Client $root): void
{
$violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation($message, null, [], $root, $property, null));
throw new ValidationException($violations);
}
}
@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Doctrine\Orm\Paginator;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\Commercial\Domain\Entity\Client;
use App\Module\Commercial\Domain\Repository\ClientRepositoryInterface;
use Doctrine\ORM\QueryBuilder;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provider du repertoire clients (M1). Cf. spec-back M1 § 4.1 / § 4.2.
*
* Collection (GET /api/clients) :
* - exclut par defaut les archives (is_archived = true) ET les soft-deletes
* (deleted_at IS NOT NULL) RG-1.24 ;
* - ?includeArchived=true reintegre les archives (les soft-deletes restent
* exclus au M1) RG-1.25 ;
* - tri par defaut companyName ASC RG-1.26 ;
* - filtres ?search=... (fuzzy companyName + lastName + email) et
* ?categoryType=<code> (clients ayant >= 1 categorie de ce type) ;
* - pagination obligatoire (convention Starseed ERP-72) : Paginator ORM ;
* echappatoire ?pagination=false pour alimenter un <select> sans pagination.
*
* Item (GET /api/clients/{id} + provider de PATCH) :
* - 404 si introuvable OU soft-delete (deleted_at non null, jamais expose au
* M1) ; les archives restent consultables/restaurables en detail.
*
* Le filtrage des champs comptables en lecture (groupe client:read:accounting)
* n'est PAS fait ici mais dans ClientReadGroupContextBuilder (le provider ne
* peut pas influencer les groupes de serialisation).
*
* @implements ProviderInterface<Client>
*/
final class ClientProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository')]
private readonly ClientRepositoryInterface $repository,
private readonly Pagination $pagination,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Client|iterable|Paginator|null
{
if ($operation instanceof CollectionOperationInterface) {
return $this->provideCollection($operation, $context);
}
return $this->provideItem($uriVariables);
}
/**
* @param array<string, mixed> $context
*
* @return list<Client>|Paginator<Client>
*/
private function provideCollection(Operation $operation, array $context): array|Paginator
{
$filters = $context['filters'] ?? [];
$includeArchived = $this->readBool($filters['includeArchived'] ?? false);
$qb = $this->repository->createListQueryBuilder($includeArchived);
$this->applySearch($qb, $filters['search'] ?? null);
$this->applyCategoryType($qb, $filters['categoryType'] ?? null);
// Echappatoire ?pagination=false : collection complete sans Paginator
// (cf. convention ERP-72 — utile pour un <select> cote front).
if (!$this->pagination->isEnabled($operation, $context)) {
/** @var list<Client> $result */
return $qb->getQuery()->getResult();
}
$limit = $this->pagination->getLimit($operation, $context);
$page = max(1, $this->pagination->getPage($context));
$offset = ($page - 1) * $limit;
$qb->setFirstResult($offset)->setMaxResults($limit);
// fetchJoinCollection: true pour un COUNT correct des que des JOINs
// to-many seront ajoutes (sous-collections embarquees en detail).
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: true));
}
/**
* @param array<string, mixed> $uriVariables
*/
private function provideItem(array $uriVariables): ?Client
{
$id = $uriVariables['id'] ?? null;
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
return null;
}
$client = $this->repository->findById((int) $id);
if (null === $client) {
return null;
}
// Soft-delete : jamais expose au M1 (HP-M2-1) — 404 via retour null.
// Les archives restent visibles en detail (consultation + restauration).
if (null !== $client->getDeletedAt()) {
return null;
}
return $client;
}
/**
* Recherche fuzzy insensible a la casse sur companyName + lastName + email.
* Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester
* litteraux.
*/
private function applySearch(QueryBuilder $qb, mixed $search): void
{
if (!is_string($search) || '' === trim($search)) {
return;
}
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
$qb->andWhere(
'LOWER(c.companyName) LIKE :search '
.'OR LOWER(c.lastName) LIKE :search '
.'OR LOWER(c.email) LIKE :search',
)->setParameter('search', $pattern);
}
/**
* Restreint aux clients possedant au moins une categorie du type donne.
* Sous-requete IN (plutot qu'un JOIN sur la collection M2M) pour ne pas
* perturber le DISTINCT / ORDER BY de la requete paginee principale.
*/
private function applyCategoryType(QueryBuilder $qb, mixed $categoryType): void
{
if (!is_string($categoryType) || '' === trim($categoryType)) {
return;
}
$sub = $this->repository->createQueryBuilder('c2')
->select('c2.id')
->join('c2.categories', 'cat2')
->join('cat2.categoryType', 'ct2')
->where('ct2.code = :categoryType')
;
$qb->andWhere($qb->expr()->in('c.id', $sub->getDQL()))
->setParameter('categoryType', trim($categoryType))
;
}
/**
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
*/
private function readBool(mixed $raw): bool
{
if (is_bool($raw)) {
return $raw;
}
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
}
}
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\DataFixtures;
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 Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
/**
* Fixtures du module Commercial : re-seed des 4 referentiels comptables
* (tva_mode, payment_delay, payment_type, bank) seedes par la migration M1
* (Version20260601000000).
*
* Pourquoi cette fixture EN PLUS du seed de la migration : depuis ERP-54 ces
* 4 tables sont des entites managees par l'ORM, donc le purger Doctrine les
* vide avant chaque `doctrine:fixtures:load`. Sans cette fixture, les
* referentiels seedes par la migration disparaitraient apres `make db-reset`
* (0 ligne en dev/test) cassant les FK Client -> referentiels et les tests
* RG-1.12/1.13. Le seed migration couvre la prod (ou les fixtures ne tournent
* pas) ; cette fixture re-aligne dev et test. Memes valeurs des deux cotes.
*
* Idempotence : lookup par `code` avant insertion (sur le modele de
* CategoryTypeFixtures). Rejouable sans doublon meme si le purger est desactive.
*/
class CommercialReferentialFixtures extends Fixture
{
/**
* Source unique des referentiels : classe d'entite => [code => [label, position]].
* Doit rester aligne sur le seed de la migration Version20260601000000.
*
* @var array<class-string, array<string, array{string, int}>>
*/
private const REFERENTIALS = [
TvaMode::class => [
'FRANCE_VENTES' => ['France (ventes)', 10],
'EXPORT_VENTES' => ['Export (ventes)', 20],
'INTRACOM_VENTES' => ['Intracom (ventes)', 30],
],
PaymentDelay::class => [
'J15' => ['15 jours', 10],
'J30' => ['30 jours', 20],
'A_RECEPTION' => ['À réception', 30],
],
PaymentType::class => [
'VIREMENT' => ['Virement', 10],
'LCR' => ['LCR', 20],
'NON_SOUMISE' => ['Non soumise', 30],
'CHEQUE' => ['Chèque', 40],
],
Bank::class => [
'SG' => ['Société Générale', 10],
'CIC' => ['CIC', 20],
'CA' => ['Crédit Agricole', 30],
],
];
public function load(ObjectManager $manager): void
{
foreach (self::REFERENTIALS as $entityClass => $rows) {
$this->seedReferential($manager, $entityClass, $rows);
}
$manager->flush();
}
/**
* Upsert idempotent d'un referentiel : indexe l'existant par code puis
* cree/met a jour chaque entree. Les 4 entites partagent le meme contrat
* setCode/setLabel/setPosition.
*
* @param class-string $entityClass
* @param array<string, array{string, int}> $rows
*/
private function seedReferential(ObjectManager $manager, string $entityClass, array $rows): void
{
$existingByCode = [];
foreach ($manager->getRepository($entityClass)->findAll() as $entity) {
$existingByCode[$entity->getCode()] = $entity;
}
foreach ($rows as $code => [$label, $position]) {
$entity = $existingByCode[$code] ?? new $entityClass();
$entity->setCode($code);
$entity->setLabel($label);
$entity->setPosition($position);
$manager->persist($entity);
}
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\Doctrine;
use App\Module\Commercial\Domain\Entity\Bank;
use App\Module\Commercial\Domain\Repository\BankRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Bank>
*/
class DoctrineBankRepository extends ServiceEntityRepository implements BankRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Bank::class);
}
public function findById(int $id): ?Bank
{
return $this->find($id);
}
public function findAllOrdered(): array
{
return $this->createQueryBuilder('b')
->orderBy('b.position', 'ASC')
->addOrderBy('b.label', 'ASC')
->getQuery()
->getResult()
;
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\Doctrine;
use App\Module\Commercial\Domain\Entity\ClientAddress;
use App\Module\Commercial\Domain\Repository\ClientAddressRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ClientAddress>
*/
class DoctrineClientAddressRepository extends ServiceEntityRepository implements ClientAddressRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ClientAddress::class);
}
public function findById(int $id): ?ClientAddress
{
return $this->find($id);
}
public function save(ClientAddress $address): void
{
$this->getEntityManager()->persist($address);
$this->getEntityManager()->flush();
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\Doctrine;
use App\Module\Commercial\Domain\Entity\ClientContact;
use App\Module\Commercial\Domain\Repository\ClientContactRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ClientContact>
*/
class DoctrineClientContactRepository extends ServiceEntityRepository implements ClientContactRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ClientContact::class);
}
public function findById(int $id): ?ClientContact
{
return $this->find($id);
}
public function save(ClientContact $contact): void
{
$this->getEntityManager()->persist($contact);
$this->getEntityManager()->flush();
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\Doctrine;
use App\Module\Commercial\Domain\Entity\Client;
use App\Module\Commercial\Domain\Repository\ClientRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Client>
*/
class DoctrineClientRepository extends ServiceEntityRepository implements ClientRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Client::class);
}
public function findById(int $id): ?Client
{
return $this->find($id);
}
public function save(Client $client): void
{
$this->getEntityManager()->persist($client);
$this->getEntityManager()->flush();
}
public function createListQueryBuilder(bool $includeArchived = false): QueryBuilder
{
$qb = $this->createQueryBuilder('c')
->andWhere('c.deletedAt IS NULL')
->orderBy('c.companyName', 'ASC')
;
if (!$includeArchived) {
$qb->andWhere('c.isArchived = false');
}
return $qb;
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\Doctrine;
use App\Module\Commercial\Domain\Entity\ClientRib;
use App\Module\Commercial\Domain\Repository\ClientRibRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ClientRib>
*/
class DoctrineClientRibRepository extends ServiceEntityRepository implements ClientRibRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ClientRib::class);
}
public function findById(int $id): ?ClientRib
{
return $this->find($id);
}
public function save(ClientRib $rib): void
{
$this->getEntityManager()->persist($rib);
$this->getEntityManager()->flush();
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\Doctrine;
use App\Module\Commercial\Domain\Entity\PaymentDelay;
use App\Module\Commercial\Domain\Repository\PaymentDelayRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<PaymentDelay>
*/
class DoctrinePaymentDelayRepository extends ServiceEntityRepository implements PaymentDelayRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, PaymentDelay::class);
}
public function findById(int $id): ?PaymentDelay
{
return $this->find($id);
}
public function findAllOrdered(): array
{
return $this->createQueryBuilder('p')
->orderBy('p.position', 'ASC')
->addOrderBy('p.label', 'ASC')
->getQuery()
->getResult()
;
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\Doctrine;
use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Commercial\Domain\Repository\PaymentTypeRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<PaymentType>
*/
class DoctrinePaymentTypeRepository extends ServiceEntityRepository implements PaymentTypeRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, PaymentType::class);
}
public function findById(int $id): ?PaymentType
{
return $this->find($id);
}
public function findAllOrdered(): array
{
return $this->createQueryBuilder('p')
->orderBy('p.position', 'ASC')
->addOrderBy('p.label', 'ASC')
->getQuery()
->getResult()
;
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\Doctrine;
use App\Module\Commercial\Domain\Entity\TvaMode;
use App\Module\Commercial\Domain\Repository\TvaModeRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<TvaMode>
*/
class DoctrineTvaModeRepository extends ServiceEntityRepository implements TvaModeRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TvaMode::class);
}
public function findById(int $id): ?TvaMode
{
return $this->find($id);
}
public function findAllOrdered(): array
{
return $this->createQueryBuilder('t')
->orderBy('t.position', 'ASC')
->addOrderBy('t.label', 'ASC')
->getQuery()
->getResult()
;
}
}
+19 -1
View File
@@ -22,6 +22,7 @@ use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
// C'est le pattern officiel Doctrine pour les bounded contexts DDD. // C'est le pattern officiel Doctrine pour les bounded contexts DDD.
use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Attribute\AuditIgnore; use App\Shared\Domain\Attribute\AuditIgnore;
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
use App\Shared\Domain\Contract\SiteInterface; use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Exception\SiteNotAuthorizedException; use App\Shared\Domain\Exception\SiteNotAuthorizedException;
use DateTimeImmutable; use DateTimeImmutable;
@@ -75,7 +76,7 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)] #[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
#[ORM\Table(name: '`user`')] #[ORM\Table(name: '`user`')]
#[Auditable] #[Auditable]
class User implements UserInterface, PasswordAuthenticatedUserInterface class User implements UserInterface, PasswordAuthenticatedUserInterface, BusinessRoleAwareInterface
{ {
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
@@ -337,6 +338,23 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $keys; return $keys;
} }
/**
* Implemente BusinessRoleAwareInterface : vrai si l'un des roles RBAC
* rattaches porte le code donne. Permet aux modules tiers de detecter un
* role metier (ex: `commerciale` pour RG-1.04 du M1 Clients) sans importer
* cette classe. Comparaison stricte sur Role::code.
*/
public function hasBusinessRole(string $roleCode): bool
{
foreach ($this->rbacRoles as $role) {
if ($role->getCode() === $roleCode) {
return true;
}
}
return false;
}
public function getPassword(): ?string public function getPassword(): ?string
{ {
return $this->password; return $this->password;
@@ -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;
}
}
@@ -184,6 +184,8 @@ final class SeedE2ECommand extends Command
'sites.view', 'sites.view',
'sites.manage', 'sites.manage',
'sites.bypass_scope', 'sites.bypass_scope',
'catalog.categories.view',
'catalog.categories.manage',
], ],
], ],
[ [
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Contrat lu par le TimestampableBlamableSubscriber.
*
* Toute entite qui l'implemente voit ses colonnes `created_by` / `updated_by`
* remplies automatiquement avec l'utilisateur authentifie (ou laissees a null
* hors contexte HTTP : CLI, cron, migration).
*
* Le type-hint cible `Symfony\Component\Security\Core\User\UserInterface`
* (deja implementee par App\Module\Core\Domain\Entity\User) pour eviter de
* coupler Shared a Module/Core. La classe concrete est resolue par Doctrine
* via `resolve_target_entities` (cf. config/packages/doctrine.yaml).
*/
interface BlamableInterface
{
public function getCreatedBy(): ?UserInterface;
public function setCreatedBy(?UserInterface $user): void;
public function getUpdatedBy(): ?UserInterface;
public function setUpdatedBy(?UserInterface $user): void;
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Expose, sans coupler a la classe concrete User (module Core), le moyen de
* savoir si un utilisateur porte un role METIER donne (par son code, cf.
* App\Shared\Domain\Security\BusinessRoles).
*
* Implementee par App\Module\Core\Domain\Entity\User. Permet a un module tiers
* (ex: Commercial RG-1.04, completude Information pour le role Commerciale)
* de raisonner sur les roles metier via Security::getUser() sans importer User
* (regle ABSOLUE n°1 : pas d'import inter-modules).
*
* Distinct de UserInterface::getRoles() (roles SYSTEME Symfony ROLE_*, derives
* de is_admin) : ici on parle des roles RBAC metier rattaches a l'utilisateur.
*/
interface BusinessRoleAwareInterface
{
/**
* Vrai si l'utilisateur porte le role RBAC metier identifie par $roleCode
* (compare au champ Role::code).
*/
public function hasBusinessRole(string $roleCode): bool;
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Interface minimale exposant ce qu'un module tiers (Commercial...) doit
* connaitre d'une Category, sans creer de couplage direct vers le module
* Catalog (regle ABSOLUE n°1 : pas d'import inter-modules).
*
* Implementee par App\Module\Catalog\Domain\Entity\Category.
* Utilisee comme cible des ManyToMany Client.categories et
* ClientAddress.categories via resolve_target_entities (cf. doctrine.yaml),
* sur le meme modele que SiteInterface / UserInterface.
*/
interface CategoryInterface
{
public function getId(): ?int;
public function getName(): ?string;
/**
* Code du type de categorie rattache (CategoryType::code), ou null si la
* categorie n'a pas de type. Expose pour permettre a un module tiers de
* raisonner sur le type metier (ex: M1 Commercial RG-1.03 : un distributor
* doit referencer un client categorise DISTRIBUTEUR ; RG-1.29 : categorie
* d'adresse limitee a SECTEUR/AUTRE) sans importer la classe concrete
* Category (regle ABSOLUE n°1).
*/
public function getCategoryTypeCode(): ?string;
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
use DateTimeImmutable;
/**
* Contrat lu par le TimestampableBlamableSubscriber.
*
* Toute entite qui l'implemente voit ses colonnes `created_at` / `updated_at`
* remplies automatiquement au prePersist / preUpdate. Le porteur des colonnes
* et des accesseurs est le TimestampableBlamableTrait.
*/
interface TimestampableInterface
{
public function getCreatedAt(): ?DateTimeImmutable;
public function setCreatedAt(DateTimeImmutable $createdAt): void;
public function getUpdatedAt(): ?DateTimeImmutable;
public function setUpdatedAt(DateTimeImmutable $updatedAt): void;
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Security;
/**
* Codes des roles METIER MALIO partages entre modules.
*
* Distincts des roles SYSTEME (cf. App\Module\Core\Domain\Security\SystemRoles :
* `admin` / `user`). Un role metier porte une intention fonctionnelle (poste de
* travail) et conditionne certaines regles de gestion au-dela des permissions
* RBAC pures ex : RG-1.04 du M1 Clients rend l'onglet Information obligatoire
* pour le seul role Commerciale, alors que Commerciale et Bureau partagent les
* memes permissions (commercial.clients.view + manage, cf. spec-back M1 § 5.2).
*
* Ces constantes vivent dans Shared (et non dans un module) pour que :
* - le seed des roles cote Core (ERP-74) reference le meme code sans importer
* une constante du module Commercial (regle ABSOLUE n°1 : pas d'import
* inter-modules) ;
* - le ClientProcessor (module Commercial) detecte le role Commerciale via ce
* meme code, sans dependre de Core.
*
* Coordination stack M1 :
* - ERP-74 seede le role `commerciale` avec ce code exact.
* - ERP-59 / ERP-60 (declaration RBAC + tests personas) le reutilisent.
* - ERP-55 (ici) ne fait que le REFERENCER : tant qu'aucun user ne porte le
* role `commerciale`, la validation de completude Information reste dormante.
*/
final class BusinessRoles
{
/**
* Role metier « Commerciale » code de Role RBAC (champ Role::code,
* snake_case impose par la regex Role). Conditionne RG-1.04.
*/
public const string COMMERCIALE = 'commerciale';
private function __construct()
{
// Classe de constantes : non instanciable.
}
}
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Trait;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Trait Doctrine qui porte les 4 colonnes Timestampable + Blamable.
*
* Usage : `use TimestampableBlamableTrait;` dans l'entite, +
* `implements TimestampableInterface, BlamableInterface`. Le
* TimestampableBlamableSubscriber remplit les colonnes automatiquement
* au prePersist / preUpdate.
*
* Les Groups Serializer utilisent une convention `default:read` agregee :
* pour exposer les 4 colonnes dans une reponse API d'une entite X, ajouter
* `default:read` au normalizationContext aux cotes du groupe `x:read`.
*/
trait TimestampableBlamableTrait
{
#[ORM\Column(name: 'created_at', type: 'datetime_immutable')]
#[Groups(['default:read'])]
private ?DateTimeImmutable $createdAt = null;
#[ORM\Column(name: 'updated_at', type: 'datetime_immutable')]
#[Groups(['default:read'])]
private ?DateTimeImmutable $updatedAt = null;
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(name: 'created_by', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['default:read'])]
private ?UserInterface $createdBy = null;
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(name: 'updated_by', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['default:read'])]
private ?UserInterface $updatedBy = null;
public function getCreatedAt(): ?DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(DateTimeImmutable $createdAt): void
{
$this->createdAt = $createdAt;
}
public function getUpdatedAt(): ?DateTimeImmutable
{
return $this->updatedAt;
}
public function setUpdatedAt(DateTimeImmutable $updatedAt): void
{
$this->updatedAt = $updatedAt;
}
public function getCreatedBy(): ?UserInterface
{
return $this->createdBy;
}
public function setCreatedBy(?UserInterface $user): void
{
$this->createdBy = $user;
}
public function getUpdatedBy(): ?UserInterface
{
return $this->updatedBy;
}
public function setUpdatedBy(?UserInterface $user): void
{
$this->updatedBy = $user;
}
}
@@ -0,0 +1,330 @@
<?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.',
],
// === M1 Commercial (ERP-53/54) — miroir des COMMENT de la migration
// Version20260601000000 pour le chemin schema:update (dev/test). ===
'tva_mode' => [
'_table' => 'Referentiel des modes de TVA appliques a un client (France, Export, Intracom).',
'id' => 'Identifiant interne auto-incremente.',
'code' => 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.',
'label' => 'Libelle affichable (FR, ≤ 120 caracteres).',
'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).',
],
'payment_delay' => [
'_table' => 'Referentiel des delais de reglement (15 jours, 30 jours, a reception).',
'id' => 'Identifiant interne auto-incremente.',
'code' => 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.',
'label' => 'Libelle affichable (FR, ≤ 120 caracteres).',
'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).',
],
'payment_type' => [
'_table' => 'Referentiel des types de reglement (virement, LCR, cheque, non soumise).',
'id' => 'Identifiant interne auto-incremente.',
'code' => 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.',
'label' => 'Libelle affichable (FR, ≤ 120 caracteres).',
'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).',
],
'bank' => [
'_table' => 'Referentiel des banques selectionnables pour le reglement par virement.',
'id' => 'Identifiant interne auto-incremente.',
'code' => 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.',
'label' => 'Libelle affichable (FR, ≤ 120 caracteres).',
'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).',
],
'client' => [
'_table' => 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).',
'id' => 'Identifiant interne auto-incremente.',
'company_name' => 'Raison sociale (stockee en MAJUSCULES, RG-1.18). Unique case-insensitive parmi les actifs non archives/non supprimes (RG-1.16, uq_client_company_name_active).',
'first_name' => 'Prenom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).',
'last_name' => 'Nom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).',
'phone_primary' => 'Telephone principal — stocke en chiffres uniquement (RG-1.20). Obligatoire.',
'phone_secondary' => 'Telephone secondaire optionnel — chiffres uniquement (RG-1.20).',
'email' => 'Email principal (lowercase serveur, RG-1.21). NON unique (RG-1.17 supprimee, Q4).',
'distributor_id' => 'FK auto-referente vers un client porteur de la categorie DISTRIBUTEUR — exclusive avec broker_id (RG-1.03, chk_client_distrib_or_broker). FK -> client.id, ON DELETE SET NULL.',
'broker_id' => 'FK auto-referente vers un client porteur de la categorie COURTIER — exclusive avec distributor_id (RG-1.03). FK -> client.id, ON DELETE SET NULL.',
'triage_service' => 'Drapeau service triage active pour le client. Faux par defaut.',
'description' => 'Onglet Information : description libre. Obligatoire pour le role Commerciale (RG-1.04), optionnel sinon.',
'competitors' => 'Onglet Information : concurrents identifies (texte libre ≤ 255). Obligatoire role Commerciale (RG-1.04).',
'founded_at' => 'Onglet Information : date de creation de l entreprise. Obligatoire role Commerciale (RG-1.04).',
'employees_count' => 'Onglet Information : effectif (entier >= 0). Obligatoire role Commerciale (RG-1.04).',
'revenue_amount' => 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).',
'director_name' => 'Onglet Information : nom du dirigeant. Obligatoire role Commerciale (RG-1.04).',
'profit_amount' => 'Onglet Information : resultat / benefice (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).',
'siren' => 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-1.15 supprimee, Q4).',
'account_number' => 'Onglet Comptabilite : numero de compte comptable du client.',
'tva_mode_id' => 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id, ON DELETE RESTRICT.',
'n_tva' => 'Onglet Comptabilite : numero de TVA intracommunautaire.',
'payment_delay_id' => 'Onglet Comptabilite : delai de reglement — FK -> payment_delay.id, ON DELETE RESTRICT.',
'payment_type_id' => 'Onglet Comptabilite : type de reglement — FK -> payment_type.id, ON DELETE RESTRICT. Code LCR impose >= 1 RIB (RG-1.13), VIREMENT impose une banque (RG-1.12).',
'bank_id' => 'Onglet Comptabilite : banque — FK -> bank.id, ON DELETE RESTRICT. Obligatoire si payment_type = VIREMENT (RG-1.12).',
'is_archived' => 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission commercial.clients.archive (RG-1.22/23).',
'archived_at' => 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration (RG-1.22/23).',
'deleted_at' => 'Horodatage du soft-delete technique (HP M2) — non expose par l API au M1. Null = ligne active.',
] + self::timestampableBlamableComments(),
'client_category' => [
'_table' => 'Jointure M2M client <-> category (Catalog) — categories metier du client (au moins une obligatoire).',
'client_id' => 'FK -> client.id, ON DELETE CASCADE — client porteur de la categorie.',
'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie rattachee au client.',
],
'client_contact' => [
'_table' => 'Contacts d un client (1:n) — au moins firstName OU lastName par contact (RG-1.05).',
'id' => 'Identifiant interne auto-incremente.',
'client_id' => 'FK -> client.id, ON DELETE CASCADE — client proprietaire du contact.',
'first_name' => 'Prenom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-1.05, chk_client_contact_name).',
'last_name' => 'Nom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-1.05, chk_client_contact_name).',
'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).',
'phone_primary' => 'Telephone principal du contact — chiffres uniquement (RG-1.20).',
'phone_secondary' => 'Telephone secondaire du contact — chiffres uniquement (RG-1.20).',
'email' => 'Email du contact (lowercase serveur, RG-1.21).',
'position' => 'Ordre d affichage du contact dans la liste du client (croissant).',
] + self::timestampableBlamableComments(),
'client_address' => [
'_table' => 'Adresses d un client (1:n) — prospect exclusif de livraison/facturation (RG-1.06/07/08), >= 1 site rattache (RG-1.10).',
'id' => 'Identifiant interne auto-incremente.',
'client_id' => 'FK -> client.id, ON DELETE CASCADE — client proprietaire de l adresse.',
'is_prospect' => 'Adresse de prospection — exclusive de is_delivery/is_billing (RG-1.06/07/08, chk_client_address_prospect_exclusive). Faux par defaut.',
'is_delivery' => 'Adresse de livraison. Exclusive de is_prospect. Faux par defaut.',
'is_billing' => 'Adresse de facturation. Exclusive de is_prospect. Impose billing_email (RG-1.11). Faux par defaut.',
'country' => 'Pays de l adresse — defaut France.',
'postal_code' => 'Code postal (4-5 chiffres attendus, RG-1.09).',
'city' => 'Ville — preremplie depuis le code postal via API BAN cote front (RG-1.09).',
'street' => 'Numero et voie de l adresse.',
'street_complement' => 'Complement d adresse (etage, batiment...) — optionnel.',
'billing_email' => 'Email de facturation — obligatoire si is_billing, null sinon (RG-1.11, chk_client_address_billing_email).',
'position' => 'Ordre d affichage de l adresse dans la liste du client (croissant).',
] + self::timestampableBlamableComments(),
'client_address_site' => [
'_table' => 'Jointure M2M client_address <-> site (Sites) — sites desservis par l adresse (>= 1 obligatoire, RG-1.10).',
'client_address_id' => 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.',
'site_id' => 'FK -> site.id, ON DELETE RESTRICT — site rattache a l adresse.',
],
'client_address_contact' => [
'_table' => 'Jointure M2M client_address <-> client_contact — contacts associes a une adresse.',
'client_address_id' => 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.',
'client_contact_id' => 'FK -> client_contact.id, ON DELETE CASCADE — contact associe a l adresse.',
],
'client_address_category' => [
'_table' => 'Jointure M2M client_address <-> category — categories d adresse (types SECTEUR/AUTRE uniquement, RG-1.29).',
'client_address_id' => 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.',
'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse (type SECTEUR ou AUTRE, RG-1.29).',
],
'client_rib' => [
'_table' => 'Coordonnees bancaires d un client (1:n) — >= 1 RIB obligatoire si payment_type = LCR (RG-1.13). Tous les champs audites (pas d AuditIgnore).',
'id' => 'Identifiant interne auto-incremente.',
'client_id' => 'FK -> client.id, ON DELETE CASCADE — client 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 client (croissant).',
] + self::timestampableBlamableComments(),
];
}
/**
* 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.
*
* @param null|list<string> $onlyTables Restreint la generation a ces tables
* (utile pour la migration retrofit qui
* ne doit commenter que les tables deja
* presentes a son instant T les tables
* des modules crees plus tard posent
* leurs propres COMMENT). null = tout.
*
* @return list<string>
*/
public static function toSqlStatements(?array $onlyTables = null): array
{
$allowed = null === $onlyTables ? null : array_fill_keys($onlyTables, true);
$statements = [];
foreach (self::comments() as $table => $entries) {
if (null !== $allowed && !isset($allowed[$table])) {
continue;
}
$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,65 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Doctrine;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\PrePersistEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Events;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Listener Doctrine global qui remplit automatiquement les colonnes
* Timestampable + Blamable.
*
* Pattern aligne sur AuditListener (cf.
* src/Module/Core/Infrastructure/Doctrine/AuditListener.php) : declare via
* #[AsDoctrineListener], auto-wire par le DoctrineBundle.
*
* Regle Blamable : si aucun utilisateur n'est authentifie (CLI, cron,
* migration), les FK `created_by` / `updated_by` restent a null. L'affichage
* front gere le libelle « Systeme » pour null.
*/
#[AsDoctrineListener(event: Events::prePersist)]
#[AsDoctrineListener(event: Events::preUpdate)]
final class TimestampableBlamableSubscriber
{
public function __construct(private readonly Security $security) {}
public function prePersist(PrePersistEventArgs $args): void
{
$entity = $args->getObject();
$now = new DateTimeImmutable();
$user = $this->security->getUser();
if ($entity instanceof TimestampableInterface) {
$entity->setCreatedAt($now);
$entity->setUpdatedAt($now);
}
if ($entity instanceof BlamableInterface && $user instanceof UserInterface) {
$entity->setCreatedBy($user);
$entity->setUpdatedBy($user);
}
}
public function preUpdate(PreUpdateEventArgs $args): void
{
$entity = $args->getObject();
$user = $this->security->getUser();
if ($entity instanceof TimestampableInterface) {
$entity->setUpdatedAt(new DateTimeImmutable());
}
if ($entity instanceof BlamableInterface && $user instanceof UserInterface) {
$entity->setUpdatedBy($user);
}
}
}
@@ -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.');
}
}
@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Tests\Architecture;
use App\Module\Catalog\Domain\Entity\CategoryType;
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\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Entity\Site;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use Doctrine\ORM\Mapping\Entity;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use Symfony\Component\Finder\Finder;
use function in_array;
/**
* Garde-fou architecture (niveau L3 de la spec § 2.8.bis).
*
* Scanne toutes les entites Doctrine sous `src/Module/<module>/Domain/Entity/`
* et verifie qu'elles implementent TimestampableInterface ET BlamableInterface
* (via TimestampableBlamableTrait). Empeche tout oubli du pattern sur une
* nouvelle entite metier : la CI passe au rouge.
*
* @internal
*/
final class EntitiesAreTimestampableBlamableTest extends TestCase
{
/**
* Entites explicitement exemptees du pattern.
*
* Au M0, on whiteliste les 4 entites preexistantes du noyau (creees avant
* l'introduction du pattern) : leur retrofit est une decision archi a part
* entiere, hors scope ERP-52.
*
* - User : referentiel d'authentification, createdAt gere manuellement dans
* le constructeur. Retrofit hors scope M0 (cf. HP-9) : impose de trancher
* la recursion Blamable (un User cree par un User) + casse des tests
* existants.
* - Role : referentiel RBAC synchronise via `app:sync-permissions`, pas de
* tracabilite user-driven necessaire.
* - Permission : idem Role (synchronise, pas pilote utilisateur).
* - Site : referentiel admin-managed, a integrer dans un futur module Sites
* v2 (cf. HP-10).
* - CategoryType : referentiel statique (codes de typage des categories),
* pas de besoin de tracabilite user-driven (cree par migration/seed,
* pas pilote utilisateur au M0). Cf. spec-back § 2.8.bis + RG-1.17.
* - TvaMode / PaymentDelay / PaymentType / Bank (M1 Commercial) : referentiels
* comptables statiques (id/code/label/position), seedes par migration +
* CommercialReferentialFixtures, lecture seule au M1 (HP-M2-2). Pas de
* tracabilite user-driven, meme justification que CategoryType. Cf.
* spec-back M1 § 2.6 + § 3.5.
*
* Les futurs referentiels statiques s'ajoutent ici avec une justification.
*/
private const EXCLUDED = [
User::class,
Role::class,
Permission::class,
Site::class,
CategoryType::class,
TvaMode::class,
PaymentDelay::class,
PaymentType::class,
Bank::class,
];
public function testAllBusinessEntitiesImplementBothInterfaces(): void
{
// Garde : chaque entree de la whitelist doit pointer sur une classe
// reelle. Empeche un FQCN errone de masquer silencieusement un oubli.
foreach (self::EXCLUDED as $excluded) {
self::assertTrue(class_exists($excluded), sprintf('Classe whitelistee inexistante : %s', $excluded));
}
$finder = new Finder()
->files()
->in(__DIR__.'/../../src/Module')
->path('Domain/Entity')
->name('*.php')
;
// Garde : si le scan ne trouve rien, le chemin est casse — le test
// deviendrait un faux positif vert. On verifie qu'il a du grain a moudre.
self::assertNotEmpty(iterator_to_array($finder), 'Aucune entite scannee : chemin src/Module invalide ?');
foreach ($finder as $file) {
$fqcn = $this->extractFqcn($file->getRealPath());
if (null === $fqcn || in_array($fqcn, self::EXCLUDED, true)) {
continue;
}
$reflection = new ReflectionClass($fqcn);
// On ignore les classes abstraites et tout ce qui n'est pas une
// entite Doctrine (value objects, embeddables non mappes, etc.).
if ($reflection->isAbstract() || [] === $reflection->getAttributes(Entity::class)) {
continue;
}
self::assertTrue(
$reflection->implementsInterface(TimestampableInterface::class)
&& $reflection->implementsInterface(BlamableInterface::class),
sprintf(
'L\'entite %s doit implementer TimestampableInterface ET BlamableInterface '
.'(utiliser TimestampableBlamableTrait). Si c\'est un referentiel statique '
.'justifie, l\'ajouter dans EntitiesAreTimestampableBlamableTest::EXCLUDED.',
$fqcn,
),
);
}
}
/**
* Extrait le FQCN (namespace + classe) d'un fichier PHP par lecture du
* source, sans charger le fichier.
*/
private function extractFqcn(string $path): ?string
{
$source = file_get_contents($path);
if (false === $source) {
return null;
}
if (
1 !== preg_match('/^namespace\s+([^;]+);/m', $source, $nsMatch)
|| 1 !== preg_match('/^(?:final\s+|abstract\s+|readonly\s+)*class\s+(\w+)/m', $source, $classMatch)
) {
return null;
}
return trim($nsMatch[1]).'\\'.$classMatch[1];
}
}

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