8313c759c6
Auto Tag Develop / tag (push) Successful in 9s
## Migration modular monolith DDD — Lesstime (0.1 → 3.3) Cette MR regroupe l'intégralité de la refonte en monolithe modulaire (strangler progressif, additif). Elle remplace les MR stackées de Phase 1 (#12–#16), désormais incluses ici. **Ne pas merger avant validation fonctionnelle** : branche destinée à être testée telle quelle. ### Périmètre — 9 modules sous `src/Module/` | Phase | Module | Contenu | |------|--------|---------| | 0.1 | (socle) | infrastructure modulaire, `ModuleInterface`, mapping Doctrine par module | | 0.2 | (socle front) | auto-détection des layers Nuxt sous `frontend/modules/*` | | 1.1 | **Core** | Identité (User/Auth), Notifications, Notifier | | 1.2 | Core | RBAC fin (permissions `module.resource.action`, sidebar gated) | | 1.3 | Core | Audit log (`#[Auditable]`, listener, provider DBAL) | | 2.1 | **TimeTracking** | TimeEntry + MCP + export | | 2.2 | **ProjectManagement** | cœur métier Projets/Tâches + 38 MCP tools | | 2.3 | **Absence** | demandes, soldes, policies, justificatifs | | 2.4 | **Directory** | Clients (migrés) + **Prospects** (nouveau, conversion → Client) | | 2.5 | **Mail** | intégration IMAP OVH + liens tâches | | 2.6 | **Integration** | Gitea / BookStack / Zimbra / Share | | 3.1 | **Reporting** | rapports transverses (DBAL read-only, 0 import inter-module) | | 3.2 | **ClientPortal** | portail client (ROLE_CLIENT cloisonné, tickets, notifications) | | 3.3 | (finition) | nettoyage legacy — `src/Entity` vide, app 100% modulaire | ### Architecture - Découplage inter-modules par **contrats** (`UserInterface`, `ProjectInterface`, `TaskInterface`, `TaskTagInterface`, `ClientInterface`, `ClientTicketInterface`, `LeaveProfileInterface`) + `resolve_target_entities` 100% modulaire (aucune cible legacy). - Repositories : interface `Domain/Repository` + implémentation `Infrastructure/Doctrine`, bindées. - Reporting en DBAL read-only pur (aucun import d'entité d'un autre module). - Chaque migration de module : déplacement à comportement préservé (API publique et noms d'outils MCP inchangés), migrations **additives** uniquement (zéro destructif). ### Sécurité - ROLE_CLIENT cloisonné : un utilisateur client n'accède qu'à `/portal` et à ses propres tickets (filtrés par `allowedProjects`), interdit sur toute l'API interne. - Correctif : interdiction pour un client de créer un lien vers le partage SMB (upload uniquement). ### QA non-régression (branche reconstruite from scratch) - Migrations from scratch + fixtures : OK. - Compilation dev + prod : OK. - **180 tests PHPUnit verts**, php-cs-fixer clean, ~96 routes, **66 outils MCP** tous sous `App\Module\*`. - Smoke test runtime multi-rôles (admin / ROLE_USER / ROLE_CLIENT) : 44 vérifications HTTP, **0 écart**, cloisonnement client étanche. - Build Nuxt OK, 9 layers, 0 import legacy résiduel. ### Points à arbitrer (hors périmètre de cette migration) - Durcissement MCP/IDOR pré-existant (`userId` explicite sans scoping sur certains tools TimeTracking/Absence/TaskDocument) — ticket dédié recommandé. - Validation fonctionnelle de **Prospect** et **ClientPortal** (conçus depuis les specs disque). - **Harmonisation visuelle Malio finale** (3.3) — finition esthétique inter-modules laissée au PO. --- ## ⚠️ Déploiement / migration des données — à ne pas oublier ### 1. Resynchroniser les séquences PostgreSQL après tout import/restore de dump Si la prod (ou tout environnement) est **montée depuis un dump** (`pg_restore` / `COPY`), les lignes sont chargées avec leurs `id` explicites **sans avancer les séquences** → au premier `INSERT` : `duplicate key value violates unique constraint "..._pkey"` (constaté en local sur `notification`, `task`, `time_entry`…). À lancer **juste après chaque restore/import** : ```sql DO $$ DECLARE r RECORD; maxid BIGINT; seq TEXT; BEGIN FOR r IN SELECT table_name, column_name FROM information_schema.columns WHERE table_schema='public' LOOP seq := pg_get_serial_sequence(quote_ident(r.table_name), r.column_name); IF seq IS NOT NULL THEN EXECUTE format('SELECT COALESCE(MAX(%I),0) FROM %I', r.column_name, r.table_name) INTO maxid; PERFORM setval(seq, GREATEST(maxid,1), maxid > 0); END IF; END LOOP; END $$; ``` > Ne concerne **pas** une prod qui tourne déjà (séquences avancées organiquement) — uniquement le cas restore/import. Idempotent, sans risque. ### 2. Fix dénormalisation des collections typées-contrat (code, inclus dans la branche) Les relations **to-many** typées par une interface `Shared\Domain\Contract\*` (`TimeEntry::tags` → `TaskTagInterface`, `Task::collaborators` → `UserInterface`) étaient **indénormalisables par API Platform** (mono-valué OK via IRI, collection KO) → **tout POST/PATCH portant une telle collection renvoyait 400/500**. Corrigé par un dénormaliseur générique `ContractRelationDenormalizer` (réutilise `resolve_target_entities`, zéro couplage par-entité) + test fonctionnel de non-régression. --------- Co-authored-by: Matthieu <contact@malio.fr> Reviewed-on: #17
3307 lines
114 KiB
Markdown
3307 lines
114 KiB
Markdown
# 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 :** `<type>(<scope>) : <message>` (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 <TestName>`.
|
||
|
||
## 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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Tests\Module\Directory\Domain\Enum;
|
||
|
||
use App\Module\Directory\Domain\Enum\ReportType;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class ReportTypeTest extends TestCase
|
||
{
|
||
public function testValuesAndLabels(): void
|
||
{
|
||
self::assertSame('call', ReportType::Call->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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Module\Directory\Domain\Enum;
|
||
|
||
enum ReportType: string
|
||
{
|
||
case Call = 'call';
|
||
case Meeting = 'meeting';
|
||
case Email = 'email';
|
||
case Note = 'note';
|
||
|
||
public function label(): string
|
||
{
|
||
return match ($this) {
|
||
self::Call => '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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Module\Directory\Domain\Entity;
|
||
|
||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||
use ApiPlatform\Metadata\ApiFilter;
|
||
use ApiPlatform\Metadata\ApiResource;
|
||
use ApiPlatform\Metadata\Delete;
|
||
use ApiPlatform\Metadata\Get;
|
||
use ApiPlatform\Metadata\GetCollection;
|
||
use ApiPlatform\Metadata\Patch;
|
||
use ApiPlatform\Metadata\Post;
|
||
use App\Module\Directory\Infrastructure\Doctrine\DoctrineContactRepository;
|
||
use App\Shared\Domain\Attribute\Auditable;
|
||
use App\Shared\Domain\Contract\BlamableInterface;
|
||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||
use Doctrine\ORM\Mapping as ORM;
|
||
use Symfony\Component\Serializer\Attribute\Groups;
|
||
|
||
#[Auditable]
|
||
#[ApiResource(
|
||
operations: [
|
||
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
|
||
new Get(security: "is_granted('ROLE_USER')"),
|
||
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||
],
|
||
normalizationContext: ['groups' => ['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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Module\Directory\Domain\Repository;
|
||
|
||
use App\Module\Directory\Domain\Entity\Contact;
|
||
|
||
interface ContactRepositoryInterface
|
||
{
|
||
public function findById(int $id): ?Contact;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3 : Créer le repository Doctrine**
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Module\Directory\Infrastructure\Doctrine;
|
||
|
||
use App\Module\Directory\Domain\Entity\Contact;
|
||
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
|
||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||
use Doctrine\Persistence\ManagerRegistry;
|
||
|
||
/**
|
||
* @extends ServiceEntityRepository<Contact>
|
||
*/
|
||
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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Module\Directory\Domain\Entity;
|
||
|
||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||
use ApiPlatform\Metadata\ApiFilter;
|
||
use ApiPlatform\Metadata\ApiResource;
|
||
use ApiPlatform\Metadata\Delete;
|
||
use ApiPlatform\Metadata\Get;
|
||
use ApiPlatform\Metadata\GetCollection;
|
||
use ApiPlatform\Metadata\Patch;
|
||
use ApiPlatform\Metadata\Post;
|
||
use App\Module\Directory\Infrastructure\Doctrine\DoctrineAddressRepository;
|
||
use App\Shared\Domain\Attribute\Auditable;
|
||
use App\Shared\Domain\Contract\BlamableInterface;
|
||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||
use Doctrine\ORM\Mapping as ORM;
|
||
use Symfony\Component\Serializer\Attribute\Groups;
|
||
|
||
#[Auditable]
|
||
#[ApiResource(
|
||
operations: [
|
||
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
|
||
new Get(security: "is_granted('ROLE_USER')"),
|
||
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||
],
|
||
normalizationContext: ['groups' => ['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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Module\Directory\Domain\Repository;
|
||
|
||
use App\Module\Directory\Domain\Entity\Address;
|
||
|
||
interface AddressRepositoryInterface
|
||
{
|
||
public function findById(int $id): ?Address;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3 : Créer le repository Doctrine**
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Module\Directory\Infrastructure\Doctrine;
|
||
|
||
use App\Module\Directory\Domain\Entity\Address;
|
||
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
|
||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||
use Doctrine\Persistence\ManagerRegistry;
|
||
|
||
/**
|
||
* @extends ServiceEntityRepository<Address>
|
||
*/
|
||
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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Module\Directory\Domain\Entity;
|
||
|
||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||
use ApiPlatform\Metadata\ApiFilter;
|
||
use ApiPlatform\Metadata\ApiResource;
|
||
use ApiPlatform\Metadata\Delete;
|
||
use ApiPlatform\Metadata\Get;
|
||
use ApiPlatform\Metadata\GetCollection;
|
||
use ApiPlatform\Metadata\Patch;
|
||
use ApiPlatform\Metadata\Post;
|
||
use App\Module\Directory\Domain\Enum\ReportType;
|
||
use App\Module\Directory\Infrastructure\Doctrine\DoctrineCommercialReportRepository;
|
||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||
use App\Shared\Domain\Contract\UserInterface;
|
||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||
use DateTimeImmutable;
|
||
use Doctrine\Common\Collections\ArrayCollection;
|
||
use Doctrine\Common\Collections\Collection;
|
||
use Doctrine\DBAL\Types\Types;
|
||
use Doctrine\ORM\Mapping as ORM;
|
||
use Symfony\Component\Serializer\Attribute\Groups;
|
||
|
||
#[ApiResource(
|
||
operations: [
|
||
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
|
||
new Get(security: "is_granted('ROLE_USER')"),
|
||
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||
],
|
||
normalizationContext: ['groups' => ['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<int, ReportDocument> */
|
||
#[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<int, ReportDocument> */
|
||
public function getDocuments(): Collection
|
||
{
|
||
return $this->documents;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2 : Créer l'interface de repository**
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Module\Directory\Domain\Repository;
|
||
|
||
use App\Module\Directory\Domain\Entity\CommercialReport;
|
||
|
||
interface CommercialReportRepositoryInterface
|
||
{
|
||
public function findById(int $id): ?CommercialReport;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3 : Créer le repository Doctrine**
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Module\Directory\Infrastructure\Doctrine;
|
||
|
||
use App\Module\Directory\Domain\Entity\CommercialReport;
|
||
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
|
||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||
use Doctrine\Persistence\ManagerRegistry;
|
||
|
||
/**
|
||
* @extends ServiceEntityRepository<CommercialReport>
|
||
*/
|
||
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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Module\Directory\Infrastructure\EventListener;
|
||
|
||
use App\Module\Directory\Domain\Entity\CommercialReport;
|
||
use App\Shared\Domain\Contract\UserInterface;
|
||
use Doctrine\ORM\Event\PrePersistEventArgs;
|
||
use Symfony\Bundle\SecurityBundle\Security;
|
||
|
||
final readonly class CommercialReportAuthorListener
|
||
{
|
||
public function __construct(private Security $security) {}
|
||
|
||
public function prePersist(CommercialReport $report, PrePersistEventArgs $args): void
|
||
{
|
||
if (null !== $report->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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Module\Directory\Domain\Entity;
|
||
|
||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||
use ApiPlatform\Metadata\ApiFilter;
|
||
use ApiPlatform\Metadata\ApiResource;
|
||
use ApiPlatform\Metadata\Delete;
|
||
use ApiPlatform\Metadata\Get;
|
||
use ApiPlatform\Metadata\GetCollection;
|
||
use ApiPlatform\Metadata\Post;
|
||
use App\Module\Directory\Infrastructure\ApiPlatform\State\ReportDocumentProcessor;
|
||
use App\Module\Directory\Infrastructure\EventListener\ReportDocumentListener;
|
||
use App\Shared\Domain\Contract\UserInterface;
|
||
use DateTimeImmutable;
|
||
use Doctrine\ORM\Mapping as ORM;
|
||
use Symfony\Component\Serializer\Attribute\Groups;
|
||
|
||
#[ApiResource(
|
||
operations: [
|
||
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
|
||
new Get(security: "is_granted('ROLE_USER')"),
|
||
new Post(
|
||
security: "is_granted('ROLE_ADMIN')",
|
||
processor: ReportDocumentProcessor::class,
|
||
deserialize: false,
|
||
),
|
||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||
],
|
||
normalizationContext: ['groups' => ['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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Module\Directory\Domain\Repository;
|
||
|
||
use App\Module\Directory\Domain\Entity\ReportDocument;
|
||
|
||
interface ReportDocumentRepositoryInterface
|
||
{
|
||
public function findById(int $id): ?ReportDocument;
|
||
}
|
||
```
|
||
|
||
`src/Module/Directory/Infrastructure/Doctrine/DoctrineReportDocumentRepository.php` :
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Module\Directory\Infrastructure\Doctrine;
|
||
|
||
use App\Module\Directory\Domain\Entity\ReportDocument;
|
||
use App\Module\Directory\Domain\Repository\ReportDocumentRepositoryInterface;
|
||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||
use Doctrine\Persistence\ManagerRegistry;
|
||
|
||
/**
|
||
* @extends ServiceEntityRepository<ReportDocument>
|
||
*/
|
||
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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Module\Directory\Infrastructure\ApiPlatform\State;
|
||
|
||
use ApiPlatform\Metadata\Operation;
|
||
use ApiPlatform\State\ProcessorInterface;
|
||
use App\Module\Directory\Domain\Entity\CommercialReport;
|
||
use App\Module\Directory\Domain\Entity\ReportDocument;
|
||
use DateTimeImmutable;
|
||
use Doctrine\ORM\EntityManagerInterface;
|
||
use Symfony\Bundle\SecurityBundle\Security;
|
||
use Symfony\Component\HttpFoundation\Request;
|
||
use Symfony\Component\HttpFoundation\RequestStack;
|
||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||
use Symfony\Component\Uid\Uuid;
|
||
|
||
use function in_array;
|
||
|
||
/**
|
||
* @implements ProcessorInterface<ReportDocument, ReportDocument>
|
||
*/
|
||
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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Module\Directory\Infrastructure\Controller;
|
||
|
||
use App\Module\Directory\Domain\Repository\ReportDocumentRepositoryInterface;
|
||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||
use Symfony\Component\HttpFoundation\Response;
|
||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||
use Symfony\Component\Routing\Attribute\Route;
|
||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||
|
||
final class ReportDocumentDownloadController
|
||
{
|
||
private const INLINE_MIME_TYPES = [
|
||
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
||
'application/pdf',
|
||
];
|
||
|
||
public function __construct(
|
||
private readonly ReportDocumentRepositoryInterface $repository,
|
||
private readonly string $uploadDir,
|
||
) {}
|
||
|
||
#[Route('/api/report_documents/{id}/download', name: 'report_document_download', methods: ['GET'], priority: 1)]
|
||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||
public function __invoke(int $id): Response
|
||
{
|
||
$document = $this->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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Module\Directory\Infrastructure\EventListener;
|
||
|
||
use App\Module\Directory\Domain\Entity\ReportDocument;
|
||
use Doctrine\ORM\Event\PreRemoveEventArgs;
|
||
use Psr\Log\LoggerInterface;
|
||
|
||
final readonly class ReportDocumentListener
|
||
{
|
||
public function __construct(
|
||
private string $uploadDir,
|
||
private LoggerInterface $logger,
|
||
) {}
|
||
|
||
public function preRemove(ReportDocument $document, PreRemoveEventArgs $args): void
|
||
{
|
||
$fileName = $document->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 = <nouveau client>` et `prospect = null`.
|
||
|
||
- [ ] **Step 1 : Écrire le test fonctionnel qui échoue**
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Tests\Module\Directory;
|
||
|
||
use App\Module\Directory\Domain\Entity\Address;
|
||
use App\Module\Directory\Domain\Entity\CommercialReport;
|
||
use App\Module\Directory\Domain\Entity\Contact;
|
||
use App\Module\Directory\Domain\Entity\Prospect;
|
||
use App\Module\Directory\Domain\Enum\ReportType;
|
||
use DateTimeImmutable;
|
||
use Doctrine\ORM\EntityManagerInterface;
|
||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||
|
||
final class ConvertProspectProcessorTest extends KernelTestCase
|
||
{
|
||
public function testConvertReassignsContactsAddressesAndReports(): void
|
||
{
|
||
self::bootKernel();
|
||
/** @var EntityManagerInterface $em */
|
||
$em = self::getContainer()->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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Module\Directory\Infrastructure\ApiPlatform\State;
|
||
|
||
use ApiPlatform\Metadata\Operation;
|
||
use ApiPlatform\State\ProcessorInterface;
|
||
use App\Module\Directory\Domain\Entity\Address;
|
||
use App\Module\Directory\Domain\Entity\Client;
|
||
use App\Module\Directory\Domain\Entity\CommercialReport;
|
||
use App\Module\Directory\Domain\Entity\Contact;
|
||
use App\Module\Directory\Domain\Entity\Prospect;
|
||
use App\Module\Directory\Domain\Enum\ProspectStatus;
|
||
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||
use Doctrine\ORM\EntityManagerInterface;
|
||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||
|
||
/**
|
||
* Converts a Prospect into a Client and reassigns its contacts, addresses and
|
||
* commercial reports to the new client (preserving the commercial history).
|
||
*
|
||
* Idempotent: if already converted, returns it unchanged.
|
||
*
|
||
* @implements ProcessorInterface<Prospect, Prospect>
|
||
*/
|
||
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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Tests\Module\Directory;
|
||
|
||
use App\Module\Directory\DirectoryModule;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class DirectoryModulePermissionsTest extends TestCase
|
||
{
|
||
public function testReportPermissionsAreDeclared(): void
|
||
{
|
||
$codes = array_column(DirectoryModule::permissions(), 'code');
|
||
|
||
self::assertContains('directory.reports.view', $codes);
|
||
self::assertContains('directory.reports.manage', $codes);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2 : Lancer le test, vérifier l'échec**
|
||
|
||
Run: `docker exec -i php-lesstime-fpm php bin/phpunit --filter DirectoryModulePermissionsTest`
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3 : Ajouter les permissions**
|
||
|
||
Dans `DirectoryModule::permissions()`, ajouter au tableau retourné :
|
||
|
||
```php
|
||
['code' => '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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Tests\Module\Directory;
|
||
|
||
use App\Module\Directory\Domain\Entity\Client;
|
||
use Doctrine\ORM\EntityManagerInterface;
|
||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||
|
||
final class CommercialReportApiTest extends WebTestCase
|
||
{
|
||
public function testAnonymousCannotReadReports(): void
|
||
{
|
||
$client = self::createClient();
|
||
$client->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<Contact[]> {
|
||
const data = await api.get<HydraCollection<Contact>>('/contacts', owner as Record<string, unknown>)
|
||
return extractHydraMembers(data)
|
||
}
|
||
|
||
async function create(payload: ContactWrite): Promise<Contact> {
|
||
return api.post<Contact>('/contacts', payload as Record<string, unknown>, {
|
||
toastSuccessKey: 'directory.contacts.saved',
|
||
})
|
||
}
|
||
|
||
async function update(id: number, payload: Partial<ContactWrite>): Promise<Contact> {
|
||
return api.patch<Contact>(`/contacts/${id}`, payload as Record<string, unknown>, {
|
||
toastSuccessKey: 'directory.contacts.saved',
|
||
})
|
||
}
|
||
|
||
async function remove(id: number): Promise<void> {
|
||
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<Address[]> {
|
||
const data = await api.get<HydraCollection<Address>>('/addresses', owner as Record<string, unknown>)
|
||
return extractHydraMembers(data)
|
||
}
|
||
|
||
async function create(payload: AddressWrite): Promise<Address> {
|
||
return api.post<Address>('/addresses', payload as Record<string, unknown>, {
|
||
toastSuccessKey: 'directory.addresses.saved',
|
||
})
|
||
}
|
||
|
||
async function update(id: number, payload: Partial<AddressWrite>): Promise<Address> {
|
||
return api.patch<Address>(`/addresses/${id}`, payload as Record<string, unknown>, {
|
||
toastSuccessKey: 'directory.addresses.saved',
|
||
})
|
||
}
|
||
|
||
async function remove(id: number): Promise<void> {
|
||
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<CommercialReport[]> {
|
||
const data = await api.get<HydraCollection<CommercialReport>>('/commercial_reports', owner as Record<string, unknown>)
|
||
return extractHydraMembers(data)
|
||
}
|
||
|
||
async function create(payload: CommercialReportWrite): Promise<CommercialReport> {
|
||
return api.post<CommercialReport>('/commercial_reports', payload as Record<string, unknown>, {
|
||
toastSuccessKey: 'directory.reports.saved',
|
||
})
|
||
}
|
||
|
||
async function update(id: number, payload: Partial<CommercialReportWrite>): Promise<CommercialReport> {
|
||
return api.patch<CommercialReport>(`/commercial_reports/${id}`, payload as Record<string, unknown>, {
|
||
toastSuccessKey: 'directory.reports.saved',
|
||
})
|
||
}
|
||
|
||
async function remove(id: number): Promise<void> {
|
||
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<ReportDocument[]> {
|
||
const data = await api.get<HydraCollection<ReportDocument>>('/report_documents', {
|
||
commercialReport: `/api/commercial_reports/${reportId}`,
|
||
})
|
||
return extractHydraMembers(data)
|
||
}
|
||
|
||
async function upload(reportId: number, file: File): Promise<ReportDocument> {
|
||
const formData = new FormData()
|
||
formData.append('file', file)
|
||
formData.append('commercialReport', `/api/commercial_reports/${reportId}`)
|
||
|
||
return $fetch<ReportDocument>(`${baseURL}/report_documents`, {
|
||
method: 'POST',
|
||
body: formData,
|
||
credentials: 'include',
|
||
})
|
||
}
|
||
|
||
async function remove(id: number): Promise<void> {
|
||
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
|
||
<template>
|
||
<div class="relative grid grid-cols-2 gap-x-8 gap-y-3 rounded bg-white p-4 shadow">
|
||
<h3 class="col-span-2 text-sm font-semibold text-neutral-700">
|
||
{{ title }}
|
||
</h3>
|
||
<MalioButtonIcon
|
||
v-if="removable && !readonly"
|
||
icon="mdi:trash-can-outline"
|
||
class="absolute right-2 top-2"
|
||
button-class="!text-red-600"
|
||
:aria-label="$t('common.delete')"
|
||
@click="$emit('remove')"
|
||
/>
|
||
|
||
<MalioInputText
|
||
:label="$t('directory.contacts.fields.lastName')"
|
||
:model-value="modelValue.lastName ?? ''"
|
||
:readonly="readonly"
|
||
@update:model-value="update('lastName', $event)"
|
||
/>
|
||
<MalioInputText
|
||
:label="$t('directory.contacts.fields.firstName')"
|
||
:model-value="modelValue.firstName ?? ''"
|
||
:readonly="readonly"
|
||
@update:model-value="update('firstName', $event)"
|
||
/>
|
||
<MalioInputText
|
||
class="col-span-2"
|
||
:label="$t('directory.contacts.fields.jobTitle')"
|
||
:model-value="modelValue.jobTitle ?? ''"
|
||
:readonly="readonly"
|
||
@update:model-value="update('jobTitle', $event)"
|
||
/>
|
||
<MalioInputText
|
||
:label="$t('directory.contacts.fields.email')"
|
||
:model-value="modelValue.email ?? ''"
|
||
:readonly="readonly"
|
||
@update:model-value="update('email', $event)"
|
||
/>
|
||
<MalioInputText
|
||
:label="$t('directory.contacts.fields.phonePrimary')"
|
||
:model-value="modelValue.phonePrimary ?? ''"
|
||
:readonly="readonly"
|
||
@update:model-value="update('phonePrimary', $event)"
|
||
/>
|
||
<MalioInputText
|
||
:label="$t('directory.contacts.fields.phoneSecondary')"
|
||
:model-value="modelValue.phoneSecondary ?? ''"
|
||
:readonly="readonly"
|
||
@update:model-value="update('phoneSecondary', $event)"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import type { Contact } from '~/modules/directory/services/dto/contact'
|
||
|
||
const props = defineProps<{
|
||
modelValue: Contact
|
||
title: string
|
||
removable?: boolean
|
||
readonly?: boolean
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
'update:modelValue': [value: Contact]
|
||
'remove': []
|
||
}>()
|
||
|
||
function update(field: keyof Contact, value: string): void {
|
||
emit('update:modelValue', { ...props.modelValue, [field]: value === '' ? null : value })
|
||
}
|
||
</script>
|
||
```
|
||
|
||
- [ ] **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
|
||
<template>
|
||
<div class="relative grid grid-cols-2 gap-x-8 gap-y-3 rounded bg-white p-4 shadow">
|
||
<h3 class="col-span-2 text-sm font-semibold text-neutral-700">
|
||
{{ title }}
|
||
</h3>
|
||
<MalioButtonIcon
|
||
v-if="removable && !readonly"
|
||
icon="mdi:trash-can-outline"
|
||
class="absolute right-2 top-2"
|
||
button-class="!text-red-600"
|
||
:aria-label="$t('common.delete')"
|
||
@click="$emit('remove')"
|
||
/>
|
||
|
||
<MalioInputText
|
||
class="col-span-2"
|
||
:label="$t('directory.addresses.fields.label')"
|
||
:model-value="modelValue.label ?? ''"
|
||
:readonly="readonly"
|
||
@update:model-value="update('label', $event)"
|
||
/>
|
||
<MalioInputText
|
||
class="col-span-2"
|
||
:label="$t('directory.addresses.fields.street')"
|
||
:model-value="modelValue.street ?? ''"
|
||
:readonly="readonly"
|
||
@update:model-value="update('street', $event)"
|
||
/>
|
||
<MalioInputText
|
||
class="col-span-2"
|
||
:label="$t('directory.addresses.fields.streetComplement')"
|
||
:model-value="modelValue.streetComplement ?? ''"
|
||
:readonly="readonly"
|
||
@update:model-value="update('streetComplement', $event)"
|
||
/>
|
||
<MalioInputText
|
||
:label="$t('directory.addresses.fields.postalCode')"
|
||
:model-value="modelValue.postalCode ?? ''"
|
||
:readonly="readonly"
|
||
@update:model-value="update('postalCode', $event)"
|
||
/>
|
||
<MalioInputText
|
||
:label="$t('directory.addresses.fields.city')"
|
||
:model-value="modelValue.city ?? ''"
|
||
:readonly="readonly"
|
||
@update:model-value="update('city', $event)"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import type { Address } from '~/modules/directory/services/dto/address'
|
||
|
||
const props = defineProps<{
|
||
modelValue: Address
|
||
title: string
|
||
removable?: boolean
|
||
readonly?: boolean
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
'update:modelValue': [value: Address]
|
||
'remove': []
|
||
}>()
|
||
|
||
function update(field: keyof Address, value: string): void {
|
||
emit('update:modelValue', { ...props.modelValue, [field]: value === '' ? null : value })
|
||
}
|
||
</script>
|
||
```
|
||
|
||
- [ ] **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
|
||
<template>
|
||
<div class="flex items-center gap-3">
|
||
<input
|
||
ref="fileInput"
|
||
type="file"
|
||
class="hidden"
|
||
@change="onFileSelected"
|
||
>
|
||
<MalioButton
|
||
icon-name="mdi:paperclip"
|
||
icon-position="left"
|
||
button-class="w-auto px-4"
|
||
:label="$t('directory.documents.add')"
|
||
:disabled="uploading"
|
||
@click="fileInput?.click()"
|
||
/>
|
||
<span v-if="uploading" class="text-sm text-neutral-500">{{ $t('directory.documents.uploading') }}</span>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { useReportDocumentService } from '~/modules/directory/services/report-documents'
|
||
|
||
const props = defineProps<{ reportId: number }>()
|
||
const emit = defineEmits<{ uploaded: [] }>()
|
||
|
||
const service = useReportDocumentService()
|
||
const fileInput = ref<HTMLInputElement | null>(null)
|
||
const uploading = ref(false)
|
||
|
||
async function onFileSelected(event: Event): Promise<void> {
|
||
const input = event.target as HTMLInputElement
|
||
const file = input.files?.[0]
|
||
if (!file) return
|
||
|
||
uploading.value = true
|
||
try {
|
||
await service.upload(props.reportId, file)
|
||
emit('uploaded')
|
||
} finally {
|
||
uploading.value = false
|
||
input.value = ''
|
||
}
|
||
}
|
||
</script>
|
||
```
|
||
|
||
- [ ] **Step 2 : `ReportDocumentList.vue`**
|
||
|
||
```vue
|
||
<template>
|
||
<ul v-if="documents.length" class="flex flex-col gap-2">
|
||
<li
|
||
v-for="doc in documents"
|
||
:key="doc.id"
|
||
class="flex items-center justify-between rounded border border-neutral-200 px-3 py-2"
|
||
>
|
||
<a
|
||
:href="downloadUrl(doc.id)"
|
||
target="_blank"
|
||
rel="noopener"
|
||
class="flex items-center gap-2 text-sm text-blue-700 hover:underline"
|
||
>
|
||
<Icon name="mdi:file-document-outline" />
|
||
{{ doc.originalName }}
|
||
</a>
|
||
<MalioButtonIcon
|
||
v-if="isAdmin"
|
||
icon="mdi:trash-can-outline"
|
||
button-class="!text-red-600"
|
||
:aria-label="$t('common.delete')"
|
||
@click="$emit('delete', doc.id)"
|
||
/>
|
||
</li>
|
||
</ul>
|
||
<p v-else class="text-sm text-neutral-400">
|
||
{{ $t('directory.documents.empty') }}
|
||
</p>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import type { ReportDocument } from '~/modules/directory/services/dto/report-document'
|
||
import { useReportDocumentService } from '~/modules/directory/services/report-documents'
|
||
|
||
defineProps<{ documents: ReportDocument[], isAdmin: boolean }>()
|
||
defineEmits<{ delete: [id: number] }>()
|
||
|
||
const { getDownloadUrl } = useReportDocumentService()
|
||
function downloadUrl(id: number): string {
|
||
return getDownloadUrl(id)
|
||
}
|
||
</script>
|
||
```
|
||
|
||
- [ ] **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
|
||
<template>
|
||
<div class="flex flex-col gap-6 pt-6">
|
||
<!-- Formulaire d'ajout / édition -->
|
||
<div v-if="isAdmin" class="grid grid-cols-2 gap-x-8 gap-y-3 rounded bg-white p-4 shadow">
|
||
<MalioInputText
|
||
class="col-span-2"
|
||
:label="$t('directory.reports.fields.subject')"
|
||
v-model="draft.subject"
|
||
/>
|
||
<MalioSelect
|
||
:label="$t('directory.reports.fields.type')"
|
||
v-model="draft.type"
|
||
:options="typeOptions"
|
||
group-class="w-full"
|
||
/>
|
||
<MalioDate
|
||
:label="$t('directory.reports.fields.occurredAt')"
|
||
v-model="draft.occurredAt"
|
||
/>
|
||
<MalioInputTextArea
|
||
class="col-span-2"
|
||
:label="$t('directory.reports.fields.body')"
|
||
v-model="draft.body"
|
||
/>
|
||
<div class="col-span-2 flex justify-end gap-3">
|
||
<MalioButton
|
||
v-if="editingId"
|
||
variant="secondary"
|
||
button-class="w-auto px-4"
|
||
:label="$t('common.cancel')"
|
||
@click="resetDraft"
|
||
/>
|
||
<MalioButton
|
||
button-class="w-auto px-4"
|
||
:label="editingId ? $t('common.save') : $t('directory.reports.add')"
|
||
:disabled="!draft.subject"
|
||
@click="save"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Liste des comptes-rendus -->
|
||
<div v-for="report in reports" :key="report.id" class="rounded border border-neutral-200 p-4">
|
||
<div class="flex items-start justify-between">
|
||
<div>
|
||
<p class="font-semibold text-neutral-800">{{ report.subject }}</p>
|
||
<p class="text-xs text-neutral-500">
|
||
{{ formatDate(report.occurredAt) }} · {{ $t(`directory.reports.types.${report.type}`) }}
|
||
<span v-if="report.author"> · {{ report.author.username }}</span>
|
||
</p>
|
||
</div>
|
||
<div v-if="isAdmin" class="flex gap-2">
|
||
<MalioButtonIcon icon="mdi:pencil-outline" :aria-label="$t('common.edit')" @click="edit(report)" />
|
||
<MalioButtonIcon icon="mdi:trash-can-outline" button-class="!text-red-600" :aria-label="$t('common.delete')" @click="remove(report.id)" />
|
||
</div>
|
||
</div>
|
||
<p v-if="report.body" class="mt-2 whitespace-pre-wrap text-sm text-neutral-700">{{ report.body }}</p>
|
||
|
||
<div class="mt-3 flex flex-col gap-2">
|
||
<ReportDocumentList
|
||
:documents="report.documents ?? []"
|
||
:is-admin="isAdmin"
|
||
@delete="(id) => removeDocument(report, id)"
|
||
/>
|
||
<ReportDocumentUpload
|
||
v-if="isAdmin"
|
||
:report-id="report.id"
|
||
@uploaded="reload"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<p v-if="!reports.length" class="text-sm text-neutral-400">
|
||
{{ $t('directory.reports.empty') }}
|
||
</p>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import type { CommercialReport, CommercialReportWrite, ReportType } from '~/modules/directory/services/dto/commercial-report'
|
||
import { useCommercialReportService } from '~/modules/directory/services/commercial-reports'
|
||
import { useReportDocumentService } from '~/modules/directory/services/report-documents'
|
||
|
||
const props = defineProps<{
|
||
owner: { client?: string, prospect?: string }
|
||
isAdmin: boolean
|
||
}>()
|
||
|
||
const { t } = useI18n()
|
||
const reportService = useCommercialReportService()
|
||
const documentService = useReportDocumentService()
|
||
|
||
const reports = ref<CommercialReport[]>([])
|
||
const editingId = ref<number | null>(null)
|
||
|
||
function emptyDraft(): CommercialReportWrite {
|
||
return {
|
||
subject: '',
|
||
body: null,
|
||
occurredAt: new Date().toISOString().slice(0, 10),
|
||
type: 'note',
|
||
...props.owner,
|
||
}
|
||
}
|
||
const draft = ref<CommercialReportWrite>(emptyDraft())
|
||
|
||
const typeOptions: { label: string, value: ReportType }[] = [
|
||
{ label: t('directory.reports.types.call'), value: 'call' },
|
||
{ label: t('directory.reports.types.meeting'), value: 'meeting' },
|
||
{ label: t('directory.reports.types.email'), value: 'email' },
|
||
{ label: t('directory.reports.types.note'), value: 'note' },
|
||
]
|
||
|
||
function formatDate(iso: string): string {
|
||
return new Date(iso).toLocaleDateString('fr-FR')
|
||
}
|
||
|
||
async function reload(): Promise<void> {
|
||
reports.value = await reportService.getByOwner(props.owner)
|
||
}
|
||
|
||
function resetDraft(): void {
|
||
editingId.value = null
|
||
draft.value = emptyDraft()
|
||
}
|
||
|
||
function edit(report: CommercialReport): void {
|
||
editingId.value = report.id
|
||
draft.value = {
|
||
subject: report.subject,
|
||
body: report.body,
|
||
occurredAt: report.occurredAt.slice(0, 10),
|
||
type: report.type,
|
||
...props.owner,
|
||
}
|
||
}
|
||
|
||
async function save(): Promise<void> {
|
||
if (editingId.value) {
|
||
await reportService.update(editingId.value, draft.value)
|
||
} else {
|
||
await reportService.create(draft.value)
|
||
}
|
||
resetDraft()
|
||
await reload()
|
||
}
|
||
|
||
async function remove(id: number): Promise<void> {
|
||
await reportService.remove(id)
|
||
await reload()
|
||
}
|
||
|
||
async function removeDocument(report: CommercialReport, id: number): Promise<void> {
|
||
await documentService.remove(id)
|
||
await reload()
|
||
}
|
||
|
||
onMounted(reload)
|
||
watch(() => props.owner, reload, { deep: true })
|
||
</script>
|
||
```
|
||
|
||
> 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<Client> {
|
||
return api.get<Client>(`/clients/${id}`)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2 : Écrire la page**
|
||
|
||
```vue
|
||
<template>
|
||
<div class="flex flex-col gap-6">
|
||
<div class="flex items-center gap-3 pt-4">
|
||
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" />
|
||
<h1 class="text-2xl font-bold text-neutral-900">{{ client?.name ?? '…' }}</h1>
|
||
</div>
|
||
|
||
<p v-if="loading">{{ $t('common.loading') }}</p>
|
||
<template v-else-if="client">
|
||
<MalioTabList v-model="activeTab" :tabs="tabs">
|
||
<template #contact>
|
||
<div class="flex flex-col gap-4 pt-6">
|
||
<DirectoryContactBlock
|
||
v-for="(contact, i) in contacts"
|
||
:key="contact.id || `new-${i}`"
|
||
:model-value="contact"
|
||
:title="$t('directory.contacts.item', { n: i + 1 })"
|
||
:removable="contacts.length > 0"
|
||
@update:model-value="(v) => onContactInput(i, v)"
|
||
@remove="removeContact(i)"
|
||
/>
|
||
<MalioButton
|
||
icon-name="mdi:plus"
|
||
icon-position="left"
|
||
button-class="w-auto px-4"
|
||
:label="$t('directory.contacts.add')"
|
||
@click="addContact"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<template #address>
|
||
<div class="flex flex-col gap-4 pt-6">
|
||
<DirectoryAddressBlock
|
||
v-for="(address, i) in addresses"
|
||
:key="address.id || `new-${i}`"
|
||
:model-value="address"
|
||
:title="$t('directory.addresses.item', { n: i + 1 })"
|
||
:removable="addresses.length > 0"
|
||
@update:model-value="(v) => onAddressInput(i, v)"
|
||
@remove="removeAddress(i)"
|
||
/>
|
||
<MalioButton
|
||
icon-name="mdi:plus"
|
||
icon-position="left"
|
||
button-class="w-auto px-4"
|
||
:label="$t('directory.addresses.add')"
|
||
@click="addAddress"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<template #report>
|
||
<CommercialReportTab :owner="owner" :is-admin="true" />
|
||
</template>
|
||
</MalioTabList>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import type { Client } from '~/modules/directory/services/dto/client'
|
||
import type { Contact } from '~/modules/directory/services/dto/contact'
|
||
import type { Address } from '~/modules/directory/services/dto/address'
|
||
import { useClientService } from '~/modules/directory/services/clients'
|
||
import { useContactService } from '~/modules/directory/services/contacts'
|
||
import { useAddressService } from '~/modules/directory/services/addresses'
|
||
|
||
definePageMeta({ middleware: ['admin'] })
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const { t } = useI18n()
|
||
|
||
const id = Number(route.params.id)
|
||
const ownerIri = `/api/clients/${id}`
|
||
const owner = { client: ownerIri }
|
||
|
||
const clientService = useClientService()
|
||
const contactService = useContactService()
|
||
const addressService = useAddressService()
|
||
|
||
const client = ref<Client | null>(null)
|
||
const contacts = ref<Contact[]>([])
|
||
const addresses = ref<Address[]>([])
|
||
const loading = ref(true)
|
||
|
||
const activeTab = ref('contact')
|
||
const tabs = [
|
||
{ key: 'contact', label: t('directory.tabs.contact'), icon: 'mdi:account-outline' },
|
||
{ key: 'address', label: t('directory.tabs.address'), icon: 'mdi:map-marker-outline' },
|
||
{ key: 'report', label: t('directory.tabs.report'), icon: 'mdi:file-document-outline' },
|
||
]
|
||
|
||
function emptyContact(): Contact {
|
||
return { id: 0, firstName: null, lastName: null, jobTitle: null, email: null, phonePrimary: null, phoneSecondary: null, client: ownerIri }
|
||
}
|
||
function emptyAddress(): Address {
|
||
return { id: 0, label: null, street: null, streetComplement: null, postalCode: null, city: null, country: 'FR', client: ownerIri }
|
||
}
|
||
|
||
async function onContactInput(index: number, value: Contact): Promise<void> {
|
||
contacts.value[index] = value
|
||
await persistContact(index)
|
||
}
|
||
async function persistContact(index: number): Promise<void> {
|
||
const c = contacts.value[index]
|
||
const payload = { firstName: c.firstName, lastName: c.lastName, jobTitle: c.jobTitle, email: c.email, phonePrimary: c.phonePrimary, phoneSecondary: c.phoneSecondary, client: ownerIri }
|
||
if (c.id && c.id > 0) {
|
||
await contactService.update(c.id, payload)
|
||
} else if (c.lastName || c.firstName) {
|
||
const created = await contactService.create(payload)
|
||
contacts.value[index] = created
|
||
}
|
||
}
|
||
function addContact(): void {
|
||
contacts.value.push(emptyContact())
|
||
}
|
||
async function removeContact(index: number): Promise<void> {
|
||
const c = contacts.value[index]
|
||
if (c.id && c.id > 0) await contactService.remove(c.id)
|
||
contacts.value.splice(index, 1)
|
||
}
|
||
|
||
async function onAddressInput(index: number, value: Address): Promise<void> {
|
||
addresses.value[index] = value
|
||
await persistAddress(index)
|
||
}
|
||
async function persistAddress(index: number): Promise<void> {
|
||
const a = addresses.value[index]
|
||
const payload = { label: a.label, street: a.street, streetComplement: a.streetComplement, postalCode: a.postalCode, city: a.city, country: a.country, client: ownerIri }
|
||
if (a.id && a.id > 0) {
|
||
await addressService.update(a.id, payload)
|
||
} else if (a.street || a.city || a.postalCode) {
|
||
const created = await addressService.create(payload)
|
||
addresses.value[index] = created
|
||
}
|
||
}
|
||
function addAddress(): void {
|
||
addresses.value.push(emptyAddress())
|
||
}
|
||
async function removeAddress(index: number): Promise<void> {
|
||
const a = addresses.value[index]
|
||
if (a.id && a.id > 0) await addressService.remove(a.id)
|
||
addresses.value.splice(index, 1)
|
||
}
|
||
|
||
function goBack(): void {
|
||
router.push('/directory')
|
||
}
|
||
|
||
onMounted(async () => {
|
||
client.value = await clientService.getById(id)
|
||
contacts.value = await contactService.getByOwner({ client: ownerIri })
|
||
addresses.value = await addressService.getByOwner({ client: ownerIri })
|
||
loading.value = false
|
||
})
|
||
</script>
|
||
```
|
||
|
||
> 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
|
||
<template>
|
||
<div class="flex flex-col gap-6">
|
||
<div class="flex items-center gap-3 pt-4">
|
||
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" />
|
||
<h1 class="text-2xl font-bold text-neutral-900">{{ prospect?.name ?? '…' }}</h1>
|
||
</div>
|
||
|
||
<p v-if="loading">{{ $t('common.loading') }}</p>
|
||
<template v-else-if="prospect">
|
||
<MalioTabList v-model="activeTab" :tabs="tabs">
|
||
<template #contact>
|
||
<div class="flex flex-col gap-4 pt-6">
|
||
<DirectoryContactBlock
|
||
v-for="(contact, i) in contacts"
|
||
:key="contact.id || `new-${i}`"
|
||
:model-value="contact"
|
||
:title="$t('directory.contacts.item', { n: i + 1 })"
|
||
:removable="contacts.length > 0"
|
||
@update:model-value="(v) => onContactInput(i, v)"
|
||
@remove="removeContact(i)"
|
||
/>
|
||
<MalioButton
|
||
icon-name="mdi:plus"
|
||
icon-position="left"
|
||
button-class="w-auto px-4"
|
||
:label="$t('directory.contacts.add')"
|
||
@click="addContact"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<template #address>
|
||
<div class="flex flex-col gap-4 pt-6">
|
||
<DirectoryAddressBlock
|
||
v-for="(address, i) in addresses"
|
||
:key="address.id || `new-${i}`"
|
||
:model-value="address"
|
||
:title="$t('directory.addresses.item', { n: i + 1 })"
|
||
:removable="addresses.length > 0"
|
||
@update:model-value="(v) => onAddressInput(i, v)"
|
||
@remove="removeAddress(i)"
|
||
/>
|
||
<MalioButton
|
||
icon-name="mdi:plus"
|
||
icon-position="left"
|
||
button-class="w-auto px-4"
|
||
:label="$t('directory.addresses.add')"
|
||
@click="addAddress"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<template #report>
|
||
<CommercialReportTab :owner="owner" :is-admin="true" />
|
||
</template>
|
||
</MalioTabList>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import type { Prospect } from '~/modules/directory/services/dto/prospect'
|
||
import type { Contact } from '~/modules/directory/services/dto/contact'
|
||
import type { Address } from '~/modules/directory/services/dto/address'
|
||
import { useProspectService } from '~/modules/directory/services/prospects'
|
||
import { useContactService } from '~/modules/directory/services/contacts'
|
||
import { useAddressService } from '~/modules/directory/services/addresses'
|
||
|
||
definePageMeta({ middleware: ['admin'] })
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const { t } = useI18n()
|
||
|
||
const id = Number(route.params.id)
|
||
const ownerIri = `/api/prospects/${id}`
|
||
const owner = { prospect: ownerIri }
|
||
|
||
const prospectService = useProspectService()
|
||
const contactService = useContactService()
|
||
const addressService = useAddressService()
|
||
|
||
const prospect = ref<Prospect | null>(null)
|
||
const contacts = ref<Contact[]>([])
|
||
const addresses = ref<Address[]>([])
|
||
const loading = ref(true)
|
||
|
||
const activeTab = ref('contact')
|
||
const tabs = [
|
||
{ key: 'contact', label: t('directory.tabs.contact'), icon: 'mdi:account-outline' },
|
||
{ key: 'address', label: t('directory.tabs.address'), icon: 'mdi:map-marker-outline' },
|
||
{ key: 'report', label: t('directory.tabs.report'), icon: 'mdi:file-document-outline' },
|
||
]
|
||
|
||
function emptyContact(): Contact {
|
||
return { id: 0, firstName: null, lastName: null, jobTitle: null, email: null, phonePrimary: null, phoneSecondary: null, prospect: ownerIri }
|
||
}
|
||
function emptyAddress(): Address {
|
||
return { id: 0, label: null, street: null, streetComplement: null, postalCode: null, city: null, country: 'FR', prospect: ownerIri }
|
||
}
|
||
|
||
async function onContactInput(index: number, value: Contact): Promise<void> {
|
||
contacts.value[index] = value
|
||
await persistContact(index)
|
||
}
|
||
async function persistContact(index: number): Promise<void> {
|
||
const c = contacts.value[index]
|
||
const payload = { firstName: c.firstName, lastName: c.lastName, jobTitle: c.jobTitle, email: c.email, phonePrimary: c.phonePrimary, phoneSecondary: c.phoneSecondary, prospect: ownerIri }
|
||
if (c.id && c.id > 0) {
|
||
await contactService.update(c.id, payload)
|
||
} else if (c.lastName || c.firstName) {
|
||
const created = await contactService.create(payload)
|
||
contacts.value[index] = created
|
||
}
|
||
}
|
||
function addContact(): void {
|
||
contacts.value.push(emptyContact())
|
||
}
|
||
async function removeContact(index: number): Promise<void> {
|
||
const c = contacts.value[index]
|
||
if (c.id && c.id > 0) await contactService.remove(c.id)
|
||
contacts.value.splice(index, 1)
|
||
}
|
||
|
||
async function onAddressInput(index: number, value: Address): Promise<void> {
|
||
addresses.value[index] = value
|
||
await persistAddress(index)
|
||
}
|
||
async function persistAddress(index: number): Promise<void> {
|
||
const a = addresses.value[index]
|
||
const payload = { label: a.label, street: a.street, streetComplement: a.streetComplement, postalCode: a.postalCode, city: a.city, country: a.country, prospect: ownerIri }
|
||
if (a.id && a.id > 0) {
|
||
await addressService.update(a.id, payload)
|
||
} else if (a.street || a.city || a.postalCode) {
|
||
const created = await addressService.create(payload)
|
||
addresses.value[index] = created
|
||
}
|
||
}
|
||
function addAddress(): void {
|
||
addresses.value.push(emptyAddress())
|
||
}
|
||
async function removeAddress(index: number): Promise<void> {
|
||
const a = addresses.value[index]
|
||
if (a.id && a.id > 0) await addressService.remove(a.id)
|
||
addresses.value.splice(index, 1)
|
||
}
|
||
|
||
function goBack(): void {
|
||
router.push('/directory')
|
||
}
|
||
|
||
onMounted(async () => {
|
||
prospect.value = await prospectService.getById(id)
|
||
contacts.value = await contactService.getByOwner({ prospect: ownerIri })
|
||
addresses.value = await addressService.getByOwner({ prospect: ownerIri })
|
||
loading.value = false
|
||
})
|
||
</script>
|
||
```
|
||
|
||
- [ ] **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<string, unknown>) {
|
||
navigateTo(`/directory/clients/${(item as Client).id}`)
|
||
}
|
||
|
||
function openEditProspect(item: Record<string, unknown>) {
|
||
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.
|