From 1589908e4cbeadf3fa705d9bef297f8099552f39 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 22 Jun 2026 14:17:12 +0200 Subject: [PATCH] docs : add directory commercial reports spec and implementation plan --- ...2026-06-22-directory-commercial-reports.md | 3306 +++++++++++++++++ ...-22-directory-commercial-reports-design.md | 203 + 2 files changed, 3509 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-22-directory-commercial-reports.md create mode 100644 docs/superpowers/specs/2026-06-22-directory-commercial-reports-design.md diff --git a/docs/superpowers/plans/2026-06-22-directory-commercial-reports.md b/docs/superpowers/plans/2026-06-22-directory-commercial-reports.md new file mode 100644 index 0000000..8b66ddb --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-directory-commercial-reports.md @@ -0,0 +1,3306 @@ +# Répertoire — Contacts, Adresses & Rapports commerciaux — Plan d'implémentation + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Doter chaque fiche client/prospect du répertoire Lesstime de contacts multiples, adresses multiples et d'un journal de comptes-rendus commerciaux (avec pièces jointes), via une fiche détail à onglets inspirée de Starseed. + +**Architecture:** Trois entités répétables (`Contact`, `Address`, `CommercialReport`) dans le module `Directory`, chacune rattachée à un `Client` **ou** un `Prospect` via double-FK nullable + contrainte CHECK (pattern existant). Les pièces jointes des comptes-rendus passent par une entité `ReportDocument` dédiée au module (frontière modulaire préservée, réutilisant le même répertoire de stockage que `TaskDocument`). Frontend : fiches détail à route dédiée avec `MalioTabList`, blocs répétables, sauvegarde indépendante par entité. + +**Tech Stack:** PHP 8.4 / Symfony 8 / API Platform 4 / Doctrine ORM / PostgreSQL 16 ; Nuxt 4 / Vue 3 / Pinia / TypeScript / Tailwind / @malio/layer-ui. + +## Global Constraints + +- **Spec de référence :** `docs/superpowers/specs/2026-06-22-directory-commercial-reports-design.md`. +- **Backend :** `declare(strict_types=1)` en tête de chaque fichier PHP ; pas de controller pour le CRUD (API Platform : ApiResource + Provider/Processor) ; controllers custom sous `/api/` → `priority: 1` sur la route. +- **Sécurité API :** lecture `is_granted('ROLE_USER')`, écriture `is_granted('ROLE_ADMIN')` (pattern du module `Directory`). +- **Doctrine/PostgreSQL :** noms de colonnes en **minuscules** dans le SQL brut. +- **Module modular monolith :** code backend sous `src/Module/Directory/{Domain,Infrastructure}` ; relations cross-module via contrats `App\Shared\Domain\Contract\*` (ex. `UserInterface`). +- **Sérialisation :** pour embarquer une relation (et non un IRI), ajouter le groupe `*:read` du parent sur les propriétés de l'entité cible. +- **Upload :** valider le MIME via `$file->getMimeType()` (serveur), pas `getClientMimeType()`. Max 50 MB. Réutiliser le paramètre Symfony `task_document_upload_dir`. +- **Frontend :** TypeScript strict, 4 espaces d'indentation ; tous les appels API via `useApi()` ; collections lues via `extractHydraMembers` doivent être servies non paginées (`paginationEnabled: false`). +- **i18n :** toutes les chaînes UI dans `frontend/i18n/locales/fr.json` (pas de texte en dur). +- **Tests :** `make test` (PHPUnit). Base de test **non isolée** (les POST s'accumulent) → tester des **invariants** (relations, statuts, présence), **jamais des counts absolus**. +- **Commits :** `() : ` (espace autour du `:`). Aucune mention d'IA/Claude. Ne pas tagger/pusher. +- **Commandes utiles :** `make migration-migrate` (migrations), `make php-cs-fixer-allow-risky` (style PHP), `make dev-nuxt` (front hot reload), `make shell` (shell PHP). Test ciblé : `docker exec -i php-lesstime-fpm php bin/phpunit --filter `. + +## File Structure + +**Backend — `src/Module/Directory/`** +- `Domain/Enum/ReportType.php` — enum type d'échange (call/meeting/email/note) + libellés FR. +- `Domain/Entity/Contact.php` — contact répétable (double-FK client/prospect). +- `Domain/Entity/Address.php` — adresse répétable (double-FK). +- `Domain/Entity/CommercialReport.php` — compte-rendu (double-FK) + OneToMany ReportDocument. +- `Domain/Entity/ReportDocument.php` — pièce jointe d'un compte-rendu. +- `Domain/Repository/{Contact,Address,CommercialReport,ReportDocument}RepositoryInterface.php`. +- `Infrastructure/Doctrine/Doctrine{Contact,Address,CommercialReport,ReportDocument}Repository.php`. +- `Infrastructure/ApiPlatform/State/ReportDocumentProcessor.php` — upload multipart. +- `Infrastructure/Controller/ReportDocumentDownloadController.php` — download. +- `Infrastructure/EventListener/ReportDocumentListener.php` — `unlink` fichier au `preRemove`. +- `Infrastructure/ApiPlatform/State/ConvertProspectProcessor.php` — *modifié* (réaffectation). +- `DirectoryModule.php` — *modifié* (permissions reports). + +**Backend — racine** +- `migrations/Version2026XXXXXXXXXX.php` — tables + data migration adresse inline. +- `tests/Module/Directory/...` — tests fonctionnels. + +**Frontend — `frontend/modules/directory/`** +- `services/dto/{contact,address,commercial-report,report-document}.ts`. +- `services/{contacts,addresses,commercial-reports,report-documents}.ts`. +- `components/DirectoryContactBlock.vue`, `DirectoryAddressBlock.vue`. +- `components/CommercialReportTab.vue`, `ReportDocumentUpload.vue`, `ReportDocumentList.vue`. +- `pages/clients/[id].vue`, `pages/prospects/[id].vue`. +- `pages/directory.vue` — *modifié* (navigation vers fiche détail). +- `frontend/i18n/locales/fr.json` — *modifié*. + +--- + +## Task 1 : Enum `ReportType` + +**Files:** +- Create: `src/Module/Directory/Domain/Enum/ReportType.php` +- Test: `tests/Module/Directory/Domain/Enum/ReportTypeTest.php` + +**Interfaces:** +- Produces: `enum ReportType: string { Call='call'; Meeting='meeting'; Email='email'; Note='note'; public function label(): string }` + +- [ ] **Step 1 : Écrire le test qui échoue** + +```php +value); + self::assertSame('Appel', ReportType::Call->label()); + self::assertSame('Rendez-vous', ReportType::Meeting->label()); + self::assertSame('Email', ReportType::Email->label()); + self::assertSame('Note', ReportType::Note->label()); + } +} +``` + +- [ ] **Step 2 : Lancer le test, vérifier l'échec** + +Run: `docker exec -i php-lesstime-fpm php bin/phpunit --filter ReportTypeTest` +Expected: FAIL (classe `ReportType` inexistante). + +- [ ] **Step 3 : Implémenter l'enum** + +```php + 'Appel', + self::Meeting => 'Rendez-vous', + self::Email => 'Email', + self::Note => 'Note', + }; + } +} +``` + +- [ ] **Step 4 : Lancer le test, vérifier le succès** + +Run: `docker exec -i php-lesstime-fpm php bin/phpunit --filter ReportTypeTest` +Expected: PASS. + +- [ ] **Step 5 : Commit** + +```bash +git add src/Module/Directory/Domain/Enum/ReportType.php tests/Module/Directory/Domain/Enum/ReportTypeTest.php +git commit -m "feat(directory) : add ReportType enum for commercial reports" +``` + +--- + +## Task 2 : Entité `Contact` + repository + +**Files:** +- Create: `src/Module/Directory/Domain/Entity/Contact.php` +- Create: `src/Module/Directory/Domain/Repository/ContactRepositoryInterface.php` +- Create: `src/Module/Directory/Infrastructure/Doctrine/DoctrineContactRepository.php` + +**Interfaces:** +- Consumes: `App\Shared\Domain\Contract\TimestampableInterface`, `TimestampableBlamableTrait`, `Auditable`. +- Produces: `Contact` (ApiResource `/contacts`, groupes `contact:read`/`contact:write`, double ManyToOne `client`/`prospect`), `ContactRepositoryInterface::findById(int): ?Contact`. + +> Pas de table en base tant que la migration (Task 6) n'a pas tourné. Cette tâche se valide par l'absence d'erreur de mapping Doctrine (`schema:validate` mapping) ; la persistance est testée en Task 6. + +- [ ] **Step 1 : Créer l'entité** + +```php + ['contact:read']], + denormalizationContext: ['groups' => ['contact:write']], + order: ['lastName' => 'ASC'], +)] +#[ApiFilter(SearchFilter::class, properties: ['client' => 'exact', 'prospect' => 'exact'])] +#[ORM\Entity(repositoryClass: DoctrineContactRepository::class)] +#[ORM\Table(name: 'directory_contact')] +class Contact implements TimestampableInterface, BlamableInterface +{ + use TimestampableBlamableTrait; + + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['contact:read'])] + private ?int $id = null; + + #[ORM\Column(length: 255, nullable: true)] + #[Groups(['contact:read', 'contact:write', 'client:read', 'prospect:read'])] + private ?string $firstName = null; + + #[ORM\Column(length: 255, nullable: true)] + #[Groups(['contact:read', 'contact:write', 'client:read', 'prospect:read'])] + private ?string $lastName = null; + + #[ORM\Column(length: 255, nullable: true)] + #[Groups(['contact:read', 'contact:write', 'client:read', 'prospect:read'])] + private ?string $jobTitle = null; + + #[ORM\Column(length: 255, nullable: true)] + #[Groups(['contact:read', 'contact:write', 'client:read', 'prospect:read'])] + private ?string $email = null; + + #[ORM\Column(length: 50, nullable: true)] + #[Groups(['contact:read', 'contact:write', 'client:read', 'prospect:read'])] + private ?string $phonePrimary = null; + + #[ORM\Column(length: 50, nullable: true)] + #[Groups(['contact:read', 'contact:write', 'client:read', 'prospect:read'])] + private ?string $phoneSecondary = null; + + #[ORM\ManyToOne(targetEntity: Client::class)] + #[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] + #[Groups(['contact:read', 'contact:write'])] + private ?Client $client = null; + + #[ORM\ManyToOne(targetEntity: Prospect::class)] + #[ORM\JoinColumn(name: 'prospect_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] + #[Groups(['contact:read', 'contact:write'])] + private ?Prospect $prospect = null; + + public function getId(): ?int + { + return $this->id; + } + + 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 getEmail(): ?string + { + return $this->email; + } + + public function setEmail(?string $email): static + { + $this->email = $email; + + 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 getClient(): ?Client + { + return $this->client; + } + + public function setClient(?Client $client): static + { + $this->client = $client; + + return $this; + } + + public function getProspect(): ?Prospect + { + return $this->prospect; + } + + public function setProspect(?Prospect $prospect): static + { + $this->prospect = $prospect; + + return $this; + } +} +``` + +- [ ] **Step 2 : Créer l'interface de repository** + +```php + + */ +final class DoctrineContactRepository extends ServiceEntityRepository implements ContactRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Contact::class); + } + + public function findById(int $id): ?Contact + { + return $this->find($id); + } +} +``` + +- [ ] **Step 4 : Valider le mapping Doctrine** + +Run: `docker exec -i php-lesstime-fpm php bin/console doctrine:schema:validate --skip-sync` +Expected: `[Mapping] OK.` (le `--skip-sync` ignore le fait que la table n'existe pas encore). + +- [ ] **Step 5 : Commit** + +```bash +git add src/Module/Directory/Domain/Entity/Contact.php src/Module/Directory/Domain/Repository/ContactRepositoryInterface.php src/Module/Directory/Infrastructure/Doctrine/DoctrineContactRepository.php +git commit -m "feat(directory) : add Contact entity with client/prospect dual ownership" +``` + +--- + +## Task 3 : Entité `Address` + repository + +**Files:** +- Create: `src/Module/Directory/Domain/Entity/Address.php` +- Create: `src/Module/Directory/Domain/Repository/AddressRepositoryInterface.php` +- Create: `src/Module/Directory/Infrastructure/Doctrine/DoctrineAddressRepository.php` + +**Interfaces:** +- Produces: `Address` (ApiResource `/addresses`, groupes `address:read`/`address:write`, double ManyToOne), `AddressRepositoryInterface::findById(int): ?Address`. + +- [ ] **Step 1 : Créer l'entité** + +```php + ['address:read']], + denormalizationContext: ['groups' => ['address:write']], + order: ['id' => 'ASC'], +)] +#[ApiFilter(SearchFilter::class, properties: ['client' => 'exact', 'prospect' => 'exact'])] +#[ORM\Entity(repositoryClass: DoctrineAddressRepository::class)] +#[ORM\Table(name: 'directory_address')] +class Address implements TimestampableInterface, BlamableInterface +{ + use TimestampableBlamableTrait; + + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['address:read'])] + private ?int $id = null; + + #[ORM\Column(length: 255, nullable: true)] + #[Groups(['address:read', 'address:write', 'client:read', 'prospect:read'])] + private ?string $label = null; + + #[ORM\Column(length: 255, nullable: true)] + #[Groups(['address:read', 'address:write', 'client:read', 'prospect:read'])] + private ?string $street = null; + + #[ORM\Column(length: 255, nullable: true)] + #[Groups(['address:read', 'address:write', 'client:read', 'prospect:read'])] + private ?string $streetComplement = null; + + #[ORM\Column(length: 20, nullable: true)] + #[Groups(['address:read', 'address:write', 'client:read', 'prospect:read'])] + private ?string $postalCode = null; + + #[ORM\Column(length: 255, nullable: true)] + #[Groups(['address:read', 'address:write', 'client:read', 'prospect:read'])] + private ?string $city = null; + + #[ORM\Column(length: 2)] + #[Groups(['address:read', 'address:write', 'client:read', 'prospect:read'])] + private string $country = 'FR'; + + #[ORM\ManyToOne(targetEntity: Client::class)] + #[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] + #[Groups(['address:read', 'address:write'])] + private ?Client $client = null; + + #[ORM\ManyToOne(targetEntity: Prospect::class)] + #[ORM\JoinColumn(name: 'prospect_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] + #[Groups(['address:read', 'address:write'])] + private ?Prospect $prospect = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setLabel(?string $label): static + { + $this->label = $label; + + 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 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 getCountry(): string + { + return $this->country; + } + + public function setCountry(string $country): static + { + $this->country = $country; + + return $this; + } + + public function getClient(): ?Client + { + return $this->client; + } + + public function setClient(?Client $client): static + { + $this->client = $client; + + return $this; + } + + public function getProspect(): ?Prospect + { + return $this->prospect; + } + + public function setProspect(?Prospect $prospect): static + { + $this->prospect = $prospect; + + return $this; + } +} +``` + +- [ ] **Step 2 : Créer l'interface de repository** + +```php + + */ +final class DoctrineAddressRepository extends ServiceEntityRepository implements AddressRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Address::class); + } + + public function findById(int $id): ?Address + { + return $this->find($id); + } +} +``` + +- [ ] **Step 4 : Valider le mapping Doctrine** + +Run: `docker exec -i php-lesstime-fpm php bin/console doctrine:schema:validate --skip-sync` +Expected: `[Mapping] OK.` + +- [ ] **Step 5 : Commit** + +```bash +git add src/Module/Directory/Domain/Entity/Address.php src/Module/Directory/Domain/Repository/AddressRepositoryInterface.php src/Module/Directory/Infrastructure/Doctrine/DoctrineAddressRepository.php +git commit -m "feat(directory) : add Address entity with client/prospect dual ownership" +``` + +--- + +## Task 4 : Entité `CommercialReport` + repository + +**Files:** +- Create: `src/Module/Directory/Domain/Entity/CommercialReport.php` +- Create: `src/Module/Directory/Domain/Repository/CommercialReportRepositoryInterface.php` +- Create: `src/Module/Directory/Infrastructure/Doctrine/DoctrineCommercialReportRepository.php` + +**Files (suite) :** +- Create: `src/Module/Directory/Infrastructure/EventListener/CommercialReportAuthorListener.php` +- Modify: `config/services.yaml` + +**Interfaces:** +- Consumes: `ReportType` (Task 1), `UserInterface` (Shared contract), `ReportDocument` (Task 5 — déclarée ici en OneToMany ; l'entité `ReportDocument` est créée en Task 5, qui doit être appliquée avant la migration Task 6). +- Produces: `CommercialReport` (ApiResource `/commercial_reports`, groupes `commercial_report:read`/`commercial_report:write`), `CommercialReportRepositoryInterface::findById(int): ?CommercialReport`. `author` renseigné automatiquement au `prePersist` avec l'utilisateur connecté. + +> ⚠️ Ordre : cette entité référence `ReportDocument::class` (Task 5). Implémenter Task 5 juste après, avant la migration (Task 6) et toute exécution. +> ⚠️ `author` n'est PAS alimenté par le trait Blamable (qui gère `created_by`). Il est rempli par un listener dédié (Steps 4–5 ci-dessous), et n'a volontairement pas de groupe `:write` (non modifiable par le client). + +- [ ] **Step 1 : Créer l'entité** + +```php + ['commercial_report:read']], + denormalizationContext: ['groups' => ['commercial_report:write']], + order: ['occurredAt' => 'DESC'], +)] +#[ApiFilter(SearchFilter::class, properties: ['client' => 'exact', 'prospect' => 'exact'])] +#[ORM\Entity(repositoryClass: DoctrineCommercialReportRepository::class)] +#[ORM\Table(name: 'commercial_report')] +class CommercialReport implements TimestampableInterface +{ + use TimestampableBlamableTrait; + + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['commercial_report:read'])] + private ?int $id = null; + + #[ORM\Column(length: 255)] + #[Groups(['commercial_report:read', 'commercial_report:write'])] + private ?string $subject = null; + + #[ORM\Column(type: Types::TEXT, nullable: true)] + #[Groups(['commercial_report:read', 'commercial_report:write'])] + private ?string $body = null; + + #[ORM\Column(type: Types::DATE_IMMUTABLE)] + #[Groups(['commercial_report:read', 'commercial_report:write'])] + private ?DateTimeImmutable $occurredAt = null; + + #[ORM\Column(type: Types::STRING, length: 32, enumType: ReportType::class)] + #[Groups(['commercial_report:read', 'commercial_report:write'])] + private ReportType $type = ReportType::Note; + + #[ORM\ManyToOne(targetEntity: UserInterface::class)] + #[ORM\JoinColumn(name: 'author_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] + #[Groups(['commercial_report:read'])] + private ?UserInterface $author = null; + + #[ORM\ManyToOne(targetEntity: Client::class)] + #[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] + #[Groups(['commercial_report:read', 'commercial_report:write'])] + private ?Client $client = null; + + #[ORM\ManyToOne(targetEntity: Prospect::class)] + #[ORM\JoinColumn(name: 'prospect_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] + #[Groups(['commercial_report:read', 'commercial_report:write'])] + private ?Prospect $prospect = null; + + /** @var Collection */ + #[ORM\OneToMany(targetEntity: ReportDocument::class, mappedBy: 'commercialReport', cascade: ['remove'])] + #[Groups(['commercial_report:read'])] + private Collection $documents; + + public function __construct() + { + $this->documents = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getSubject(): ?string + { + return $this->subject; + } + + public function setSubject(string $subject): static + { + $this->subject = $subject; + + return $this; + } + + public function getBody(): ?string + { + return $this->body; + } + + public function setBody(?string $body): static + { + $this->body = $body; + + return $this; + } + + public function getOccurredAt(): ?DateTimeImmutable + { + return $this->occurredAt; + } + + public function setOccurredAt(DateTimeImmutable $occurredAt): static + { + $this->occurredAt = $occurredAt; + + return $this; + } + + public function getType(): ReportType + { + return $this->type; + } + + public function setType(ReportType $type): static + { + $this->type = $type; + + return $this; + } + + public function getAuthor(): ?UserInterface + { + return $this->author; + } + + public function setAuthor(?UserInterface $author): static + { + $this->author = $author; + + return $this; + } + + public function getClient(): ?Client + { + return $this->client; + } + + public function setClient(?Client $client): static + { + $this->client = $client; + + return $this; + } + + public function getProspect(): ?Prospect + { + return $this->prospect; + } + + public function setProspect(?Prospect $prospect): static + { + $this->prospect = $prospect; + + return $this; + } + + /** @return Collection */ + public function getDocuments(): Collection + { + return $this->documents; + } +} +``` + +- [ ] **Step 2 : Créer l'interface de repository** + +```php + + */ +final class DoctrineCommercialReportRepository extends ServiceEntityRepository implements CommercialReportRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, CommercialReport::class); + } + + public function findById(int $id): ?CommercialReport + { + return $this->find($id); + } +} +``` + +- [ ] **Step 4 : Créer le listener qui renseigne l'auteur** + +`src/Module/Directory/Infrastructure/EventListener/CommercialReportAuthorListener.php` : + +```php +getAuthor()) { + return; + } + + $user = $this->security->getUser(); + + if ($user instanceof UserInterface) { + $report->setAuthor($user); + } + } +} +``` + +- [ ] **Step 5 : Enregistrer le listener dans `config/services.yaml`** + +```yaml + App\Module\Directory\Infrastructure\EventListener\CommercialReportAuthorListener: + tags: + - { name: doctrine.orm.entity_listener, entity: 'App\Module\Directory\Domain\Entity\CommercialReport', event: prePersist } +``` + +> Si l'autowiring du projet enregistre déjà tous les services de `src/`, seule la partie `tags` est nécessaire (vérifier le bloc `services:` par défaut de `config/services.yaml`). + +- [ ] **Step 6 : Commit** (validation Doctrine après Task 5, car référence `ReportDocument`) + +```bash +git add src/Module/Directory/Domain/Entity/CommercialReport.php src/Module/Directory/Domain/Repository/CommercialReportRepositoryInterface.php src/Module/Directory/Infrastructure/Doctrine/DoctrineCommercialReportRepository.php src/Module/Directory/Infrastructure/EventListener/CommercialReportAuthorListener.php config/services.yaml +git commit -m "feat(directory) : add CommercialReport entity with dual ownership and author" +``` + +--- + +## Task 5 : Entité `ReportDocument` + processor + download + listener + +**Files:** +- Create: `src/Module/Directory/Domain/Entity/ReportDocument.php` +- Create: `src/Module/Directory/Domain/Repository/ReportDocumentRepositoryInterface.php` +- Create: `src/Module/Directory/Infrastructure/Doctrine/DoctrineReportDocumentRepository.php` +- Create: `src/Module/Directory/Infrastructure/ApiPlatform/State/ReportDocumentProcessor.php` +- Create: `src/Module/Directory/Infrastructure/Controller/ReportDocumentDownloadController.php` +- Create: `src/Module/Directory/Infrastructure/EventListener/ReportDocumentListener.php` +- Modify: `config/services.yaml` (injecter `task_document_upload_dir` dans le processor/controller/listener) + +**Interfaces:** +- Consumes: `CommercialReport` (Task 4), `UserInterface`, paramètre Symfony `task_document_upload_dir`. +- Produces: `ReportDocument` (ApiResource `/report_documents`, groupes `report_document:read`/`report_document:write`), endpoint download `/api/report_documents/{id}/download`. + +- [ ] **Step 1 : Créer l'entité `ReportDocument`** + +```php + ['report_document:read']], + denormalizationContext: ['groups' => ['report_document:write']], + order: ['id' => 'DESC'], +)] +#[ApiFilter(SearchFilter::class, properties: ['commercialReport' => 'exact'])] +#[ORM\Entity] +#[ORM\Table(name: 'report_document')] +#[ORM\EntityListeners([ReportDocumentListener::class])] +class ReportDocument +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['report_document:read', 'commercial_report:read'])] + private ?int $id = null; + + #[ORM\ManyToOne(targetEntity: CommercialReport::class, inversedBy: 'documents')] + #[ORM\JoinColumn(name: 'commercial_report_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] + #[Groups(['report_document:read', 'report_document:write'])] + private ?CommercialReport $commercialReport = null; + + #[ORM\Column(length: 255)] + #[Groups(['report_document:read', 'commercial_report:read'])] + private ?string $originalName = null; + + #[ORM\Column(length: 255, nullable: true)] + #[Groups(['report_document:read', 'commercial_report:read'])] + private ?string $fileName = null; + + #[ORM\Column(length: 100)] + #[Groups(['report_document:read', 'commercial_report:read'])] + private ?string $mimeType = null; + + #[ORM\Column] + #[Groups(['report_document:read', 'commercial_report:read'])] + private ?int $size = null; + + #[ORM\Column(type: 'datetime_immutable')] + #[Groups(['report_document:read', 'commercial_report:read'])] + private ?DateTimeImmutable $createdAt = null; + + #[ORM\ManyToOne(targetEntity: UserInterface::class)] + #[ORM\JoinColumn(name: 'uploaded_by_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] + #[Groups(['report_document:read', 'commercial_report:read'])] + private ?UserInterface $uploadedBy = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getCommercialReport(): ?CommercialReport + { + return $this->commercialReport; + } + + public function setCommercialReport(?CommercialReport $commercialReport): static + { + $this->commercialReport = $commercialReport; + + return $this; + } + + public function getOriginalName(): ?string + { + return $this->originalName; + } + + public function setOriginalName(string $originalName): static + { + $this->originalName = $originalName; + + return $this; + } + + public function getFileName(): ?string + { + return $this->fileName; + } + + public function setFileName(?string $fileName): static + { + $this->fileName = $fileName; + + return $this; + } + + public function getMimeType(): ?string + { + return $this->mimeType; + } + + public function setMimeType(string $mimeType): static + { + $this->mimeType = $mimeType; + + return $this; + } + + public function getSize(): ?int + { + return $this->size; + } + + public function setSize(int $size): static + { + $this->size = $size; + + return $this; + } + + public function getCreatedAt(): ?DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(DateTimeImmutable $createdAt): static + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getUploadedBy(): ?UserInterface + { + return $this->uploadedBy; + } + + public function setUploadedBy(?UserInterface $uploadedBy): static + { + $this->uploadedBy = $uploadedBy; + + return $this; + } +} +``` + +- [ ] **Step 2 : Créer l'interface + le repository Doctrine** + +`src/Module/Directory/Domain/Repository/ReportDocumentRepositoryInterface.php` : + +```php + + */ +final class DoctrineReportDocumentRepository extends ServiceEntityRepository implements ReportDocumentRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ReportDocument::class); + } + + public function findById(int $id): ?ReportDocument + { + return $this->find($id); + } +} +``` + +- [ ] **Step 3 : Créer le processor d'upload** (calqué sur `TaskDocumentProcessor`, sans SMB) + +`src/Module/Directory/Infrastructure/ApiPlatform/State/ReportDocumentProcessor.php` : + +```php + + */ +final readonly class ReportDocumentProcessor implements ProcessorInterface +{ + private const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB + + private const ALLOWED_MIME_TYPES = [ + 'image/jpeg', 'image/png', 'image/gif', 'image/webp', + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'text/plain', 'text/csv', + 'application/zip', 'application/x-rar-compressed', 'application/gzip', + 'application/json', 'application/xml', 'text/xml', + ]; + + private const MIME_TO_EXTENSION = [ + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/webp' => 'webp', + 'application/pdf' => 'pdf', + 'application/msword' => 'doc', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx', + 'application/vnd.ms-excel' => 'xls', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx', + 'application/vnd.ms-powerpoint' => 'ppt', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx', + 'text/plain' => 'txt', + 'text/csv' => 'csv', + 'application/zip' => 'zip', + 'application/x-rar-compressed' => 'rar', + 'application/gzip' => 'gz', + 'application/json' => 'json', + 'application/xml' => 'xml', + 'text/xml' => 'xml', + ]; + + public function __construct( + private EntityManagerInterface $entityManager, + private Security $security, + private RequestStack $requestStack, + private string $uploadDir, + ) {} + + /** + * @param ReportDocument $data + */ + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ReportDocument + { + if (!$this->security->isGranted('ROLE_ADMIN')) { + throw new AccessDeniedHttpException('Creating report documents requires admin privileges.'); + } + + $request = $this->requestStack->getCurrentRequest(); + + if (null === $request) { + throw new BadRequestHttpException('No request available.'); + } + + $document = $this->createUpload($request); + $document->setCreatedAt(new DateTimeImmutable()); + $document->setUploadedBy($this->security->getUser()); + + $this->entityManager->persist($document); + $this->entityManager->flush(); + + return $document; + } + + private function createUpload(Request $request): ReportDocument + { + $file = $request->files->get('file'); + + if (null === $file || !$file->isValid()) { + throw new BadRequestHttpException('No valid file uploaded.'); + } + + if ($file->getSize() > self::MAX_FILE_SIZE) { + throw new BadRequestHttpException('File size exceeds 50 MB limit.'); + } + + $report = $this->resolveReport((string) $request->request->get('commercialReport', '')); + + $originalName = $file->getClientOriginalName(); + $mimeType = $file->getMimeType() ?: 'application/octet-stream'; + $fileSize = $file->getSize(); + + if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) { + throw new BadRequestHttpException(sprintf('File type "%s" is not allowed.', $mimeType)); + } + + $extension = self::MIME_TO_EXTENSION[$mimeType] ?? 'bin'; + $fileName = Uuid::v4()->toRfc4122().'.'.$extension; + + if (!is_dir($this->uploadDir)) { + mkdir($this->uploadDir, 0o775, true); + } + + $file->move($this->uploadDir, $fileName); + + $document = new ReportDocument(); + $document->setCommercialReport($report); + $document->setOriginalName($originalName); + $document->setFileName($fileName); + $document->setMimeType($mimeType); + $document->setSize($fileSize); + + return $document; + } + + private function resolveReport(string $iri): CommercialReport + { + if ('' === $iri) { + throw new BadRequestHttpException('A commercialReport IRI is required.'); + } + + $report = $this->entityManager->getRepository(CommercialReport::class)->find((int) basename($iri)); + + if (null === $report) { + throw new BadRequestHttpException('Commercial report not found.'); + } + + return $report; + } +} +``` + +- [ ] **Step 4 : Créer le controller de download** (calqué sur `TaskDocumentDownloadController`, fichier disque uniquement) + +`src/Module/Directory/Infrastructure/Controller/ReportDocumentDownloadController.php` : + +```php +repository->findById($id); + + if (null === $document || null === $document->getFileName()) { + throw new NotFoundHttpException('Document not found.'); + } + + $filePath = $this->uploadDir.'/'.$document->getFileName(); + + if (!is_file($filePath)) { + throw new NotFoundHttpException('File missing on disk.'); + } + + $response = new BinaryFileResponse($filePath); + $mimeType = (string) $document->getMimeType(); + + $disposition = in_array($mimeType, self::INLINE_MIME_TYPES, true) + ? ResponseHeaderBag::DISPOSITION_INLINE + : ResponseHeaderBag::DISPOSITION_ATTACHMENT; + + $response->setContentDisposition($disposition, (string) $document->getOriginalName()); + $response->headers->set('Content-Type', $mimeType); + $response->headers->set('X-Content-Type-Options', 'nosniff'); + + return $response; + } +} +``` + +- [ ] **Step 5 : Créer le listener de suppression** + +`src/Module/Directory/Infrastructure/EventListener/ReportDocumentListener.php` : + +```php +getFileName(); + + if (null === $fileName) { + return; + } + + $path = $this->uploadDir.'/'.$fileName; + + if (is_file($path) && !@unlink($path)) { + $this->logger->warning('Failed to delete report document file', ['path' => $path]); + } + } +} +``` + +- [ ] **Step 6 : Injecter le paramètre `task_document_upload_dir`** + +Dans `config/services.yaml`, ajouter (à la suite des autres définitions explicites de services) : + +```yaml + App\Module\Directory\Infrastructure\ApiPlatform\State\ReportDocumentProcessor: + arguments: + $uploadDir: '%task_document_upload_dir%' + + App\Module\Directory\Infrastructure\Controller\ReportDocumentDownloadController: + arguments: + $uploadDir: '%task_document_upload_dir%' + tags: ['controller.service_arguments'] + + App\Module\Directory\Infrastructure\EventListener\ReportDocumentListener: + arguments: + $uploadDir: '%task_document_upload_dir%' + tags: + - { name: doctrine.orm.entity_listener, entity: 'App\Module\Directory\Domain\Entity\ReportDocument', event: preRemove } +``` + +> Vérifier d'abord comment `TaskDocumentProcessor`/`TaskDocumentListener` reçoivent `$uploadDir` et sont tagués (`grep -n "task_document_upload_dir\|TaskDocumentListener" config/services.yaml`) et reproduire exactement le même style (autowiring partiel vs définition explicite). + +- [ ] **Step 7 : Valider le mapping Doctrine de tout le module** + +Run: `docker exec -i php-lesstime-fpm php bin/console doctrine:schema:validate --skip-sync` +Expected: `[Mapping] OK.` + +- [ ] **Step 8 : Commit** + +```bash +git add src/Module/Directory/Domain/Entity/ReportDocument.php src/Module/Directory/Domain/Repository/ReportDocumentRepositoryInterface.php src/Module/Directory/Infrastructure/Doctrine/DoctrineReportDocumentRepository.php src/Module/Directory/Infrastructure/ApiPlatform/State/ReportDocumentProcessor.php src/Module/Directory/Infrastructure/Controller/ReportDocumentDownloadController.php src/Module/Directory/Infrastructure/EventListener/ReportDocumentListener.php config/services.yaml +git commit -m "feat(directory) : add ReportDocument storage (entity, upload processor, download, cleanup)" +``` + +--- + +## Task 6 : Migration — tables + data migration adresse inline + +**Files:** +- Create: `migrations/Version2026XXXXXXXXXX.php` (généré puis édité) +- Modify: `src/Module/Directory/Domain/Entity/Client.php` (retrait colonnes inline `street`/`city`/`postalCode`) +- Modify: `src/Module/Directory/Domain/Entity/Prospect.php` (idem) + +**Interfaces:** +- Consumes: les 4 entités des Tasks 2–5. +- Produces: tables `directory_contact`, `directory_address`, `commercial_report`, `report_document` ; contraintes CHECK d'appartenance ; suppression des colonnes inline d'adresse sur `client`/`prospect` (données migrées vers `directory_address`). + +- [ ] **Step 1 : Retirer les colonnes inline d'adresse de `Client`** + +Dans `src/Module/Directory/Domain/Entity/Client.php`, **supprimer** les 3 propriétés `street`, `city`, `postalCode` (lignes 61–71 actuelles) **et** leurs getters/setters (`getStreet/setStreet/getCity/setCity/getPostalCode/setPostalCode`). Conserver `name`, `email`, `phone`, `projects`. + +- [ ] **Step 2 : Retirer les colonnes inline d'adresse de `Prospect`** + +Dans `src/Module/Directory/Domain/Entity/Prospect.php`, **supprimer** les propriétés `street`, `city`, `postalCode` (lignes 74–84 actuelles) et leurs getters/setters. Conserver `name`, `company`, `email`, `phone`, `status`, `source`, `notes`, `convertedClient`. + +- [ ] **Step 3 : Générer le diff de migration** + +Run: `docker exec -i php-lesstime-fpm php bin/console doctrine:migrations:diff --no-interaction` +Expected: un fichier `migrations/Version2026XXXXXXXXXX.php` créé contenant les `CREATE TABLE` des 4 nouvelles tables, les FK, et les `ALTER TABLE client/prospect DROP COLUMN street/city/postal_code`. + +- [ ] **Step 4 : Éditer la migration — ajouter le CHECK et la data migration** + +Ouvrir le fichier généré. Dans `up()`, **avant** les `DROP COLUMN` sur `client`/`prospect`, insérer la copie des adresses inline existantes vers `directory_address`, et **après** les `CREATE TABLE`, ajouter les contraintes CHECK. Le corps de `up()` doit contenir (ordre important) : + +```php +public function up(Schema $schema): void +{ + // 1. CREATE TABLE directory_contact / directory_address / commercial_report / report_document + // + FK + index : conserver tel quel le SQL généré par le diff. + // (ne pas recopier ici : garder les addSql() générés) + + // 2. Contraintes CHECK d'appartenance (au moins un propriétaire). + $this->addSql("ALTER TABLE directory_contact ADD CONSTRAINT chk_contact_owner CHECK (client_id IS NOT NULL OR prospect_id IS NOT NULL)"); + $this->addSql("ALTER TABLE directory_address ADD CONSTRAINT chk_address_owner CHECK (client_id IS NOT NULL OR prospect_id IS NOT NULL)"); + $this->addSql("ALTER TABLE commercial_report ADD CONSTRAINT chk_report_owner CHECK (client_id IS NOT NULL OR prospect_id IS NOT NULL)"); + + // 3. Data migration : adresse inline -> directory_address (avant DROP COLUMN). + $this->addSql("INSERT INTO directory_address (label, street, postal_code, city, country, client_id, created_at, updated_at) + SELECT 'Principale', street, postal_code, city, 'FR', id, NOW(), NOW() + FROM client + WHERE COALESCE(street, '') <> '' OR COALESCE(city, '') <> '' OR COALESCE(postal_code, '') <> ''"); + $this->addSql("INSERT INTO directory_address (label, street, postal_code, city, country, prospect_id, created_at, updated_at) + SELECT 'Principale', street, postal_code, city, 'FR', id, NOW(), NOW() + FROM prospect + WHERE COALESCE(street, '') <> '' OR COALESCE(city, '') <> '' OR COALESCE(postal_code, '') <> ''"); + + // 4. DROP COLUMN street/city/postal_code sur client et prospect : conserver le SQL généré. +} +``` + +> ⚠️ Vérifier les noms de colonnes Timestampable réels sur `directory_address` après le diff (le trait peut générer `created_at`/`updated_at`/`created_by_id`/`updated_by_id`) et adapter la liste de colonnes de l'`INSERT` en conséquence. `created_by_id`/`updated_by_id` étant nullable, ne pas les inclure. Colonnes en **minuscules**. +> Dans `down()`, ajouter en premier les `ALTER TABLE client/prospect ADD street/city/postal_code` (recréation) générés par le diff, puis les `DROP TABLE`. La perte de données au `down()` est acceptable (dev). + +- [ ] **Step 5 : Appliquer la migration** + +Run: `docker exec -i php-lesstime-fpm php bin/console doctrine:migrations:migrate --no-interaction` +Expected: migration exécutée sans erreur ; `doctrine:schema:validate` → `[Mapping] OK` **et** `[Database] OK`. + +- [ ] **Step 6 : Vérifier les tables et le CHECK** + +Run: +```bash +docker exec -i php-lesstime-fpm php bin/console dbal:run-sql "SELECT conname FROM pg_constraint WHERE conname LIKE 'chk_%owner'" +``` +Expected: `chk_contact_owner`, `chk_address_owner`, `chk_report_owner`. + +- [ ] **Step 7 : Commit** + +```bash +git add migrations/Version2026XXXXXXXXXX.php src/Module/Directory/Domain/Entity/Client.php src/Module/Directory/Domain/Entity/Prospect.php +git commit -m "feat(directory) : add contact/address/report tables and migrate inline addresses" +``` + +--- + +## Task 7 : Conversion prospect → client réaffecte les sous-entités + +**Files:** +- Modify: `src/Module/Directory/Infrastructure/ApiPlatform/State/ConvertProspectProcessor.php` +- Test: `tests/Module/Directory/ConvertProspectProcessorTest.php` + +**Interfaces:** +- Consumes: `Contact`, `Address`, `CommercialReport` (Tasks 2–4), repositories Doctrine. +- Produces: après convert, les contacts/adresses/rapports du prospect ont `client = ` et `prospect = null`. + +- [ ] **Step 1 : Écrire le test fonctionnel qui échoue** + +```php +get(EntityManagerInterface::class); + + $prospect = new Prospect(); + $prospect->setName('Atelier Test'); + $prospect->setCompany('Atelier Test SARL'); + $em->persist($prospect); + + $contact = (new Contact())->setLastName('Durand')->setProspect($prospect); + $address = (new Address())->setCity('Niort')->setProspect($prospect); + $report = (new CommercialReport()) + ->setSubject('Premier contact') + ->setOccurredAt(new DateTimeImmutable('2026-06-01')) + ->setType(ReportType::Call) + ->setProspect($prospect); + $em->persist($contact); + $em->persist($address); + $em->persist($report); + $em->flush(); + + $processor = self::getContainer()->get(\App\Module\Directory\Infrastructure\ApiPlatform\State\ConvertProspectProcessor::class); + $operation = new \ApiPlatform\Metadata\Post(); + $processor->process($prospect, $operation, ['id' => $prospect->getId()]); + + $em->refresh($contact); + $em->refresh($address); + $em->refresh($report); + + $client = $prospect->getConvertedClient(); + self::assertNotNull($client, 'Prospect should be converted to a client'); + + // Invariants (pas de counts absolus) : chaque sous-entité pointe vers le client, plus vers le prospect. + self::assertSame($client->getId(), $contact->getClient()?->getId()); + self::assertNull($contact->getProspect()); + self::assertSame($client->getId(), $address->getClient()?->getId()); + self::assertNull($address->getProspect()); + self::assertSame($client->getId(), $report->getClient()?->getId()); + self::assertNull($report->getProspect()); + } +} +``` + +- [ ] **Step 2 : Lancer le test, vérifier l'échec** + +Run: `docker exec -i php-lesstime-fpm php bin/phpunit --filter ConvertProspectProcessorTest` +Expected: FAIL (le processor ne réaffecte pas encore ; `getClient()` est null). + +- [ ] **Step 3 : Modifier le processor** + +Remplacer le contenu de `ConvertProspectProcessor.php` par : + +```php + + */ +final readonly class ConvertProspectProcessor implements ProcessorInterface +{ + public function __construct( + private EntityManagerInterface $entityManager, + private ProspectRepositoryInterface $prospectRepository, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Prospect + { + $id = $uriVariables['id'] ?? null; + $prospect = is_numeric($id) ? $this->prospectRepository->findById((int) $id) : null; + + if (!$prospect instanceof Prospect) { + throw new NotFoundHttpException('Prospect not found.'); + } + + if (null !== $prospect->getConvertedClient()) { + return $prospect; + } + + $client = new Client(); + $client->setName($prospect->getCompany() ?: (string) $prospect->getName()); + $client->setEmail($prospect->getEmail()); + $client->setPhone($prospect->getPhone()); + + $this->entityManager->persist($client); + + $this->reassignContacts($prospect, $client); + $this->reassignAddresses($prospect, $client); + $this->reassignReports($prospect, $client); + + $prospect->setConvertedClient($client); + $prospect->setStatus(ProspectStatus::Won); + + $this->entityManager->flush(); + + return $prospect; + } + + private function reassignContacts(Prospect $prospect, Client $client): void + { + foreach ($this->entityManager->getRepository(Contact::class)->findBy(['prospect' => $prospect]) as $contact) { + $contact->setClient($client); + $contact->setProspect(null); + } + } + + private function reassignAddresses(Prospect $prospect, Client $client): void + { + foreach ($this->entityManager->getRepository(Address::class)->findBy(['prospect' => $prospect]) as $address) { + $address->setClient($client); + $address->setProspect(null); + } + } + + private function reassignReports(Prospect $prospect, Client $client): void + { + foreach ($this->entityManager->getRepository(CommercialReport::class)->findBy(['prospect' => $prospect]) as $report) { + $report->setClient($client); + $report->setProspect(null); + } + } +} +``` + +> Note : la copie des champs adresse inline (`street`/`city`/`postalCode`) est retirée du processor (colonnes supprimées en Task 6). Les adresses suivent désormais via `reassignAddresses`. + +- [ ] **Step 4 : Lancer le test, vérifier le succès** + +Run: `docker exec -i php-lesstime-fpm php bin/phpunit --filter ConvertProspectProcessorTest` +Expected: PASS. + +- [ ] **Step 5 : Commit** + +```bash +git add src/Module/Directory/Infrastructure/ApiPlatform/State/ConvertProspectProcessor.php tests/Module/Directory/ConvertProspectProcessorTest.php +git commit -m "feat(directory) : carry over contacts/addresses/reports on prospect conversion" +``` + +--- + +## Task 8 : Permissions RBAC du module + +**Files:** +- Modify: `src/Module/Directory/DirectoryModule.php` +- Test: `tests/Module/Directory/DirectoryModulePermissionsTest.php` + +**Interfaces:** +- Produces: `DirectoryModule::permissions()` inclut `directory.reports.view` et `directory.reports.manage`. + +- [ ] **Step 1 : Écrire le test qui échoue** + +```php + 'directory.reports.view', 'label' => 'Voir les comptes-rendus commerciaux'], + ['code' => 'directory.reports.manage', 'label' => 'Gérer les comptes-rendus commerciaux'], +``` + +- [ ] **Step 4 : Lancer le test, vérifier le succès** + +Run: `docker exec -i php-lesstime-fpm php bin/phpunit --filter DirectoryModulePermissionsTest` +Expected: PASS. + +- [ ] **Step 5 : Commit** + +```bash +git add src/Module/Directory/DirectoryModule.php tests/Module/Directory/DirectoryModulePermissionsTest.php +git commit -m "feat(directory) : declare commercial report RBAC permissions" +``` + +--- + +## Task 9 : Tests API d'appartenance et de sécurité + +**Files:** +- Test: `tests/Module/Directory/CommercialReportApiTest.php` + +**Interfaces:** +- Consumes: endpoints `/api/commercial_reports`, `/api/contacts`, fixtures users (`admin`/`admin`, `alice`/`alice`). + +> Réutiliser le pattern de test fonctionnel API existant du projet (`grep -rl "ApiTestCase\|createClient" tests/` pour trouver la classe de base et la façon de s'authentifier en JWT cookie). Adapter le helper d'auth ci-dessous au helper réel du projet. + +- [ ] **Step 1 : Écrire les tests (invariants, pas de counts)** + +```php +request('GET', '/api/commercial_reports'); + + self::assertGreaterThanOrEqual(401, $client->getResponse()->getStatusCode()); + } + + public function testNonAdminCannotCreateReport(): void + { + $client = self::createClient(); + // TODO(remplacer) : authentifier en ROLE_USER via le helper JWT du projet (cf. autres tests API). + $this->loginAs($client, 'alice', 'alice'); + + $target = $this->anyClientIri(); + $client->request('POST', '/api/commercial_reports', server: [ + 'CONTENT_TYPE' => 'application/ld+json', + ], content: json_encode([ + 'subject' => 'Test', + 'occurredAt' => '2026-06-01', + 'type' => 'call', + 'client' => $target, + ])); + + self::assertSame(403, $client->getResponse()->getStatusCode()); + } + + private function anyClientIri(): string + { + /** @var EntityManagerInterface $em */ + $em = self::getContainer()->get(EntityManagerInterface::class); + $client = $em->getRepository(Client::class)->findOneBy([]); + + self::assertNotNull($client, 'A client fixture is required'); + + return '/api/clients/'.$client->getId(); + } + + // loginAs(): à implémenter selon le helper d'auth JWT cookie du projet. +} +``` + +- [ ] **Step 2 : Adapter `loginAs` au helper réel** (lire un test API existant et reproduire l'auth JWT cookie). + +- [ ] **Step 3 : Lancer les tests** + +Run: `docker exec -i php-lesstime-fpm php bin/phpunit --filter CommercialReportApiTest` +Expected: PASS (anonyme rejeté, ROLE_USER interdit en écriture). + +- [ ] **Step 4 : Lancer la suite complète** + +Run: `make test` +Expected: suite verte. + +- [ ] **Step 5 : Commit** + +```bash +git add tests/Module/Directory/CommercialReportApiTest.php +git commit -m "test(directory) : api security for commercial reports" +``` + +--- + +## Task 10 : DTO frontend + +**Files:** +- Create: `frontend/modules/directory/services/dto/contact.ts` +- Create: `frontend/modules/directory/services/dto/address.ts` +- Create: `frontend/modules/directory/services/dto/commercial-report.ts` +- Create: `frontend/modules/directory/services/dto/report-document.ts` + +**Interfaces:** +- Produces: types `Contact`/`ContactWrite`, `Address`/`AddressWrite`, `CommercialReport`/`CommercialReportWrite`, `ReportType`, `ReportDocument`. + +- [ ] **Step 1 : `contact.ts`** + +```ts +export type Contact = { + id: number + '@id'?: string + firstName: string | null + lastName: string | null + jobTitle: string | null + email: string | null + phonePrimary: string | null + phoneSecondary: string | null + client?: string | null + prospect?: string | null +} + +export type ContactWrite = { + firstName: string | null + lastName: string | null + jobTitle: string | null + email: string | null + phonePrimary: string | null + phoneSecondary: string | null + client?: string | null + prospect?: string | null +} +``` + +- [ ] **Step 2 : `address.ts`** + +```ts +export type Address = { + id: number + '@id'?: string + label: string | null + street: string | null + streetComplement: string | null + postalCode: string | null + city: string | null + country: string + client?: string | null + prospect?: string | null +} + +export type AddressWrite = { + label: string | null + street: string | null + streetComplement: string | null + postalCode: string | null + city: string | null + country: string + client?: string | null + prospect?: string | null +} +``` + +- [ ] **Step 3 : `report-document.ts`** + +```ts +import type { UserData } from '~/services/dto/user' + +export type ReportDocument = { + '@id'?: string + id: number + commercialReport: string + originalName: string + fileName?: string | null + mimeType: string + size: number + createdAt: string + uploadedBy?: UserData | null +} +``` + +> Vérifier le chemin réel du type `UserData` (`grep -rn "export type UserData\|export interface UserData" frontend`) et ajuster l'import. À défaut, remplacer par `{ id: number, username: string } | null`. + +- [ ] **Step 4 : `commercial-report.ts`** + +```ts +import type { ReportDocument } from './report-document' + +export type ReportType = 'call' | 'meeting' | 'email' | 'note' + +export type CommercialReport = { + id: number + '@id'?: string + subject: string + body: string | null + occurredAt: string + type: ReportType + author?: { id: number, username: string } | null + client?: string | null + prospect?: string | null + documents?: ReportDocument[] + createdAt?: string + updatedAt?: string +} + +export type CommercialReportWrite = { + subject: string + body: string | null + occurredAt: string + type: ReportType + client?: string | null + prospect?: string | null +} +``` + +- [ ] **Step 5 : Vérifier la compilation TS** + +Run: `cd frontend && npx nuxi typecheck` (ou `npm run typecheck` si défini ; sinon `npx vue-tsc --noEmit`). +Expected: pas d'erreur sur les nouveaux fichiers. + +- [ ] **Step 6 : Commit** + +```bash +git add frontend/modules/directory/services/dto/ +git commit -m "feat(directory) : add frontend DTOs for contacts, addresses, reports" +``` + +--- + +## Task 11 : Services frontend + +**Files:** +- Create: `frontend/modules/directory/services/contacts.ts` +- Create: `frontend/modules/directory/services/addresses.ts` +- Create: `frontend/modules/directory/services/commercial-reports.ts` +- Create: `frontend/modules/directory/services/report-documents.ts` + +**Interfaces:** +- Consumes: DTO de Task 10, `useApi()`, `extractHydraMembers`. +- Produces: `useContactService`, `useAddressService`, `useCommercialReportService`, `useReportDocumentService` (CRUD + filtre par owner). + +- [ ] **Step 1 : `contacts.ts`** + +```ts +import type { Contact, ContactWrite } from './dto/contact' +import type { HydraCollection } from '~/utils/api' +import { extractHydraMembers } from '~/utils/api' + +type Owner = { client?: string, prospect?: string } + +export function useContactService() { + const api = useApi() + + async function getByOwner(owner: Owner): Promise { + const data = await api.get>('/contacts', owner as Record) + return extractHydraMembers(data) + } + + async function create(payload: ContactWrite): Promise { + return api.post('/contacts', payload as Record, { + toastSuccessKey: 'directory.contacts.saved', + }) + } + + async function update(id: number, payload: Partial): Promise { + return api.patch(`/contacts/${id}`, payload as Record, { + toastSuccessKey: 'directory.contacts.saved', + }) + } + + async function remove(id: number): Promise { + await api.delete(`/contacts/${id}`, {}, { toastSuccessKey: 'directory.contacts.deleted' }) + } + + return { getByOwner, create, update, remove } +} +``` + +- [ ] **Step 2 : `addresses.ts`** (même structure, ressource `/addresses`, clés i18n `directory.addresses.*`) + +```ts +import type { Address, AddressWrite } from './dto/address' +import type { HydraCollection } from '~/utils/api' +import { extractHydraMembers } from '~/utils/api' + +type Owner = { client?: string, prospect?: string } + +export function useAddressService() { + const api = useApi() + + async function getByOwner(owner: Owner): Promise { + const data = await api.get>('/addresses', owner as Record) + return extractHydraMembers(data) + } + + async function create(payload: AddressWrite): Promise
{ + return api.post
('/addresses', payload as Record, { + toastSuccessKey: 'directory.addresses.saved', + }) + } + + async function update(id: number, payload: Partial): Promise
{ + return api.patch
(`/addresses/${id}`, payload as Record, { + toastSuccessKey: 'directory.addresses.saved', + }) + } + + async function remove(id: number): Promise { + await api.delete(`/addresses/${id}`, {}, { toastSuccessKey: 'directory.addresses.deleted' }) + } + + return { getByOwner, create, update, remove } +} +``` + +- [ ] **Step 3 : `commercial-reports.ts`** + +```ts +import type { CommercialReport, CommercialReportWrite } from './dto/commercial-report' +import type { HydraCollection } from '~/utils/api' +import { extractHydraMembers } from '~/utils/api' + +type Owner = { client?: string, prospect?: string } + +export function useCommercialReportService() { + const api = useApi() + + async function getByOwner(owner: Owner): Promise { + const data = await api.get>('/commercial_reports', owner as Record) + return extractHydraMembers(data) + } + + async function create(payload: CommercialReportWrite): Promise { + return api.post('/commercial_reports', payload as Record, { + toastSuccessKey: 'directory.reports.saved', + }) + } + + async function update(id: number, payload: Partial): Promise { + return api.patch(`/commercial_reports/${id}`, payload as Record, { + toastSuccessKey: 'directory.reports.saved', + }) + } + + async function remove(id: number): Promise { + await api.delete(`/commercial_reports/${id}`, {}, { toastSuccessKey: 'directory.reports.deleted' }) + } + + return { getByOwner, create, update, remove } +} +``` + +- [ ] **Step 4 : `report-documents.ts`** (upload multipart + download URL, calqué sur `task-documents.ts`) + +```ts +import type { ReportDocument } from './dto/report-document' +import type { HydraCollection } from '~/utils/api' +import { extractHydraMembers } from '~/utils/api' +import { $fetch } from 'ofetch' + +export function useReportDocumentService() { + const api = useApi() + const config = useRuntimeConfig() + const baseURL = config.public.apiBase || '/api' + + async function getByReport(reportId: number): Promise { + const data = await api.get>('/report_documents', { + commercialReport: `/api/commercial_reports/${reportId}`, + }) + return extractHydraMembers(data) + } + + async function upload(reportId: number, file: File): Promise { + const formData = new FormData() + formData.append('file', file) + formData.append('commercialReport', `/api/commercial_reports/${reportId}`) + + return $fetch(`${baseURL}/report_documents`, { + method: 'POST', + body: formData, + credentials: 'include', + }) + } + + async function remove(id: number): Promise { + await api.delete(`/report_documents/${id}`, {}, { toastSuccessKey: 'directory.documents.deleted' }) + } + + function getDownloadUrl(id: number): string { + return `${baseURL}/report_documents/${id}/download` + } + + return { getByReport, upload, remove, getDownloadUrl } +} +``` + +- [ ] **Step 5 : Vérifier la compilation TS** — `cd frontend && npx nuxi typecheck`. Expected: pas d'erreur. + +- [ ] **Step 6 : Commit** + +```bash +git add frontend/modules/directory/services/ +git commit -m "feat(directory) : add frontend services for contacts, addresses, reports, documents" +``` + +--- + +## Task 12 : Composant `DirectoryContactBlock` + +**Files:** +- Create: `frontend/modules/directory/components/DirectoryContactBlock.vue` + +**Interfaces:** +- Consumes: type `Contact` (Task 10), composants `@malio/layer-ui` (`MalioInputText`, `MalioInputEmail`, `MalioButtonIcon`). +- Produces: bloc d'édition d'un contact ; props `modelValue: Contact`, `title: string`, `removable?: boolean`, `readonly?: boolean` ; events `update:modelValue`, `remove`. + +> Avant d'écrire : ouvrir `frontend/node_modules/@malio/layer-ui/COMPONENTS.md` pour confirmer les noms de props des inputs (`label`, `model-value`/`v-model`, `readonly`, `error`). Reproduire le style des drawers existants (`ProspectDrawer.vue`). + +- [ ] **Step 1 : Écrire le composant** + +```vue + + + +``` + +- [ ] **Step 2 : Vérifier le rendu** — `make dev-nuxt`, importer le composant dans une page de test ou attendre Task 16. Vérifier l'absence d'erreur console au build : `cd frontend && npx nuxi typecheck`. + +- [ ] **Step 3 : Commit** + +```bash +git add frontend/modules/directory/components/DirectoryContactBlock.vue +git commit -m "feat(directory) : add repeatable contact block component" +``` + +--- + +## Task 13 : Composant `DirectoryAddressBlock` + +**Files:** +- Create: `frontend/modules/directory/components/DirectoryAddressBlock.vue` + +**Interfaces:** +- Produces: bloc d'édition d'une adresse ; mêmes props/events que `DirectoryContactBlock` mais `modelValue: Address`. + +- [ ] **Step 1 : Écrire le composant** + +```vue + + + +``` + +- [ ] **Step 2 : Vérifier la compilation** — `cd frontend && npx nuxi typecheck`. Expected: pas d'erreur. + +- [ ] **Step 3 : Commit** + +```bash +git add frontend/modules/directory/components/DirectoryAddressBlock.vue +git commit -m "feat(directory) : add repeatable address block component" +``` + +--- + +## Task 14 : Composants documents de rapport (`ReportDocumentUpload`, `ReportDocumentList`) + +**Files:** +- Create: `frontend/modules/directory/components/ReportDocumentUpload.vue` +- Create: `frontend/modules/directory/components/ReportDocumentList.vue` + +**Interfaces:** +- Consumes: `useReportDocumentService` (Task 11), `ReportDocument` DTO. +- Produces: `ReportDocumentUpload` (prop `reportId: number`, event `uploaded`), `ReportDocumentList` (prop `documents: ReportDocument[]`, `isAdmin: boolean`, event `delete`). + +> S'inspirer de `frontend/modules/project-management/components/TaskDocumentUpload.vue` et `TaskDocumentList.vue` (les lire d'abord). Version simplifiée : upload simple + liste avec lien download + bouton suppression. Pas de SMB, pas de preview avancé (lien `getDownloadUrl` ouvert dans un onglet). + +- [ ] **Step 1 : `ReportDocumentUpload.vue`** + +```vue + + + +``` + +- [ ] **Step 2 : `ReportDocumentList.vue`** + +```vue + + + +``` + +- [ ] **Step 3 : Vérifier la compilation** — `cd frontend && npx nuxi typecheck`. Expected: pas d'erreur. + +- [ ] **Step 4 : Commit** + +```bash +git add frontend/modules/directory/components/ReportDocumentUpload.vue frontend/modules/directory/components/ReportDocumentList.vue +git commit -m "feat(directory) : add report document upload/list components" +``` + +--- + +## Task 15 : Onglet Rapport (`CommercialReportTab`) + +**Files:** +- Create: `frontend/modules/directory/components/CommercialReportTab.vue` + +**Interfaces:** +- Consumes: `useCommercialReportService` (Task 11), `ReportDocumentUpload`/`ReportDocumentList` (Task 14), composants Malio (`MalioInputText`, `MalioInputTextArea`, `MalioSelect`, `MalioDate`, `MalioButton`). +- Produces: onglet complet ; prop `owner: { client?: string, prospect?: string }`, `isAdmin: boolean`. Gère liste + formulaire d'ajout/édition + documents. + +- [ ] **Step 1 : Écrire le composant** + +```vue + + + +``` + +> Confirmer les props réelles de `MalioDate`, `MalioInputTextArea`, `MalioSelect` dans `COMPONENTS.md` (notamment `v-model` vs `model-value`/`@update:model-value`, et que `MalioSelect` accepte une valeur `string` — cf. CLAUDE.md). Ajuster si nécessaire. + +- [ ] **Step 2 : Vérifier la compilation** — `cd frontend && npx nuxi typecheck`. Expected: pas d'erreur. + +- [ ] **Step 3 : Commit** + +```bash +git add frontend/modules/directory/components/CommercialReportTab.vue +git commit -m "feat(directory) : add commercial report tab (list, form, documents)" +``` + +--- + +## Task 16 : Fiche détail Client + +**Files:** +- Create: `frontend/modules/directory/pages/clients/[id].vue` + +**Interfaces:** +- Consumes: `useClientService`, `useContactService`, `useAddressService`, blocs Task 12/13, `CommercialReportTab` (Task 15), `MalioTabList`. +- Produces: page `/directory/clients/:id` ; onglets Contact/Adresse/Rapport ; owner = `{ client: '/api/clients/:id' }`. + +> `useClientService` n'a pas `getById` aujourd'hui (`services/clients.ts`). Ajouter une méthode `getById(id)` au service clients (calquée sur prospects.ts) dans cette tâche. + +- [ ] **Step 1 : Ajouter `getById` au service clients** + +Dans `frontend/modules/directory/services/clients.ts`, ajouter à l'intérieur de `useClientService` (et l'exposer dans le `return`) : + +```ts + async function getById(id: number): Promise { + return api.get(`/clients/${id}`) + } +``` + +- [ ] **Step 2 : Écrire la page** + +```vue + + + +``` + +> Décision UX explicite : la sauvegarde par bloc se fait au `@update:model-value` (à la frappe). Si ça génère trop d'appels, ajouter un debounce ou un bouton « Enregistrer » par bloc dans une itération ultérieure (hors périmètre de ce plan). + +- [ ] **Step 3 : Vérifier le routage et le rendu** + +Run: `make dev-nuxt` puis naviguer vers `/directory/clients/1`. +Expected: page chargée, 3 onglets visibles, ajout d'un contact persiste (vérifier via `GET /api/contacts?client=/api/clients/1`). + +- [ ] **Step 4 : Commit** + +```bash +git add frontend/modules/directory/pages/clients/ frontend/modules/directory/services/clients.ts +git commit -m "feat(directory) : add client detail page with contact/address/report tabs" +``` + +--- + +## Task 17 : Fiche détail Prospect + +**Files:** +- Create: `frontend/modules/directory/pages/prospects/[id].vue` + +**Interfaces:** +- Identique à Task 16 mais owner = `{ prospect: '/api/prospects/:id' }`, service `useProspectService().getById` (déjà présent). + +- [ ] **Step 1 : Écrire la page** + +Reprendre **intégralement** la page de Task 16 (`clients/[id].vue`) en remplaçant : +- `useClientService` → `useProspectService` ; `clientService.getById` → `prospectService.getById` ; type `Client` → `Prospect` (import `~/modules/directory/services/dto/prospect`). +- `const ownerIri = '/api/prospects/${id}'` ; `const owner = { prospect: ownerIri }`. +- Dans `emptyContact`/`emptyAddress` et les payloads : remplacer `client: ownerIri` par `prospect: ownerIri`. +- `getByOwner({ client: ownerIri })` → `getByOwner({ prospect: ownerIri })` (contacts et adresses). + +Code complet : + +```vue + + + +``` + +- [ ] **Step 2 : Vérifier le rendu** — `/directory/prospects/1`. Expected: 3 onglets, ajout de contact persiste. + +- [ ] **Step 3 : Commit** + +```bash +git add frontend/modules/directory/pages/prospects/ +git commit -m "feat(directory) : add prospect detail page with contact/address/report tabs" +``` + +--- + +## Task 18 : Navigation liste → fiche détail + +**Files:** +- Modify: `frontend/modules/directory/pages/directory.vue` + +**Interfaces:** +- Consumes: pages détail Task 16/17. +- Produces: clic sur une ligne client/prospect → navigation vers la fiche détail (au lieu d'ouvrir le drawer). Le drawer reste pour la création (`openCreateClient`/`openCreateProspect`). + +- [ ] **Step 1 : Modifier les handlers de clic** + +Dans `frontend/modules/directory/pages/directory.vue`, remplacer `openEditClient` et `openEditProspect` par une navigation : + +```ts +function openEditClient(item: Record) { + navigateTo(`/directory/clients/${(item as Client).id}`) +} + +function openEditProspect(item: Record) { + navigateTo(`/directory/prospects/${(item as Prospect).id}`) +} +``` + +Laisser `selectedClient`/`selectedProspect` et les drawers en place **uniquement** pour la création (`openCreateClient`/`openCreateProspect` inchangés). Retirer `selectedClient.value = item` des anciens handlers. + +- [ ] **Step 2 : Vérifier** + +Run: `make dev-nuxt` ; sur `/directory`, cliquer une ligne client → arrive sur `/directory/clients/:id` ; le bouton « Ajouter » ouvre toujours le drawer. + +- [ ] **Step 3 : Commit** + +```bash +git add frontend/modules/directory/pages/directory.vue +git commit -m "feat(directory) : open detail page on row click, keep drawer for creation" +``` + +--- + +## Task 19 : Traductions i18n + +**Files:** +- Modify: `frontend/i18n/locales/fr.json` + +**Interfaces:** +- Produces: toutes les clés `directory.tabs.{contact,address,report}`, `directory.contacts.*`, `directory.addresses.*`, `directory.reports.*`, `directory.documents.*`, et les clés `common.*` utilisées (`back`, `loading`, `edit`, `delete`, `save`, `cancel`). + +- [ ] **Step 1 : Ajouter les clés sous `directory`** + +Dans `frontend/i18n/locales/fr.json`, étendre l'objet `directory` (fusionner avec l'existant `directory.title`/`directory.tabs.clients`/...) : + +```json +"directory": { + "tabs": { + "contact": "Contact", + "address": "Adresse", + "report": "Rapport" + }, + "contacts": { + "add": "Ajouter un contact", + "item": "Contact {n}", + "saved": "Contact enregistré.", + "deleted": "Contact supprimé.", + "fields": { + "lastName": "Nom", + "firstName": "Prénom", + "jobTitle": "Fonction", + "email": "Email", + "phonePrimary": "Téléphone", + "phoneSecondary": "Téléphone secondaire" + } + }, + "addresses": { + "add": "Ajouter une adresse", + "item": "Adresse {n}", + "saved": "Adresse enregistrée.", + "deleted": "Adresse supprimée.", + "fields": { + "label": "Libellé", + "street": "Rue", + "streetComplement": "Complément", + "postalCode": "Code postal", + "city": "Ville" + } + }, + "reports": { + "add": "Ajouter un compte-rendu", + "empty": "Aucun compte-rendu.", + "saved": "Compte-rendu enregistré.", + "deleted": "Compte-rendu supprimé.", + "fields": { + "subject": "Objet", + "type": "Type d'échange", + "occurredAt": "Date", + "body": "Compte-rendu" + }, + "types": { + "call": "Appel", + "meeting": "Rendez-vous", + "email": "Email", + "note": "Note" + } + }, + "documents": { + "add": "Joindre un document", + "uploading": "Envoi…", + "empty": "Aucun document.", + "deleted": "Document supprimé." + } +} +``` + +> ⚠️ Préserver les clés `directory` existantes (`title`, `tabs.clients`, `tabs.prospects`, `clients.*`, `prospects.*`). Fusionner, ne pas écraser. `tabs` reçoit `contact`/`address`/`report` **en plus** de `clients`/`prospects`. + +- [ ] **Step 2 : Ajouter les clés `common` manquantes** (si absentes — vérifier d'abord `grep '"common"' frontend/i18n/locales/fr.json`) + +```json +"common": { + "back": "Retour", + "loading": "Chargement…", + "edit": "Modifier", + "delete": "Supprimer", + "save": "Enregistrer", + "cancel": "Annuler" +} +``` + +- [ ] **Step 3 : Vérifier le JSON** + +Run: `docker exec -i php-lesstime-fpm node -e "require('./frontend/i18n/locales/fr.json')"` (ou `python3 -m json.tool frontend/i18n/locales/fr.json > /dev/null`). +Expected: pas d'erreur de parsing. + +- [ ] **Step 4 : Vérifier l'absence de clés manquantes en UI** — `make dev-nuxt`, parcourir les fiches : aucun libellé brut `directory.xxx` affiché. + +- [ ] **Step 5 : Commit** + +```bash +git add frontend/i18n/locales/fr.json +git commit -m "feat(directory) : add i18n keys for contacts, addresses, reports tabs" +``` + +--- + +## Task 20 : Vérification de bout en bout + +**Files:** aucun (vérification manuelle + suite de tests). + +- [ ] **Step 1 : Suite backend complète** + +Run: `make test` +Expected: vert. + +- [ ] **Step 2 : Style PHP** + +Run: `make php-cs-fixer-allow-risky` +Expected: fichiers conformes (commit si des corrections sont appliquées). + +- [ ] **Step 3 : Parcours fonctionnel** + +Avec `make dev-nuxt` et un compte `admin/admin` : +1. `/directory` → cliquer un client → fiche détail. +2. Onglet Contact : ajouter 2 contacts, recharger la page → les 2 sont présents. +3. Onglet Adresse : ajouter 1 adresse → présente après reload. +4. Onglet Rapport : créer un compte-rendu (type « Appel »), joindre un PDF → document listé et téléchargeable. +5. Sur un prospect avec contacts/adresses/rapports → « Convertir en client » → ouvrir le client créé → contacts/adresses/rapports présents sur le client, absents du prospect. + +- [ ] **Step 4 : Commit de clôture éventuel** (si php-cs-fixer a modifié des fichiers) + +```bash +git add -A +git commit -m "style(directory) : apply php-cs-fixer" +``` + +--- + +## Notes d'exécution + +- **Ordre des tâches :** 1 → 9 (backend) avant 10 → 19 (frontend) ; Task 5 doit précéder la migration (Task 6) car `CommercialReport` référence `ReportDocument`. +- **Points à confirmer en cours de route** (signalés inline) : style d'injection des services dans `services.yaml` (copier le pattern `TaskDocument*`), props exactes des composants Malio (`COMPONENTS.md`), chemin du type `UserData`, helper d'auth JWT des tests API. +- **Décision UX assumée :** sauvegarde par bloc à la frappe (Contact/Adresse). Debounce/bouton explicite = itération ultérieure si besoin. diff --git a/docs/superpowers/specs/2026-06-22-directory-commercial-reports-design.md b/docs/superpowers/specs/2026-06-22-directory-commercial-reports-design.md new file mode 100644 index 0000000..16d8255 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-directory-commercial-reports-design.md @@ -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 = ` 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.