Files
Inventory/MIGRATION_PLAN.md

44 KiB
Raw Blame History

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)

  • Site, TypeMachine, Machine
  • ModelType, Composant, Piece, Product
  • Constructeur, Document
  • CustomField, CustomFieldValue
  • MachineComponentLink, MachinePieceLink, MachineProductLink
  • TypeMachine*Requirement (×3)

Étape 3.3 : Migration des Données (TERMINÉE)

  • Backup inventory-data
  • Schéma Doctrine aligné
  • Import inventory-datainventory
  • Validation des comptes (script scripts/validate-migration.php)

Étape 3.4 : Front Nuxt (EN COURS)

  • Base URL API -> http://localhost:8081/api
  • Parsing JSON-LD dans composables
  • Normalisation des relations vers IRI
  • 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
  2. Architecture Cible
  3. Mapping Prisma → Doctrine
  4. Stratégie de Migration des Données
  5. Configuration Docker
  6. Plan d'Exécution Phase par Phase
  7. Gestion des Risques
  8. 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):

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

#[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

#[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)

#[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)

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)

#[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)

#[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

#[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 :

    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
// 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)

# 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

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

# /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 :

#!/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 :

# À 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) :

# 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 :

# 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 :

    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 :

    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 :

    php bin/console make:entity --regenerate
    
  3. Créer migration Doctrine :

    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 :

    # 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 :

    pg_dump -U postgres -h localhost -p 5433 inventory_db > backup_avant_migration.sql
    
  2. Créer script de validation :

    // scripts/validate-schema.php
    // Comparer schéma Prisma vs Doctrine
    
  3. Exécuter migration Doctrine :

    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) -
  1. Implémenter upload de documents :

    • Configurer VichUploaderBundle
    • Créer endpoint POST /api/documents
    • Créer endpoint GET /api/documents/{id}/download
  2. Implémenter custom fields :

    • Service de gestion des définitions
    • Service de gestion des valeurs
    • Validation dynamique
  3. Créer State Processors API Platform :

    • Pour logique custom (ex: reconfiguration machine)
    • Pour validation complexe
  4. 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 :

    // Ancien
    const baseURL = 'http://localhost:3000';
    
    // Nouveau
    const baseURL = import.meta.env.VITE_API_URL || 'http://localhost:8081/api';
    
  2. Implémenter gestion JWT :

    // 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 :

    docker-compose stop web
    
  2. Restaurer DB depuis backup :

    psql -U postgres -h localhost -p 5433 inventory_db < backup_avant_migration.sql
    
  3. Redémarrer NestJS :

    cd Inventory_backend && npm run start:dev
    
  4. Reconfigurer frontend :

    // 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
  • 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

# 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


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.