# Plan de Migration : NestJS/Prisma → Symfony/API Platform

**Date de création**: 2026-01-10
**Dernière mise à jour**: 2026-01-11
**Objectif**: Migrer le backend Inventory (NestJS + Prisma) vers Symfony 8 + API Platform avec JWT auth, tout en conservant les données existantes et en minimisant les disruptions.

---

## 📊 État des Lieux - 2026-01-11

### ✅ Phase 1 : Préparation (TERMINÉE, ajustée)
- ✅ Bundles Symfony installés (JWT, VichUploader, Uid)
- ✅ Entité Profile créée (UserInterface + session ready)
- ✅ pgAdmin configuré et connecté à PostgreSQL
- ✅ Docker fonctionnel (web:8081, db:5433, pgadmin:5050)
- ✅ API Platform configurée avec préfixe `/api`
- ⚠️ JWT désactivé temporairement (session cookie pour compatibilité front)

### ✅ Phase 2 : Tests & Validation (TERMINÉE, version session)
- ✅ API Platform retourne JSON-LD correctement
- ✅ Routes session `/api/session/profile` + `/api/session/profiles` opérationnelles
- ✅ CORS prévu via Nelmio (mais variable d'env à injecter dans container)

### 📦 Données à Migrer (Base: `inventory-data`)

**Total: 673 lignes réparties sur 22 tables (migrées)**

| Table | Lignes | Priorité | Commentaire |
|-------|--------|----------|-------------|
| `sites` | 3 | ⚠️ Critique | Point d'entrée, relations cascades |
| `machines` | 3 | ⚠️ Critique | Entité centrale |
| `type_machines` | 3 | ⚠️ Critique | Configuration machines |
| `ModelType` | 71 | 🔴 Haute | Templates composants/pièces/produits |
| `composants` | 23 | 🔴 Haute | - |
| `pieces` | 82 | 🔴 Haute | Plus grande table métier |
| `products` | 2 | 🟡 Moyenne | - |
| `constructeurs` | 20 | 🟡 Moyenne | - |
| `custom_fields` | 95 | 🔴 Haute | Configuration champs custom |
| `custom_field_values` | 203 | 🔴 Haute | Plus grande table (valeurs) |
| `documents` | 14 | 🟡 Moyenne | Fichiers uploadés |
| `machine_component_links` | 25 | 🔴 Haute | Relations machines ↔ composants |
| `machine_piece_links` | 99 | 🔴 Haute | Relations machines ↔ pièces |
| `machine_product_links` | 0 | 🟢 Basse | Table vide |
| `type_machine_*_requirements` (×3) | 22 | 🟡 Moyenne | Config requirements types |
| `_*Constructeurs` (×4) | 14 | 🟡 Moyenne | Tables de liaison ManyToMany |
| `profiles` | 2 | 🟢 Basse | Utilisateurs session (migrés) |

**Complexités identifiées (résolues ou contournées)** :
- ✅ IDs CUID Prisma → conservés en `string(36)` côté Doctrine (compatibles)
- ✅ Champs JSON complexes importés (components, criticalParts, machinePieces, specifications)
- ✅ Relations polymorphiques Document + CustomFieldValue migrées
- ✅ Tables de liaison ManyToMany (`_*Constructeurs`) migrées
- ✅ Custom fields dynamiques migrés (95 définitions, 203 valeurs)

### 🏗️ Structure Actuelle des Projets

```
/home/r-dev/Inventory/
├── Inventory_backend/          # NestJS + Prisma (11k lignes)
│   ├── src/                    # 18 modules NestJS
│   ├── prisma/schema.prisma   # Schéma DB source
│   └── package.json
├── Inventory_frontend/         # Nuxt 3 (front principal)
│   ├── app/
│   │   ├── composables/
│   │   └── pages/
│   └── package.json
├── src/                        # ⚡ Symfony backend
│   ├── Entity/                # Toutes entités créées
│   ├── Controller/            # Session + custom fields + documents + skeleton
│   └── Repository/
├── config/                     # Configuration Symfony
├── migrations/                 # Migrations Doctrine
└── docker-compose.yml          # Web + DB + pgAdmin
```

### 🎯 Prochaines Étapes (Phase 3 - Mise en production fonctionnelle)

#### Étape 3.1 : Réorganisation du Projet (optionnel)
- [ ] Déplacer `Inventory_frontend/` vers `frontend/` (si besoin)
- [ ] Archiver `Inventory_backend/` vers `_archives/backend_nestjs/`
- [ ] Mettre à jour .gitignore

#### Étape 3.2 : Entités & API Platform (TERMINÉE)
- [x] Site, TypeMachine, Machine
- [x] ModelType, Composant, Piece, Product
- [x] Constructeur, Document
- [x] CustomField, CustomFieldValue
- [x] MachineComponentLink, MachinePieceLink, MachineProductLink
- [x] TypeMachine*Requirement (×3)

#### Étape 3.3 : Migration des Données (TERMINÉE)
- [x] Backup `inventory-data`
- [x] Schéma Doctrine aligné
- [x] Import `inventory-data` → `inventory`
- [x] Validation des comptes (script `scripts/validate-migration.php`)

#### Étape 3.4 : Front Nuxt (EN COURS)
- [x] Base URL API -> `http://localhost:8081/api`
- [x] Parsing JSON-LD dans composables
- [x] Normalisation des relations vers IRI
- [x] Endpoints session profile compatibles
- [ ] Corriger CORS (injecter `CORS_ALLOW_ORIGIN` dans container + clear cache)
- [ ] Valider le squelette machine (endpoint `/api/machines/{id}/skeleton`)
- [ ] Valider documents catalogue (composant/piece/produit)

#### Étape 3.5 : Sécurité (À FAIRE)
- [ ] Réactiver JWT (après stabilisation)
- [ ] Ajouter refresh token si nécessaire

---

## Table des Matières
1. [Vue d'ensemble](#vue-densemble)
2. [Architecture Cible](#architecture-cible)
3. [Mapping Prisma → Doctrine](#mapping-prisma--doctrine)
4. [Stratégie de Migration des Données](#stratégie-de-migration-des-données)
5. [Configuration Docker](#configuration-docker)
6. [Plan d'Exécution Phase par Phase](#plan-dexécution-phase-par-phase)
7. [Gestion des Risques](#gestion-des-risques)
8. [Checklist de Migration](#checklist-de-migration)

---

## 1. Vue d'ensemble

### Situation Actuelle
- **Backend NestJS**: Port 3000, Prisma ORM, PostgreSQL, Sessions cookie-based
- **Frontend Nuxt 3**: Port 3001, API calls vers localhost:3000
- **Base de données**: PostgreSQL 16 sur port 5433, 28 modèles Prisma, données en production
- **Docker**: Container unique avec PHP 8.4 + Node 24.12, Apache, XDebug

### Objectif Final
- **Backend Symfony 8**: Port 8081, Doctrine ORM, API Platform 4.2, JWT auth
- **Frontend Nuxt 3**: Port 3001, API calls vers localhost:8081
- **Base de données**: Même PostgreSQL, migration Prisma → Doctrine
- **Docker**: 2 backends en parallèle pendant la transition (NestJS:3000 + Symfony:8081)

### Pourquoi cette migration ?
- Code généré par IA (ChatGPT) à nettoyer et reprendre en main
- Symfony + API Platform = stack PHP standard, mature, bien documentée
- JWT auth plus adapté pour API REST moderne
- Meilleure intégration avec l'écosystème PHP existant

---

## 2. Architecture Cible

### Stack Technique

#### Backend Symfony
```
Symfony 8.0
├── API Platform 4.2         # REST API auto-documentée (OpenAPI)
├── Doctrine ORM 3.6         # ORM pour PostgreSQL
├── Lexik JWT Bundle         # JWT authentication
├── Nelmio CORS Bundle       # CORS pour frontend
├── VichUploader Bundle      # Upload de documents
├── Symfony Validator        # Validation des DTOs
└── Doctrine Migrations      # Gestion des migrations DB
```

#### Structure de Projet
```
src/
├── Entity/                  # 16 entités Doctrine
│   ├── Site.php
│   ├── Machine.php
│   ├── Composant.php
│   ├── Piece.php
│   ├── Product.php
│   ├── TypeMachine.php
│   ├── ModelType.php
│   ├── Constructeur.php
│   ├── Profile.php
│   ├── Document.php
│   ├── CustomField.php
│   ├── CustomFieldValue.php
│   ├── MachineComponentLink.php
│   ├── MachinePieceLink.php
│   ├── MachineProductLink.php
│   └── TypeMachine*Requirement.php (×3)
├── Repository/              # Repositories Doctrine
├── Controller/              # Controllers custom si besoin
├── State/                   # State providers/processors API Platform
├── Validator/               # Contraintes de validation custom
└── Service/                 # Services métier (équivalents NestJS services)
```

#### Configuration JWT

**Flow d'authentification**:
```
1. POST /api/login_check
   Body: {"username": "...", "password": "..."}
   Response: {"token": "eyJ0eXAiOiJKV1QiLCJhbGc..."}

2. Requêtes authentifiées
   Header: Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGc...

3. Token refresh (optionnel)
   POST /api/token/refresh
   Body: {"refresh_token": "..."}
```

**Configuration sécurité** (`config/packages/security.yaml`):
```yaml
security:
    password_hashers:
        App\Entity\Profile:
            algorithm: auto

    providers:
        app_user_provider:
            entity:
                class: App\Entity\Profile
                property: email  # À ajouter à Profile

    firewalls:
        login:
            pattern: ^/api/login
            stateless: true
            json_login:
                check_path: /api/login_check
                success_handler: lexik_jwt_authentication.handler.authentication_success
                failure_handler: lexik_jwt_authentication.handler.authentication_failure

        api:
            pattern: ^/api
            stateless: true
            jwt: ~

    access_control:
        - { path: ^/api/login, roles: PUBLIC_ACCESS }
        - { path: ^/api/docs, roles: PUBLIC_ACCESS }
        - { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
```

---

## 3. Mapping Prisma → Doctrine

### 3.1 Types de Données

| Prisma | Doctrine | Notes |
|--------|----------|-------|
| `String` | `string` | `@ORM\Column(type="string")` |
| `String @id @default(cuid())` | `Ulid` | `@ORM\Id` + `@ORM\GeneratedValue(strategy="CUSTOM")` |
| `Int` | `integer` | `@ORM\Column(type="integer")` |
| `Decimal @db.Decimal(10,2)` | `string` | `@ORM\Column(type="decimal", precision=10, scale=2)` |
| `Boolean` | `bool` | `@ORM\Column(type="boolean")` |
| `DateTime` | `\DateTimeImmutable` | `@ORM\Column(type="datetime_immutable")` |
| `Json` | `array` | `@ORM\Column(type="json")` |
| `String[]` | `array` | `@ORM\Column(type="json")` |
| `String?` | `?string` | `@ORM\Column(nullable=true)` |

### 3.2 Relations

| Prisma | Doctrine | Exemple |
|--------|----------|---------|
| `@relation(onDelete: Cascade)` | `@ORM\JoinColumn(onDelete="CASCADE")` | Machine → Site |
| `@relation(onDelete: SetNull)` | `@ORM\JoinColumn(nullable=true, onDelete="SET NULL")` | Composant → Product |
| `Model[]` | `@ORM\OneToMany` + `Collection` | Site → machines |
| `Model` | `@ORM\ManyToOne` | Machine → site |
| Pas de `@relation` | `@ORM\ManyToMany` | Machine ↔ Constructeurs |

### 3.3 Contraintes et Index

| Prisma | Doctrine |
|--------|----------|
| `@unique` | `@ORM\Column(unique=true)` |
| `@@unique([field1, field2])` | `@ORM\UniqueConstraint(columns=["field1", "field2"])` |
| `@@map("table_name")` | `@ORM\Table(name="table_name")` |
| `@db.VarChar(120)` | `@ORM\Column(type="string", length=120)` |
| `@default("")` | `#[ORM\Column(options: ['default' => ''])]` |

### 3.4 Mapping Détaillé des 16 Entités

#### 1. Site
```php
#[ORM\Entity(repositoryClass: SiteRepository::class)]
#[ORM\Table(name: 'sites')]
#[ApiResource(
    operations: [
        new Get(),
        new GetCollection(),
        new Post(),
        new Put(),
        new Delete()
    ]
)]
class Site
{
    #[ORM\Id]
    #[ORM\Column(type: 'ulid', unique: true)]
    #[ORM\GeneratedValue(strategy: 'CUSTOM')]
    #[ORM\CustomIdGenerator(class: UlidGenerator::class)]
    private ?Ulid $id = null;

    #[ORM\Column(type: 'string', length: 255)]
    #[Assert\NotBlank]
    private string $name;

    #[ORM\Column(type: 'string', length: 255, options: ['default' => ''])]
    private string $contactName = '';

    #[ORM\Column(type: 'string', length: 20, options: ['default' => ''])]
    private string $contactPhone = '';

    #[ORM\Column(type: 'string', length: 500, options: ['default' => ''])]
    private string $contactAddress = '';

    #[ORM\Column(type: 'string', length: 10, options: ['default' => ''])]
    private string $contactPostalCode = '';

    #[ORM\Column(type: 'string', length: 100, options: ['default' => ''])]
    private string $contactCity = '';

    #[ORM\Column(type: 'datetime_immutable')]
    private \DateTimeImmutable $createdAt;

    #[ORM\Column(type: 'datetime_immutable')]
    private \DateTimeImmutable $updatedAt;

    #[ORM\OneToMany(mappedBy: 'site', targetEntity: Machine::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
    private Collection $machines;

    #[ORM\OneToMany(mappedBy: 'site', targetEntity: Document::class, cascade: ['remove'], orphanRemoval: true)]
    private Collection $documents;

    #[ORM\PrePersist]
    public function setCreatedAtValue(): void
    {
        $this->createdAt = new \DateTimeImmutable();
        $this->updatedAt = new \DateTimeImmutable();
    }

    #[ORM\PreUpdate]
    public function setUpdatedAtValue(): void
    {
        $this->updatedAt = new \DateTimeImmutable();
    }
}
```

#### 2. Machine
```php
#[ORM\Entity]
#[ORM\Table(name: 'machines')]
#[ApiResource]
class Machine
{
    #[ORM\Id]
    #[ORM\Column(type: 'ulid')]
    private ?Ulid $id = null;

    #[ORM\Column(type: 'string', length: 255, unique: true)]
    private string $name;

    #[ORM\Column(type: 'string', length: 255, nullable: true)]
    private ?string $reference = null;

    #[ORM\Column(type: 'decimal', precision: 10, scale: 2, nullable: true)]
    private ?string $prix = null;

    #[ORM\ManyToOne(targetEntity: Site::class, inversedBy: 'machines')]
    #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
    private Site $site;

    #[ORM\ManyToOne(targetEntity: TypeMachine::class, inversedBy: 'machines')]
    #[ORM\JoinColumn(nullable: true)]
    private ?TypeMachine $typeMachine = null;

    #[ORM\ManyToMany(targetEntity: Constructeur::class, inversedBy: 'machines')]
    #[ORM\JoinTable(name: 'machine_constructeurs')]
    private Collection $constructeurs;

    #[ORM\OneToMany(mappedBy: 'machine', targetEntity: MachineComponentLink::class, cascade: ['persist', 'remove'])]
    private Collection $componentLinks;

    #[ORM\OneToMany(mappedBy: 'machine', targetEntity: MachinePieceLink::class, cascade: ['persist', 'remove'])]
    private Collection $pieceLinks;

    #[ORM\OneToMany(mappedBy: 'machine', targetEntity: MachineProductLink::class, cascade: ['persist', 'remove'])]
    private Collection $productLinks;

    #[ORM\OneToMany(mappedBy: 'machine', targetEntity: Document::class, cascade: ['remove'])]
    private Collection $documents;

    #[ORM\OneToMany(mappedBy: 'machine', targetEntity: CustomFieldValue::class, cascade: ['persist', 'remove'])]
    private Collection $customFieldValues;

    #[ORM\Column(type: 'datetime_immutable')]
    private \DateTimeImmutable $createdAt;

    #[ORM\Column(type: 'datetime_immutable')]
    private \DateTimeImmutable $updatedAt;
}
```

#### 3. TypeMachine (avec champs JSON)
```php
#[ORM\Entity]
#[ORM\Table(name: 'type_machines')]
#[ApiResource]
class TypeMachine
{
    #[ORM\Id]
    #[ORM\Column(type: 'ulid')]
    private ?Ulid $id = null;

    #[ORM\Column(type: 'string', length: 255, unique: true)]
    private string $name;

    #[ORM\Column(type: 'text', nullable: true)]
    private ?string $description = null;

    #[ORM\Column(type: 'string', length: 100, nullable: true)]
    private ?string $category = null;

    #[ORM\Column(type: 'string', length: 100, nullable: true)]
    private ?string $maintenanceFrequency = null;

    // Champs JSON (structure hiérarchique, specs techniques)
    #[ORM\Column(type: 'json', nullable: true)]
    private ?array $components = null;

    #[ORM\Column(type: 'json', nullable: true)]
    private ?array $criticalParts = null;

    #[ORM\Column(type: 'json', nullable: true)]
    private ?array $machinePieces = null;

    #[ORM\Column(type: 'json', nullable: true)]
    private ?array $specifications = null;

    #[ORM\OneToMany(mappedBy: 'typeMachine', targetEntity: Machine::class)]
    private Collection $machines;

    #[ORM\OneToMany(mappedBy: 'typeMachine', targetEntity: CustomField::class, cascade: ['remove'])]
    private Collection $customFields;

    #[ORM\OneToMany(mappedBy: 'typeMachine', targetEntity: TypeMachineComponentRequirement::class, cascade: ['persist', 'remove'])]
    private Collection $componentRequirements;

    #[ORM\OneToMany(mappedBy: 'typeMachine', targetEntity: TypeMachinePieceRequirement::class, cascade: ['persist', 'remove'])]
    private Collection $pieceRequirements;

    #[ORM\OneToMany(mappedBy: 'typeMachine', targetEntity: TypeMachineProductRequirement::class, cascade: ['persist', 'remove'])]
    private Collection $productRequirements;
}
```

#### 4. ModelType (avec Enum)
```php
enum ModelCategory: string
{
    case COMPONENT = 'COMPONENT';
    case PIECE = 'PIECE';
    case PRODUCT = 'PRODUCT';
}

#[ORM\Entity]
#[ORM\Table(name: 'model_types')]
#[ORM\UniqueConstraint(name: 'unique_category_name', columns: ['category', 'name'])]
#[ApiResource]
class ModelType
{
    #[ORM\Id]
    #[ORM\Column(type: 'ulid')]
    private ?Ulid $id = null;

    #[ORM\Column(type: 'string', length: 120)]
    private string $name;

    #[ORM\Column(type: 'string', length: 60, unique: true)]
    private string $code;

    #[ORM\Column(type: 'string', enumType: ModelCategory::class)]
    private ModelCategory $category;

    #[ORM\Column(type: 'text', nullable: true)]
    private ?string $notes = null;

    #[ORM\Column(type: 'text', nullable: true)]
    private ?string $description = null;

    #[ORM\Column(type: 'json', nullable: true)]
    private ?array $componentSkeleton = null;

    #[ORM\Column(type: 'json', nullable: true)]
    private ?array $pieceSkeleton = null;

    #[ORM\Column(type: 'json', nullable: true)]
    private ?array $productSkeleton = null;

    #[ORM\OneToMany(mappedBy: 'typeComposant', targetEntity: Composant::class)]
    private Collection $composants;

    #[ORM\OneToMany(mappedBy: 'typePiece', targetEntity: Piece::class)]
    private Collection $pieces;

    #[ORM\OneToMany(mappedBy: 'typeProduct', targetEntity: Product::class)]
    private Collection $products;

    // Relations avec les requirements (×3)
    // Relations avec les custom fields (×3)
}
```

#### 5. Document (Relations Polymorphiques)
```php
#[ORM\Entity]
#[ORM\Table(name: 'documents')]
#[ApiResource(
    normalizationContext: ['groups' => ['document:read']],
    denormalizationContext: ['groups' => ['document:write']]
)]
class Document
{
    #[ORM\Id]
    #[ORM\Column(type: 'ulid')]
    private ?Ulid $id = null;

    #[ORM\Column(type: 'string', length: 255)]
    private string $name;

    #[ORM\Column(type: 'string', length: 255)]
    private string $filename;

    #[ORM\Column(type: 'string', length: 500)]
    private string $path;

    #[ORM\Column(type: 'string', length: 100)]
    private string $mimeType;

    #[ORM\Column(type: 'integer')]
    private int $size;

    // Relations polymorphiques (nullable)
    #[ORM\ManyToOne(targetEntity: Machine::class, inversedBy: 'documents')]
    #[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
    private ?Machine $machine = null;

    #[ORM\ManyToOne(targetEntity: Composant::class, inversedBy: 'documents')]
    #[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
    private ?Composant $composant = null;

    #[ORM\ManyToOne(targetEntity: Piece::class, inversedBy: 'documents')]
    #[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
    private ?Piece $piece = null;

    #[ORM\ManyToOne(targetEntity: Product::class, inversedBy: 'documents')]
    #[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
    private ?Product $product = null;

    #[ORM\ManyToOne(targetEntity: Site::class, inversedBy: 'documents')]
    #[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
    private ?Site $site = null;
}
```

#### 6-16. Autres Entités
Les autres entités suivent le même pattern :
- **Composant, Piece, Product** : Similaires à Machine avec relations vers ModelType
- **Constructeur** : ManyToMany avec Machine/Composant/Piece/Product
- **Profile** : Entité utilisateur (à enrichir avec email/password pour JWT)
- **CustomField, CustomFieldValue** : Système de champs dynamiques
- **MachineComponentLink, MachinePieceLink, MachineProductLink** : Tables de liaison avec overrides
- **TypeMachine*Requirement (×3)** : Définition des requirements (minCount, maxCount, label, etc.)

---

## 4. Stratégie de Migration des Données

### 4.1 Problème : CUID vs ULID

**Prisma utilise CUID** (`@default(cuid())`) :
- Format: `clabcd123xyz...` (25 caractères)
- Exemple: `cldu8kzj30000vh8q9k0y8h7i`

**Doctrine recommande ULID** :
- Format: `01ARZ3NDEKTSV4RRFFQ69G5FAV` (26 caractères)
- Type Symfony : `Symfony\Component\Uid\Ulid`

**Solutions possibles** :

#### Option A : Garder les CUID comme chaînes (RECOMMANDÉE)
```php
#[ORM\Id]
#[ORM\Column(type: 'string', length: 30)]
private string $id;
```

**Avantages** :
- ✅ Aucune modification des IDs existants
- ✅ Migration transparente, pas de mapping d'IDs
- ✅ Relations FK préservées
- ✅ Frontend continue à utiliser les mêmes IDs

**Inconvénients** :
- ❌ Pas de type Ulid natif Symfony
- ❌ Moins "moderne"

#### Option B : Migrer CUID → ULID avec mapping
```php
#[ORM\Id]
#[ORM\Column(type: 'ulid')]
private Ulid $id;
```

**Nécessite** :
1. Créer une table de mapping `cuid_to_ulid`
2. Générer de nouveaux ULIDs pour chaque ligne
3. Mettre à jour toutes les FK
4. Adapter le frontend

**Risque** : Complexité élevée, risque de perte de données.

### 4.2 Approche Recommandée : Migration en 3 Étapes

#### Étape 1 : Créer le schéma Doctrine en parallèle (SAFE)

1. Générer les migrations Doctrine sans exécution :
   ```bash
   php bin/console doctrine:migrations:diff
   ```

2. **NE PAS exécuter** la migration immédiatement

3. Analyser le SQL généré pour détecter les différences :
   - Noms de colonnes (camelCase → snake_case)
   - Types de données (Decimal en Prisma → string en Doctrine)
   - Contraintes (vérifier les FK, indexes)

#### Étape 2 : Script de Validation (Dry-run)

Créer un script PHP qui :
1. Lit toutes les tables Prisma actuelles
2. Compare avec le schéma Doctrine généré
3. Génère un rapport de différences
4. Valide que toutes les données peuvent être mappées

```php
// scripts/validate-migration.php
use Doctrine\ORM\EntityManagerInterface;

class MigrationValidator
{
    public function validate(): array
    {
        $issues = [];

        // Vérifier chaque table
        foreach ($this->getTables() as $table) {
            // Comparer schéma Prisma vs Doctrine
            $schemaIssues = $this->compareSchemas($table);

            // Vérifier les données peuvent être migrées
            $dataIssues = $this->validateData($table);

            $issues = array_merge($issues, $schemaIssues, $dataIssues);
        }

        return $issues;
    }
}
```

#### Étape 3 : Migration réelle (avec Backup)

```bash
# 1. BACKUP de la DB
pg_dump -U postgres -h localhost -p 5433 inventory_db > backup_$(date +%Y%m%d_%H%M%S).sql

# 2. Exécuter les migrations Doctrine
php bin/console doctrine:migrations:migrate --no-interaction

# 3. Vérifier l'intégrité
php bin/console doctrine:schema:validate

# 4. Si erreur : ROLLBACK
psql -U postgres -h localhost -p 5433 inventory_db < backup_YYYYMMDD_HHMMSS.sql
```

### 4.3 Mapping des Noms de Colonnes

Prisma → Doctrine naming strategy :

| Prisma | Doctrine | Action |
|--------|----------|--------|
| `siteId` | `site_id` | Annotation `#[ORM\JoinColumn(name="siteId")]` ou laisser Doctrine snake_case |
| `createdAt` | `created_at` | Idem |
| `contactName` | `contact_name` | Idem |

**Recommandation** : Utiliser `#[ORM\Column(name="xxx")]` pour garder les noms Prisma existants et éviter les ALTER TABLE.

### 4.4 Checklist de Migration DB

- [ ] Créer backup complet de la DB
- [ ] Générer migration Doctrine (`doctrine:migrations:diff`)
- [ ] Analyser SQL généré manuellement
- [ ] Tester migration sur copie locale de la DB
- [ ] Valider que les données sont intactes après migration
- [ ] Créer script de rollback
- [ ] Documenter les différences schéma Prisma vs Doctrine
- [ ] Tester toutes les requêtes via Doctrine
- [ ] Vérifier les performances (indexes, FK)

---

## 5. Configuration Docker

### 5.1 Setup : 2 Backends en Parallèle

**Objectif** : Faire tourner NestJS (port 3000) ET Symfony (port 8081) simultanément pendant la transition.

#### Modification `docker-compose.yml`

```yaml
services:
  web:
    container_name: php-${DOCKER_APP_NAME}-apache
    build:
      context: ./docker/php
      dockerfile: Dockerfile
      args:
        DOCKER_PHP_VERSION: ${DOCKER_PHP_VERSION}
        DOCKER_NODE_VERSION: ${DOCKER_NODE_VERSION}
        CURRENT_UID: ${CURRENT_UID}
        CURRENT_GID: ${CURRENT_GID}
    environment:
      PHP_IDE_CONFIG: serverName=${DOCKER_APP_NAME}-docker
      XDEBUG_CLIENT_HOST: ${XDEBUG_CLIENT_HOST:-host.docker.internal}
      XDEBUG_CONFIG: client_host=${XDEBUG_CLIENT_HOST:-host.docker.internal} client_port=9003
      DATABASE_URL: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?serverVersion=16&charset=utf8"
      # Variables pour NestJS
      NESTJS_DATABASE_URL: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public"
      NESTJS_PORT: 3000
      NESTJS_CORS_ORIGIN: "http://localhost:3001"
      # Variables pour Symfony
      APP_ENV: dev
      APP_SECRET: ${APP_SECRET}
      JWT_SECRET_KEY: ${JWT_SECRET_KEY}
      JWT_PUBLIC_KEY: ${JWT_PUBLIC_KEY}
      JWT_PASSPHRASE: ${JWT_PASSPHRASE}
    volumes:
      - ./:/var/www/html
      - ~/.cache:/var/www/.cache
      - ~/.config:/var/www/.config
      - ~/.composer:/var/www/.composer
      - ./docker/php/config/php.ini:/usr/local/etc/php/php.ini
      - ./docker/php/config/vhost.conf:/etc/apache2/sites-available/000-default.conf
      - ./docker/php/config/docker-php-ext-xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
      - ./LOG:/var/www/html/LOG
      - ./LOG/logs_apache:/var/log/apache2/
    extra_hosts:
      - "host.docker.internal:host-gateway"
    depends_on:
      - db
    ports:
      - "8081:80"     # Apache/Symfony
      - "3000:3000"   # NestJS backend (nouveau mapping)
      - "3001:3001"   # Nuxt frontend
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - pg_data:/var/lib/postgresql/data
    ports:
      - "${POSTGRES_PORT:-5433}:5432"
    restart: unless-stopped

volumes:
  pg_data:
```

#### Configuration Apache VirtualHost

```apache
# /docker/php/config/vhost.conf

<VirtualHost *:80>
    ServerName localhost
    DocumentRoot /var/www/html/public

    <Directory /var/www/html/public>
        AllowOverride All
        Require all granted
        FallbackResource /index.php
    </Directory>

    # Logs
    ErrorLog /var/log/apache2/error.log
    CustomLog /var/log/apache2/access.log combined
</VirtualHost>
```

#### Script de Démarrage des 2 Backends

Créer `/docker/start-services.sh` :

```bash
#!/bin/bash

# Démarrer Apache (Symfony)
apache2-foreground &

# Installer dépendances NestJS si besoin
cd /var/www/html/Inventory_backend
if [ ! -d "node_modules" ]; then
    npm install
fi

# Démarrer NestJS
npm run start:dev &

# Installer dépendances Nuxt si besoin
cd /var/www/html/Inventory_frontend
if [ ! -d "node_modules" ]; then
    npm install
fi

# Démarrer Nuxt
npm run dev &

# Garder le container actif
wait -n
```

Modifier `Dockerfile` pour utiliser ce script :

```dockerfile
# À la fin du Dockerfile
COPY start-services.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/start-services.sh

CMD ["start-services.sh"]
```

### 5.2 Variables d'Environnement

Créer `/docker/.env.docker.local` (déjà existant, à compléter) :

```env
# Existant
DOCKER_APP_NAME=inventory
DOCKER_PHP_VERSION=8.4.6
DOCKER_NODE_VERSION=24.12.0
CURRENT_UID=1000
CURRENT_GID=1000
POSTGRES_DB=inventory_db
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_PORT=5433

# Nouveau : Symfony
APP_ENV=dev
APP_SECRET=changeme_super_secret_key_123456789
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=your_jwt_passphrase

# Nouveau : NestJS
NESTJS_PORT=3000
SESSION_SECRET=changeme_session_secret
CORS_ORIGIN=http://localhost:3001
```

### 5.3 Génération des Clés JWT

Ajouter au script de démarrage :

```bash
# Générer clés JWT si inexistantes
mkdir -p /var/www/html/config/jwt
if [ ! -f /var/www/html/config/jwt/private.pem ]; then
    openssl genpkey -out /var/www/html/config/jwt/private.pem -aes256 -algorithm rsa -pkeyopt rsa_keybits:4096 -pass pass:${JWT_PASSPHRASE}
    openssl pkey -in /var/www/html/config/jwt/private.pem -passin pass:${JWT_PASSPHRASE} -out /var/www/html/config/jwt/public.pem -pubout
    chmod 600 /var/www/html/config/jwt/*.pem
fi
```

---

## 6. Plan d'Exécution Phase par Phase

### Phase 1 : Préparation (Semaine 1)

#### Tâches
1. **Installer bundles Symfony** :
   ```bash
   composer require lexik/jwt-authentication-bundle
   composer require vich/uploader-bundle
   composer require symfony/uid
   ```

2. **Configurer JWT** :
   - Générer clés RSA
   - Configurer `lexik_jwt_authentication.yaml`
   - Configurer `security.yaml`

3. **Créer entité Profile enrichie** :
   ```php
   class Profile implements UserInterface
   {
       // Ajouter email, password, roles
   }
   ```

4. **Analyser schéma Prisma** :
   - Documenter toutes les relations
   - Identifier les champs JSON complexes
   - Lister les contraintes de validation

5. **Setup Docker** :
   - Modifier `docker-compose.yml`
   - Créer script de démarrage multi-services
   - Tester les 2 backends en parallèle

#### Livrables
- [ ] Symfony configuré avec tous les bundles
- [ ] JWT fonctionnel avec endpoint `/api/login_check`
- [ ] Docker lance NestJS (3000) + Symfony (8081) + Nuxt (3001)
- [ ] Documentation schéma Prisma complète

---

### Phase 2 : Création des Entités (Semaine 2-3)

#### Tâches
1. **Créer toutes les entités Doctrine** (16 entités) :
   - Utiliser les attributs PHP 8 (`#[ORM\Entity]`)
   - Mapper exactement le schéma Prisma
   - Ajouter annotations API Platform
   - Implémenter les lifecycle callbacks (`#[ORM\PrePersist]`, etc.)

2. **Générer repositories** :
   ```bash
   php bin/console make:entity --regenerate
   ```

3. **Créer migration Doctrine** :
   ```bash
   php bin/console doctrine:migrations:diff
   ```

4. **Analyser SQL généré** :
   - Comparer avec schéma Prisma actuel
   - Identifier les différences
   - Préparer script d'ajustement si besoin

5. **Tester sur DB vide** :
   ```bash
   # Créer DB test
   createdb inventory_test
   # Appliquer migrations
   php bin/console doctrine:migrations:migrate --env=test
   # Valider schéma
   php bin/console doctrine:schema:validate
   ```

#### Livrables
- [ ] 16 entités Doctrine complètes avec relations
- [ ] Migration Doctrine générée et validée
- [ ] Tests sur DB vide réussis
- [ ] Documentation des différences Prisma/Doctrine

---

### Phase 3 : Migration de la Base de Données (Semaine 4)

#### Tâches
1. **Backup complet** :
   ```bash
   pg_dump -U postgres -h localhost -p 5433 inventory_db > backup_avant_migration.sql
   ```

2. **Créer script de validation** :
   ```php
   // scripts/validate-schema.php
   // Comparer schéma Prisma vs Doctrine
   ```

3. **Exécuter migration Doctrine** :
   ```bash
   php bin/console doctrine:migrations:migrate --no-interaction
   ```

4. **Valider données migrées** :
   - Compter les lignes par table (Prisma count vs Doctrine count)
   - Vérifier intégrité référentielle
   - Tester quelques requêtes

5. **Tests de régression** :
   - Lire toutes les entités via Doctrine
   - Vérifier les relations
   - Tester les champs JSON

#### Livrables
- [ ] Base de données migrée avec succès
- [ ] Backup de sécurité créé
- [ ] Script de validation OK (0 différences)
- [ ] Tests de lecture/écriture via Doctrine OK

---

### Phase 4 : Logique Métier (Semaine 5-6)

#### Tâches
1. **Migrer services NestJS → Symfony** :

**Mapping des services** :

| NestJS Service | Symfony Service | Responsabilités |
|----------------|-----------------|-----------------|
| `SitesService` | `SiteManager` | CRUD sites |
| `MachinesService` | `MachineManager` | CRUD machines, reconfiguration |
| `ComposantsService` | `ComposantManager` | CRUD composants |
| `PiecesService` | `PieceManager` | CRUD pieces |
| `ProductsService` | `ProductManager` | CRUD products |
| `TypesService` | `TypeMachineManager` | Gestion types machines |
| `ModelTypeService` | `ModelTypeManager` | Gestion model types |
| `DocumentsService` | `DocumentManager` | Upload/download documents |
| `CustomFieldsService` | `CustomFieldManager` | Gestion custom fields |
| `ConstructeursService` | `ConstructeurManager` | Gestion constructeurs |
| `ProfilesService` | `ProfileManager` | Gestion profiles |
| `SessionService` | Supprimé (JWT) | - |

2. **Implémenter upload de documents** :
   - Configurer VichUploaderBundle
   - Créer endpoint `POST /api/documents`
   - Créer endpoint `GET /api/documents/{id}/download`

3. **Implémenter custom fields** :
   - Service de gestion des définitions
   - Service de gestion des valeurs
   - Validation dynamique

4. **Créer State Processors API Platform** :
   - Pour logique custom (ex: reconfiguration machine)
   - Pour validation complexe

5. **Tests unitaires** :
   - Tester chaque service
   - Mocker les repositories
   - Vérifier logique métier

#### Livrables
- [ ] Tous les services métier migrés
- [ ] Upload/download documents fonctionnel
- [ ] Custom fields opérationnels
- [ ] Tests unitaires couvrant 80%+ du code

---

### Phase 5 : API Platform (Semaine 7)

#### Tâches
1. **Configurer les endpoints API Platform** :
   - Vérifier routes auto-générées
   - Customiser opérations si besoin
   - Ajouter filtres de recherche
   - Configurer pagination

2. **Sérialisation** :
   - Créer groupes de normalisation
   - Gérer relations circulaires
   - Optimiser avec `@Groups`

3. **Validation** :
   - Ajouter contraintes de validation
   - Créer validators custom
   - Gérer messages d'erreur

4. **Documentation API** :
   - Générer OpenAPI spec
   - Tester via Swagger UI
   - Documenter endpoints custom

5. **Tests d'intégration** :
   - Tester tous les endpoints CRUD
   - Vérifier codes de réponse HTTP
   - Valider structure JSON

#### Livrables
- [ ] Tous les endpoints API fonctionnels
- [ ] Documentation OpenAPI complète
- [ ] Tests d'intégration OK
- [ ] Swagger UI accessible sur `/api/docs`

---

### Phase 6 : Adaptation Frontend (Semaine 8)

#### Tâches
1. **Modifier `useApi.js`** :
   ```js
   // Ancien
   const baseURL = 'http://localhost:3000';

   // Nouveau
   const baseURL = import.meta.env.VITE_API_URL || 'http://localhost:8081/api';
   ```

2. **Implémenter gestion JWT** :
   ```js
   // composables/useAuth.js
   export const useAuth = () => {
     const token = ref(localStorage.getItem('jwt_token'));

     const login = async (username, password) => {
       const response = await fetch('/api/login_check', {
         method: 'POST',
         body: JSON.stringify({ username, password })
       });
       const { token } = await response.json();
       localStorage.setItem('jwt_token', token);
       token.value = token;
     };

     const logout = () => {
       localStorage.removeItem('jwt_token');
       token.value = null;
     };

     return { token, login, logout };
   };
   ```

3. **Adapter les composables** :
   - `useSites.js` → vérifier format réponse
   - `useMachines.js` → idem
   - Tous les autres composables

4. **Gestion des changements d'API** :
   - Ajuster parsing des réponses JSON:API vs JSON simple
   - Gérer les nouveaux formats d'erreur
   - Adapter les IDs si changement

5. **Tests fonctionnels** :
   - Tester chaque page du frontend
   - Vérifier tous les flows utilisateur
   - Tester upload/download documents

#### Livrables
- [ ] Frontend connecté au backend Symfony
- [ ] Authentification JWT fonctionnelle
- [ ] Tous les composables adaptés
- [ ] Tests fonctionnels réussis

---

### Phase 7 : Tests & Décommissionnement NestJS (Semaine 9)

#### Tâches
1. **Tests de charge** :
   - Comparer performances NestJS vs Symfony
   - Vérifier temps de réponse
   - Tester avec données réelles

2. **Tests de régression complète** :
   - Rejouer tous les scénarios utilisateur
   - Comparer résultats NestJS vs Symfony
   - Fixer les bugs

3. **Documentation** :
   - Documenter nouvelle architecture
   - Créer guide migration pour équipe
   - Documenter différences API

4. **Décommissionner NestJS** :
   - Arrêter le service NestJS
   - Supprimer du docker-compose
   - Archiver le code (ne pas supprimer)

5. **Cleanup** :
   - Supprimer dépendances NestJS inutilisées
   - Nettoyer fichiers de config
   - Optimiser Docker

#### Livrables
- [ ] Tests de charge validés
- [ ] 0 bugs de régression
- [ ] Documentation complète
- [ ] NestJS arrêté, Symfony en production
- [ ] Code NestJS archivé

---

## 7. Gestion des Risques

### 7.1 Risques Techniques

| Risque | Probabilité | Impact | Mitigation |
|--------|-------------|--------|------------|
| **Perte de données pendant migration DB** | Faible | Critique | Backup complet avant migration, script de rollback, validation post-migration |
| **Incompatibilité schéma Prisma/Doctrine** | Moyenne | Élevé | Validation schema dry-run, tests sur DB copie, garder noms de colonnes Prisma |
| **Performances Symfony < NestJS** | Faible | Moyen | Benchmarks, optimisation requêtes Doctrine, cache API Platform |
| **Bugs lors migration logique métier** | Moyenne | Élevé | Tests unitaires + intégration, comparison NestJS/Symfony side-by-side |
| **JWT incompatible avec frontend** | Faible | Moyen | Tests d'auth dès Phase 1, documentation claire |
| **Upload documents ne fonctionne pas** | Faible | Moyen | Tester VichUploader tôt, fallback manuel si besoin |
| **Relations Doctrine mal configurées** | Moyenne | Élevé | Tests avec données réelles, validation via `doctrine:schema:validate` |

### 7.2 Risques Projet

| Risque | Probabilité | Impact | Mitigation |
|--------|-------------|--------|------------|
| **Timeline trop optimiste** | Élevée | Moyen | Buffer de 2 semaines, découpage en phases indépendantes |
| **Manque de tests** | Moyenne | Élevé | Écrire tests dès Phase 2, coverage > 80% |
| **Documentation insuffisante** | Moyenne | Moyen | Documenter au fur et à mesure, pas à la fin |
| **Réversion nécessaire** | Faible | Critique | Garder NestJS actif en parallèle, backup DB, script de rollback |

### 7.3 Plan de Rollback

Si problème critique :

1. **Arrêter Symfony** :
   ```bash
   docker-compose stop web
   ```

2. **Restaurer DB depuis backup** :
   ```bash
   psql -U postgres -h localhost -p 5433 inventory_db < backup_avant_migration.sql
   ```

3. **Redémarrer NestJS** :
   ```bash
   cd Inventory_backend && npm run start:dev
   ```

4. **Reconfigurer frontend** :
   ```js
   // Revert API URL
   const baseURL = 'http://localhost:3000';
   ```

5. **Analyser l'erreur** :
   - Lire logs Symfony (`var/log/dev.log`)
   - Lire logs PostgreSQL
   - Identifier root cause

6. **Fix et retry** :
   - Corriger le problème
   - Retester en local
   - Relancer migration

---

## 8. Checklist de Migration

### Avant de Commencer
- [ ] Lire entièrement ce document
- [ ] Comprendre schéma Prisma actuel
- [ ] Valider que Docker fonctionne
- [ ] Créer backup DB initial
- [ ] Setup environnement de dev local

### Phase 1 : Préparation
- [ ] Installer bundles Symfony (JWT, VichUploader, Uid)
- [ ] Configurer JWT avec clés RSA
- [ ] Enrichir entité Profile (email, password, roles)
- [ ] Modifier docker-compose.yml pour 2 backends
- [ ] Tester démarrage des 2 backends en parallèle
- [ ] Documenter schéma Prisma

### Phase 2 : Entités
- [ ] Créer 16 entités Doctrine
- [ ] Ajouter annotations API Platform
- [ ] Générer migration Doctrine
- [ ] Analyser SQL généré
- [ ] Tester migration sur DB vide
- [ ] Valider schéma Doctrine

### Phase 3 : Migration DB
- [ ] Créer backup DB production
- [ ] Créer script de validation
- [ ] Exécuter migration Doctrine
- [ ] Appliquer migrations post-migration (ajouts colonnes, types JSON)
- [ ] Valider count de lignes par table
- [ ] Tester requêtes Doctrine
- [ ] Vérifier intégrité référentielle

### Phase 4 : Services
- [ ] Migrer les 11 services métier
- [ ] Implémenter upload documents
- [ ] Implémenter custom fields
- [ ] Écrire tests unitaires
- [ ] Valider logique métier

### Phase 5 : API
- [ ] Configurer endpoints API Platform
- [ ] Implémenter sérialisation
- [ ] Ajouter validation
- [ ] Générer doc OpenAPI
- [ ] Tests d'intégration

### Phase 6 : Frontend
- [ ] Modifier useApi.js (nouveau baseURL)
- [ ] Implémenter gestion JWT
- [ ] Adapter tous les composables
- [ ] Tester toutes les pages
- [ ] Valider flows utilisateur

### Phase 7 : Finalisation
- [ ] Tests de charge
- [ ] Tests de régression complète
- [ ] Documenter nouvelle architecture
- [ ] Arrêter NestJS
- [ ] Archiver code NestJS
- [ ] Cleanup Docker

---

## Annexes

### A. Commandes Utiles

```bash
# Symfony
php bin/console doctrine:schema:validate
php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate
php bin/console doctrine:query:sql "SELECT COUNT(*) FROM sites"
php bin/console debug:router
php bin/console cache:clear

# Docker
docker-compose up -d --build
docker-compose logs -f web
docker-compose exec web bash
docker-compose down

# Base de données
pg_dump -U postgres -h localhost -p 5433 inventory_db > backup.sql
psql -U postgres -h localhost -p 5433 inventory_db < backup.sql
psql -U postgres -h localhost -p 5433 inventory_db -c "SELECT COUNT(*) FROM sites"

# Tests
php bin/phpunit
php vendor/bin/phpstan analyse src
```

### B. Structure Finale du Projet

```
/home/r-dev/Inventory/
├── Inventory_backend/               # À archiver après migration
├── Inventory_frontend/              # Frontend Nuxt (à garder)
│   ├── app/
│   │   ├── composables/
│   │   │   ├── useApi.js          # Modifier baseURL
│   │   │   ├── useAuth.js         # Nouveau : JWT
│   │   │   └── ...
│   │   └── pages/
├── src/                             # Backend Symfony (nouveau)
│   ├── Entity/
│   │   ├── Site.php
│   │   ├── Machine.php
│   │   └── ... (16 entités)
│   ├── Repository/
│   ├── Service/
│   │   ├── SiteManager.php
│   │   ├── MachineManager.php
│   │   └── ... (11 services)
│   ├── Controller/                  # Si besoin de custom controllers
│   └── State/                       # State providers API Platform
├── config/
│   ├── packages/
│   │   ├── doctrine.yaml
│   │   ├── api_platform.yaml
│   │   ├── lexik_jwt_authentication.yaml
│   │   ├── security.yaml
│   │   └── nelmio_cors.yaml
│   └── jwt/
│       ├── private.pem
│       └── public.pem
├── migrations/                      # Migrations Doctrine
├── docker/
│   ├── php/
│   │   ├── Dockerfile
│   │   └── config/
│   ├── start-services.sh           # Nouveau : lance Symfony + Nuxt
│   ├── .env.docker
│   └── .env.docker.local
├── docker-compose.yml              # Modifié : 2 backends
├── composer.json                   # Symfony dependencies
└── MIGRATION_PLAN.md               # Ce document
```

### C. Ressources

- [API Platform Docs](https://api-platform.com/docs/)
- [Doctrine ORM Docs](https://www.doctrine-project.org/projects/doctrine-orm/en/latest/)
- [Lexik JWT Bundle](https://github.com/lexik/LexikJWTAuthenticationBundle/blob/2.x/Resources/doc/index.rst)
- [Symfony Best Practices](https://symfony.com/doc/current/best_practices.html)
- [Prisma to Doctrine Migration Guide](https://symfony.com/doc/current/doctrine.html#creating-an-entity-class)

---

## Conclusion

Ce plan de migration détaille la stratégie pour passer de NestJS/Prisma à Symfony/API Platform de manière progressive et sécurisée. Les points clés :

1. **Migration incrémentale** : Garder NestJS actif en parallèle
2. **Sécurité des données** : Backups, validation, rollback
3. **Modernisation** : JWT auth, API Platform, documentation OpenAPI
4. **Minimiser les risques** : Tests à chaque phase, validation continue

**Durée estimée** : 9 semaines (avec buffer)
**Effort requis** : 1 développeur full-time

**Prêt à démarrer ?** Validation de ce plan avant de commencer la Phase 1.
