Files
Lesstime/docs/superpowers/specs/2026-06-22-directory-commercial-reports-design.md
T
matthieu 8313c759c6
Auto Tag Develop / tag (push) Successful in 9s
Migration modular monolith DDD (0.1 → 3.3) (#17)
## Migration modular monolith DDD — Lesstime (0.1 → 3.3)

Cette MR regroupe l'intégralité de la refonte en monolithe modulaire (strangler progressif, additif). Elle remplace les MR stackées de Phase 1 (#12–#16), désormais incluses ici.

**Ne pas merger avant validation fonctionnelle** : branche destinée à être testée telle quelle.

### Périmètre — 9 modules sous `src/Module/`
| Phase | Module | Contenu |
|------|--------|---------|
| 0.1 | (socle) | infrastructure modulaire, `ModuleInterface`, mapping Doctrine par module |
| 0.2 | (socle front) | auto-détection des layers Nuxt sous `frontend/modules/*` |
| 1.1 | **Core** | Identité (User/Auth), Notifications, Notifier |
| 1.2 | Core | RBAC fin (permissions `module.resource.action`, sidebar gated) |
| 1.3 | Core | Audit log (`#[Auditable]`, listener, provider DBAL) |
| 2.1 | **TimeTracking** | TimeEntry + MCP + export |
| 2.2 | **ProjectManagement** | cœur métier Projets/Tâches + 38 MCP tools |
| 2.3 | **Absence** | demandes, soldes, policies, justificatifs |
| 2.4 | **Directory** | Clients (migrés) + **Prospects** (nouveau, conversion → Client) |
| 2.5 | **Mail** | intégration IMAP OVH + liens tâches |
| 2.6 | **Integration** | Gitea / BookStack / Zimbra / Share |
| 3.1 | **Reporting** | rapports transverses (DBAL read-only, 0 import inter-module) |
| 3.2 | **ClientPortal** | portail client (ROLE_CLIENT cloisonné, tickets, notifications) |
| 3.3 | (finition) | nettoyage legacy — `src/Entity` vide, app 100% modulaire |

### Architecture
- Découplage inter-modules par **contrats** (`UserInterface`, `ProjectInterface`, `TaskInterface`, `TaskTagInterface`, `ClientInterface`, `ClientTicketInterface`, `LeaveProfileInterface`) + `resolve_target_entities` 100% modulaire (aucune cible legacy).
- Repositories : interface `Domain/Repository` + implémentation `Infrastructure/Doctrine`, bindées.
- Reporting en DBAL read-only pur (aucun import d'entité d'un autre module).
- Chaque migration de module : déplacement à comportement préservé (API publique et noms d'outils MCP inchangés), migrations **additives** uniquement (zéro destructif).

### Sécurité
- ROLE_CLIENT cloisonné : un utilisateur client n'accède qu'à `/portal` et à ses propres tickets (filtrés par `allowedProjects`), interdit sur toute l'API interne.
- Correctif : interdiction pour un client de créer un lien vers le partage SMB (upload uniquement).

### QA non-régression (branche reconstruite from scratch)
- Migrations from scratch + fixtures : OK.
- Compilation dev + prod : OK.
- **180 tests PHPUnit verts**, php-cs-fixer clean, ~96 routes, **66 outils MCP** tous sous `App\Module\*`.
- Smoke test runtime multi-rôles (admin / ROLE_USER / ROLE_CLIENT) : 44 vérifications HTTP, **0 écart**, cloisonnement client étanche.
- Build Nuxt OK, 9 layers, 0 import legacy résiduel.

### Points à arbitrer (hors périmètre de cette migration)
- Durcissement MCP/IDOR pré-existant (`userId` explicite sans scoping sur certains tools TimeTracking/Absence/TaskDocument) — ticket dédié recommandé.
- Validation fonctionnelle de **Prospect** et **ClientPortal** (conçus depuis les specs disque).
- **Harmonisation visuelle Malio finale** (3.3) — finition esthétique inter-modules laissée au PO.

---

## ⚠️ Déploiement / migration des données — à ne pas oublier

### 1. Resynchroniser les séquences PostgreSQL après tout import/restore de dump
Si la prod (ou tout environnement) est **montée depuis un dump** (`pg_restore` / `COPY`), les lignes sont chargées avec leurs `id` explicites **sans avancer les séquences** → au premier `INSERT` : `duplicate key value violates unique constraint "..._pkey"` (constaté en local sur `notification`, `task`, `time_entry`…).

À lancer **juste après chaque restore/import** :

```sql
DO $$
DECLARE r RECORD; maxid BIGINT; seq TEXT;
BEGIN
  FOR r IN SELECT table_name, column_name FROM information_schema.columns WHERE table_schema='public'
  LOOP
    seq := pg_get_serial_sequence(quote_ident(r.table_name), r.column_name);
    IF seq IS NOT NULL THEN
      EXECUTE format('SELECT COALESCE(MAX(%I),0) FROM %I', r.column_name, r.table_name) INTO maxid;
      PERFORM setval(seq, GREATEST(maxid,1), maxid > 0);
    END IF;
  END LOOP;
END $$;
```

> Ne concerne **pas** une prod qui tourne déjà (séquences avancées organiquement) — uniquement le cas restore/import. Idempotent, sans risque.

### 2. Fix dénormalisation des collections typées-contrat (code, inclus dans la branche)
Les relations **to-many** typées par une interface `Shared\Domain\Contract\*` (`TimeEntry::tags` → `TaskTagInterface`, `Task::collaborators` → `UserInterface`) étaient **indénormalisables par API Platform** (mono-valué OK via IRI, collection KO) → **tout POST/PATCH portant une telle collection renvoyait 400/500**. Corrigé par un dénormaliseur générique `ContractRelationDenormalizer` (réutilise `resolve_target_entities`, zéro couplage par-entité) + test fonctionnel de non-régression.

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #17
2026-06-23 13:50:42 +00:00

204 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Répertoire — Contacts, Adresses & Rapports commerciaux
**Date :** 2026-06-22
**Module :** `Directory` (Lesstime)
**Statut :** Conception validée — prêt pour plan d'implémentation
## Contexte & objectif
Le module `Directory` gère aujourd'hui `Client` et `Prospect` de façon volontairement
minimaliste : champs à plat (`name`, `email`, `phone`, `street`, `city`, `postalCode`),
adresse *inline*, aucun contact individuel, aucun suivi commercial. Le CRUD se fait via
des drawers sur une page unique `/directory` à deux onglets, sans fiche détail.
On veut transformer chaque fiche client/prospect en une **vraie fiche détail à onglets**,
inspirée du répertoire de Starseed (blocs répétables, sauvegarde indépendante par onglet,
validation 422 inline), avec trois onglets : **Contact**, **Adresse**, **Rapport**.
Le « rapport commercial » est un **journal de comptes-rendus** (objet + texte + date +
type d'échange + auteur) auquel on peut **joindre des documents**.
Décisions cadrées avec l'utilisateur :
- Contacts et adresses : **plusieurs** par fiche (blocs répétables, façon Starseed).
- UX : **fiche détail à route dédiée** (le clic sur une ligne ouvre la fiche, plus le drawer).
- Rapport = **comptes-rendus** (objet + texte + date + type) **avec documents joints**.
- Conversion prospect → client : **tout est repris** (contacts, adresses, rapports).
- Cible : **Lesstime** (Starseed sert uniquement de référence de design).
## Approche retenue
**Entités partagées via double-FK** : `Contact`, `Address`, `CommercialReport` sont
chacune rattachées à **un `Client` OU un `Prospect`** via deux FK nullables
(`client_id?`, `prospect_id?`) + une contrainte CHECK « exactly-one ».
C'est le pattern **déjà employé par `task_document`** (`task_id` / `client_ticket_id` +
CHECK `task_id IS NOT NULL OR client_ticket_id IS NOT NULL`) — on reste donc cohérent
avec le code existant. La conversion prospect→client se réduit à une **réaffectation de
FK** (pas de copie), ce qui préserve l'historique.
Alternative écartée : entités dupliquées par propriétaire (`ClientContact` +
`ProspectContact`, etc.) → 2× plus de tables/code et conversion par recopie.
## Modèle de données (backend — `src/Module/Directory`)
Toutes les nouvelles entités vivent dans le module `Directory`
(`Domain/Entity`, `Domain/Repository`, `Domain/Enum`, `Infrastructure/Doctrine`,
`Infrastructure/ApiPlatform`), suivent les traits `TimestampableBlamableTrait` et
sont `#[Auditable]` comme `Client`/`Prospect`.
### `Contact` (répétable)
| Champ | Type | Notes |
|-------|------|-------|
| id | int PK | |
| firstName | string? | |
| lastName | string? | |
| jobTitle | string? | fonction |
| email | string? | lowercase |
| phonePrimary | string? | |
| phoneSecondary | string? | |
| client | ManyToOne Client? | FK `client_id`, ON DELETE CASCADE |
| prospect | ManyToOne Prospect? | FK `prospect_id`, ON DELETE CASCADE |
Contrainte CHECK : `client_id IS NOT NULL OR prospect_id IS NOT NULL` (et au plus un des
deux, garanti par la logique applicative + index). « Sans contrainte » fonctionnelle : un
contact est valide dès qu'il a au moins un nom **ou** prénom (validation souple, façon
`isContactNamed` de Starseed).
### `Address` (répétable)
| Champ | Type | Notes |
|-------|------|-------|
| id | int PK | |
| label | string? | libellé libre (« Siège », « Facturation »…) |
| street | string? | |
| streetComplement | string? | |
| postalCode | string? | |
| city | string? | |
| country | string | défaut `FR` |
| client / prospect | ManyToOne ?, FK CASCADE | double-FK + CHECK |
### `CommercialReport` (compte-rendu, répétable)
| Champ | Type | Notes |
|-------|------|-------|
| id | int PK | |
| subject | string | objet du compte-rendu |
| body | text | le compte-rendu lui-même |
| occurredAt | date | date de l'échange |
| type | enum `ReportType` | `call` / `meeting` / `email` / `note` |
| author | ManyToOne User? | rempli via Blamable (utilisateur connecté) |
| documents | OneToMany ReportDocument | pièces jointes (voir section dédiée) |
| client / prospect | ManyToOne ?, FK CASCADE | double-FK + CHECK |
`ReportType` (enum, libellés FR) : Appel, Rendez-vous, Email, Note.
### Migration de l'adresse *inline*
Les colonnes `street`, `city`, `postal_code` de `client` et `prospect` sont **migrées**
vers une première ligne `Address` (data migration : pour chaque client/prospect ayant une
adresse non vide, créer une `Address` rattachée), puis **supprimées** des tables
`client`/`prospect` pour ne pas dédoubler la donnée. Les champs `name`, `email`, `phone`
restent sur `Client`/`Prospect` (identité principale).
### Documents des comptes-rendus
> **Correction post-exploration :** contrairement à une première hypothèse, `task_document`
> n'a **aucune** colonne propriétaire générique. La migration `Version20260522110000`
> (suppression du portail client) a **retiré** `client_ticket_id` de `task_document` et
> restauré `task_id` en `NOT NULL`. Le `TaskDocumentProcessor` **exige** une tâche.
> « Réutiliser TaskDocument » impose donc de le **généraliser** (FK + processor), ce qui
> recouple `ProjectManagement` ↔ `Directory`.
**Décision d'architecture (`ReportDocument` dédié — recommandé) :** créer une entité
`ReportDocument` **propre au module `Directory`**, qui réutilise le **même mécanisme de
stockage** (même paramètre `task_document_upload_dir`, mêmes validations MIME/taille, même
stratégie de download `BinaryFileResponse`), mais **sans** la mécanique SMB (inutile pour
des pièces jointes de compte-rendu). Cela préserve la frontière modulaire (pas de FK
croisée `ProjectManagement``Directory`) au prix d'une duplication maîtrisée du processor
et du controller de download (≈ 150 lignes, sans la partie SMB). Côté front, les composants
de preview/list de `ProjectManagement` sont **génériques** et réutilisés tels quels (ils ne
dépendent que du DTO document + de l'URL de download).
Entité `ReportDocument` (module `Directory`) : `id`, `commercialReport` (ManyToOne, FK
`commercial_report_id`, nullable:false, ON DELETE CASCADE), `originalName`, `fileName`,
`mimeType`, `size`, `createdAt`, `uploadedBy` (ManyToOne User, SET NULL). Endpoint
`POST /api/report_documents` (multipart, `deserialize:false`, `ReportDocumentProcessor`),
`GET /api/report_documents/{id}/download` (controller dédié, `priority: 1`),
`DELETE /api/report_documents/{id}` (listener `preRemove` qui `unlink` le fichier disque),
`GetCollection` filtrable par `commercialReport`.
## API Platform
Trois ressources (`Contact`, `Address`, `CommercialReport`) exposées avec :
- Opérations : `GetCollection`, `Get`, `Post`, `Patch`, `Delete`.
- Filtres : `SearchFilter` sur `client` et `prospect` (exact) pour charger la collection
d'une fiche donnée. Collections non paginées (aligné sur `Client`/`Prospect`).
- Sécurité : lecture `ROLE_USER`, écriture `ROLE_ADMIN` (pattern existant du module).
- Groupes de sérialisation : `contact:read`/`contact:write`, `address:read`/`address:write`,
`commercial_report:read`/`commercial_report:write`. `CommercialReport:read` embarque
`author` (id + username) et `documents`.
Permissions RBAC ajoutées au `Module::permissions()` :
`directory.reports.view`, `directory.reports.manage`. (Contacts/adresses couverts par
`directory.clients.*` / `directory.prospects.*` existants.)
## Conversion prospect → client
`ConvertProspectProcessor`
(`src/Module/Directory/Infrastructure/ApiPlatform/State/ConvertProspectProcessor.php`)
est étendu : après création/liaison du `Client`, pour chaque `Contact`, `Address` et
`CommercialReport` du prospect → set `client = <nouveau client>` et `prospect = null`.
Reste **idempotent** (si déjà converti, retourne inchangé). Les documents suivent
automatiquement (rattachés au `CommercialReport`, pas au prospect).
## Frontend (Nuxt — `frontend/modules/directory`)
### Liste & navigation
- `pages/directory.vue` (2 onglets Clients/Prospects, `MalioDataTable`) **reste**.
- Le clic sur une ligne ouvre désormais la **fiche détail** (`navigateTo`), au lieu du drawer.
- Le drawer (`ClientDrawer`/`ProspectDrawer`) est **conservé pour la création rapide**
(champs principaux : name/email/phone, + company/status/source/notes pour le prospect).
### Fiches détail
`pages/clients/[id].vue` et `pages/prospects/[id].vue` :
- En-tête : retour + titre + actions (archiver/supprimer selon droits).
- Bloc principal (identité : name/email/phone…), éditable en place.
- `MalioTabList` avec onglets **Contact**, **Adresse**, **Rapport** :
- **Contact** : `DirectoryContactBlock` répétable (ajout/suppression, sauvegarde par bloc
POST/PATCH, suppression = DELETE immédiat), validation 422 inline via `useFormErrors`.
- **Adresse** : `DirectoryAddressBlock` répétable, même mécanique.
- **Rapport** : liste des comptes-rendus (date, type badge, objet, auteur) + formulaire
d'ajout/édition (objet, type, date, corps) + zone documents (`ReportDocumentUpload` /
`ReportDocumentList`, calqués sur les composants `TaskDocument*` génériques).
Les blocs Contact/Adresse sont des composants **génériques** (mêmes pour client et prospect),
paramétrés par l'IRI du propriétaire (`client` ou `prospect`).
### Services & DTO
Nouveaux services `services/contacts.ts`, `services/addresses.ts`,
`services/commercial-reports.ts` (CRUD + filtre par owner) et DTO associés
(`dto/contact.ts`, `dto/address.ts`, `dto/commercial-report.ts`). Réutilisation du service
existant `task-documents.ts` via `uploadWithRelation('commercialReport', iri, file)`.
## i18n
Traductions FR ajoutées sous `directory.*` : libellés des onglets (Contact, Adresse,
Rapport), champs des trois entités, types de compte-rendu (Appel/Rendez-vous/Email/Note),
toasts de succès (créé/mis à jour/supprimé) et messages de validation.
## Tests (PHPUnit)
- Entités + contrainte CHECK double-FK (un contact/adresse/rapport ne peut être orphelin).
- Conversion : après convert, contacts/adresses/rapports du prospect pointent vers le
client (`prospect = null`), idempotence.
- Sécurité : lecture `ROLE_USER`, écriture refusée hors `ROLE_ADMIN`.
- Upload : un document peut être rattaché à un `CommercialReport` ; CHECK respecté.
- Data migration adresse inline → `Address` (au moins une adresse créée par client/prospect
ayant une adresse non vide).
> ⚠️ Base de test non isolée (les POST s'accumulent) : tester des **invariants**
> (relations, statuts, présence), pas des **counts absolus**.
## Hors périmètre (YAGNI)
- Pas de pipeline d'opportunités/affaires avec montants (le `status` du prospect suffit).
- Pas de dashboard/statistiques commerciales chiffrées.
- Pas de relance/prochaine action datée sur le compte-rendu (non retenu au cadrage).
- Pas de gestion de types d'adresse structurés (facturation/livraison) : `label` libre.