docs : add directory commercial reports spec and implementation plan
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,203 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user