erp-core/docs/04-modelado/especificaciones-tecnicas/transversal/SPEC-WIZARD-TRANSIENT-MODEL.md

39 KiB

SPEC-WIZARD-TRANSIENT-MODEL

Metadatos

Campo Valor
Código SPEC-TRANS-019
Versión 1.0.0
Fecha 2025-01-15
Autor Requirements-Analyst Agent
Estado DRAFT
Prioridad P0
Módulos Afectados Todos (patrón transversal)
Gaps Cubiertos Patrón TransientModel/Wizard

Resumen Ejecutivo

Esta especificación define el sistema de wizards (asistentes) para ERP Core:

  1. TransientModel: Entidades temporales con limpieza automática
  2. Wizard Single-Step: Asistentes de confirmación simples
  3. Wizard Multi-Step: Asistentes con múltiples pasos/estados
  4. Batch Operations: Operaciones masivas sobre múltiples registros
  5. Context Passing: Transferencia de datos entre registro padre y wizard
  6. Preview Pattern: Previsualización antes de confirmar acción

Referencia Odoo 18

Basado en análisis del patrón TransientModel de Odoo 18:

  • models.TransientModel: Base para wizards
  • Cleanup automático de registros antiguos (>1 hora)
  • Aislamiento por usuario
  • Integración con ir.actions.act_window

Parte 1: Arquitectura del Sistema

1.1 Visión General

┌─────────────────────────────────────────────────────────────────────────┐
│                    SISTEMA DE WIZARDS                                    │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  ┌──────────────────────────────────────────────────────────────┐       │
│  │                    REGISTRO PADRE                             │       │
│  │        (Sale Order, Invoice, Partner, etc.)                   │       │
│  │                                                                │       │
│  │   [Botón Acción] ──┬──context: {active_id, active_model}     │       │
│  └────────────────────┼─────────────────────────────────────────┘       │
│                       │                                                  │
│                       ▼                                                  │
│  ┌──────────────────────────────────────────────────────────────┐       │
│  │                    WIZARD (TransientModel)                    │       │
│  │                                                                │       │
│  │   ┌─────────────┐  ┌─────────────┐  ┌─────────────┐          │       │
│  │   │ default_get │  │   Fields    │  │   Actions   │          │       │
│  │   │ (populate)  │  │ (user input)│  │  (process)  │          │       │
│  │   └──────┬──────┘  └──────┬──────┘  └──────┬──────┘          │       │
│  │          │                │                │                  │       │
│  │          └────────────────┼────────────────┘                  │       │
│  │                           │                                   │       │
│  │                 ┌─────────┴─────────┐                        │       │
│  │                 │ action_confirm()  │                        │       │
│  │                 └─────────┬─────────┘                        │       │
│  └───────────────────────────┼──────────────────────────────────┘       │
│                              │                                           │
│         ┌────────────────────┼────────────────────┐                     │
│         │                    │                    │                     │
│         ▼                    ▼                    ▼                     │
│  ┌─────────────┐      ┌─────────────┐      ┌─────────────┐             │
│  │   Close     │      │   Create    │      │   Update    │             │
│  │   Dialog    │      │   Records   │      │   Parent    │             │
│  └─────────────┘      └─────────────┘      └─────────────┘             │
│                                                                          │
│  ┌──────────────────────────────────────────────────────────────┐       │
│  │                    CLEANUP SERVICE (Cron)                     │       │
│  │           Elimina wizards > 1 hora automáticamente            │       │
│  └──────────────────────────────────────────────────────────────┘       │
└─────────────────────────────────────────────────────────────────────────┘

1.2 Flujo de Wizard

1. USUARIO INICIA WIZARD
   ├─ Click en botón de acción
   ├─ Sistema prepara context (active_id, active_model)
   └─ Abre modal con formulario de wizard
        │
        ▼
2. WIZARD SE INICIALIZA
   ├─ default_get() obtiene datos del padre
   ├─ Pre-popula campos
   └─ Usuario ve formulario con datos
        │
        ▼
3. USUARIO INTERACTÚA
   ├─ Modifica campos
   ├─ Selecciona opciones
   └─ Click en acción (Confirmar/Siguiente)
        │
        ▼
4. WIZARD PROCESA
   ├─ Valida datos
   ├─ Ejecuta lógica de negocio
   └─ Crea/actualiza registros
        │
        ▼
5. WIZARD RETORNA
   ├─ Cierra modal (act_window_close)
   ├─ O abre registro creado
   └─ O avanza al siguiente paso

Parte 2: Modelo de Datos

2.1 Tablas de Sistema

-- Registro de wizards para cleanup
CREATE TABLE wizard_registry (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

    -- Identificación del wizard
    wizard_type VARCHAR(100) NOT NULL, -- Nombre del tipo de wizard

    -- Usuario propietario
    user_id UUID NOT NULL REFERENCES users(id),

    -- Datos temporales (JSON)
    data JSONB NOT NULL DEFAULT '{}',

    -- Estado multi-step
    current_step VARCHAR(50),

    -- Referencia al registro padre
    parent_model VARCHAR(100),
    parent_id UUID,

    -- Timestamps
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),

    -- Para cleanup
    expires_at TIMESTAMPTZ NOT NULL DEFAULT (now() + INTERVAL '1 hour')
);

CREATE INDEX idx_wizard_registry_user ON wizard_registry(user_id);
CREATE INDEX idx_wizard_registry_expires ON wizard_registry(expires_at);
CREATE INDEX idx_wizard_registry_type ON wizard_registry(wizard_type);

2.2 Estructura de Wizard Genérico

// src/common/wizard/wizard-base.entity.ts

/**
 * Interfaz base para todos los wizards
 */
export interface IWizard {
  id: string;
  wizardType: string;
  userId: string;
  data: Record<string, any>;
  currentStep?: string;
  parentModel?: string;
  parentId?: string;
  createdAt: Date;
  expiresAt: Date;
}

/**
 * Contexto del wizard
 */
export interface WizardContext {
  activeId?: string;
  activeIds?: string[];
  activeModel?: string;
  defaultValues?: Record<string, any>;
  [key: string]: any;
}

/**
 * Resultado de acción de wizard
 */
export interface WizardActionResult {
  type: 'close' | 'open_record' | 'open_list' | 'next_step' | 'reload';
  model?: string;
  resId?: string;
  resIds?: string[];
  viewMode?: string;
  nextStep?: string;
  message?: string;
}

Parte 3: Implementación Base

3.1 WizardService Base

// src/common/wizard/wizard.service.ts

import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, LessThan } from 'typeorm';
import { Cron, CronExpression } from '@nestjs/schedule';

@Injectable()
export class WizardService {
  constructor(
    @InjectRepository(WizardRegistry)
    private readonly wizardRepo: Repository<WizardRegistry>,
  ) {}

  /**
   * Crear instancia de wizard
   */
  async createWizard<T extends Record<string, any>>(
    wizardType: string,
    userId: string,
    context: WizardContext,
    initialData?: T
  ): Promise<WizardRegistry> {
    // Obtener datos por defecto del registro padre
    let defaultData = { ...initialData };

    if (context.activeId && context.activeModel) {
      const parentDefaults = await this.getParentDefaults(
        context.activeModel,
        context.activeId,
        wizardType
      );
      defaultData = { ...defaultData, ...parentDefaults };
    }

    // Aplicar defaults del context
    if (context.defaultValues) {
      defaultData = { ...defaultData, ...context.defaultValues };
    }

    const wizard = this.wizardRepo.create({
      wizardType,
      userId,
      data: defaultData,
      parentModel: context.activeModel,
      parentId: context.activeId,
      expiresAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hora
    });

    return this.wizardRepo.save(wizard);
  }

  /**
   * Obtener wizard verificando propiedad
   */
  async getWizard(wizardId: string, userId: string): Promise<WizardRegistry> {
    const wizard = await this.wizardRepo.findOne({
      where: { id: wizardId }
    });

    if (!wizard) {
      throw new NotFoundException('Wizard no encontrado');
    }

    // Solo el propietario puede acceder
    if (wizard.userId !== userId) {
      throw new ForbiddenException('No tiene acceso a este wizard');
    }

    // Verificar expiración
    if (wizard.expiresAt < new Date()) {
      await this.wizardRepo.delete(wizardId);
      throw new NotFoundException('Wizard expirado');
    }

    return wizard;
  }

  /**
   * Actualizar datos del wizard
   */
  async updateWizardData<T extends Record<string, any>>(
    wizardId: string,
    userId: string,
    data: Partial<T>
  ): Promise<WizardRegistry> {
    const wizard = await this.getWizard(wizardId, userId);

    wizard.data = { ...wizard.data, ...data };
    wizard.updatedAt = new Date();

    return this.wizardRepo.save(wizard);
  }

  /**
   * Cambiar paso del wizard (multi-step)
   */
  async setWizardStep(
    wizardId: string,
    userId: string,
    step: string
  ): Promise<WizardRegistry> {
    const wizard = await this.getWizard(wizardId, userId);
    wizard.currentStep = step;
    return this.wizardRepo.save(wizard);
  }

  /**
   * Eliminar wizard
   */
  async deleteWizard(wizardId: string, userId: string): Promise<void> {
    const wizard = await this.getWizard(wizardId, userId);
    await this.wizardRepo.delete(wizard.id);
  }

  /**
   * Obtener defaults del registro padre
   */
  protected async getParentDefaults(
    model: string,
    id: string,
    wizardType: string
  ): Promise<Record<string, any>> {
    // Este método debe ser overrideado por wizards específicos
    // o usar un registro de mappings
    return {};
  }

  /**
   * Cleanup de wizards expirados
   */
  @Cron(CronExpression.EVERY_HOUR)
  async cleanupExpiredWizards(): Promise<void> {
    await this.wizardRepo.delete({
      expiresAt: LessThan(new Date())
    });
  }
}

3.2 Decorador @Wizard

// src/common/wizard/wizard.decorator.ts

import 'reflect-metadata';

const WIZARD_METADATA_KEY = 'wizard:config';

export interface WizardConfig {
  name: string;
  steps?: string[];
  defaultTimeout?: number; // minutos
  parentModels?: string[]; // Modelos desde los que se puede llamar
}

/**
 * Decorador para definir un wizard
 */
export function Wizard(config: WizardConfig): ClassDecorator {
  return (target: Function) => {
    Reflect.defineMetadata(WIZARD_METADATA_KEY, config, target);
  };
}

/**
 * Obtener configuración de wizard
 */
export function getWizardConfig(target: object): WizardConfig | undefined {
  return Reflect.getMetadata(WIZARD_METADATA_KEY, target.constructor);
}

3.3 Base Class para Wizards

// src/common/wizard/wizard-base.ts

import { BadRequestException } from '@nestjs/common';

/**
 * Clase base abstracta para implementar wizards específicos
 */
export abstract class WizardBase<TData extends Record<string, any>> {
  protected wizardId: string;
  protected userId: string;
  protected data: TData;
  protected context: WizardContext;
  protected currentStep: string;

  constructor(
    protected readonly wizardService: WizardService,
    protected readonly dataSource: DataSource,
  ) {}

  /**
   * Inicializar wizard con datos
   */
  async initialize(
    userId: string,
    context: WizardContext
  ): Promise<{ wizardId: string; data: TData }> {
    const config = getWizardConfig(this);

    // Obtener defaults
    const defaultData = await this.getDefaultData(context);

    // Crear wizard
    const wizard = await this.wizardService.createWizard(
      config.name,
      userId,
      context,
      defaultData
    );

    this.wizardId = wizard.id;
    this.userId = userId;
    this.data = wizard.data as TData;
    this.context = context;
    this.currentStep = config.steps?.[0] || 'main';

    return {
      wizardId: wizard.id,
      data: this.data,
    };
  }

  /**
   * Cargar wizard existente
   */
  async load(wizardId: string, userId: string): Promise<TData> {
    const wizard = await this.wizardService.getWizard(wizardId, userId);

    this.wizardId = wizard.id;
    this.userId = userId;
    this.data = wizard.data as TData;
    this.currentStep = wizard.currentStep || 'main';
    this.context = {
      activeId: wizard.parentId,
      activeModel: wizard.parentModel,
    };

    return this.data;
  }

  /**
   * Actualizar datos del wizard
   */
  async update(data: Partial<TData>): Promise<TData> {
    const wizard = await this.wizardService.updateWizardData(
      this.wizardId,
      this.userId,
      data
    );
    this.data = wizard.data as TData;
    return this.data;
  }

  /**
   * Validar datos antes de procesar
   */
  protected abstract validate(): Promise<void>;

  /**
   * Obtener datos por defecto del padre
   */
  protected abstract getDefaultData(context: WizardContext): Promise<TData>;

  /**
   * Procesar y confirmar wizard
   */
  abstract confirm(): Promise<WizardActionResult>;

  /**
   * Cancelar wizard
   */
  async cancel(): Promise<WizardActionResult> {
    await this.wizardService.deleteWizard(this.wizardId, this.userId);
    return { type: 'close' };
  }

  /**
   * Avanzar al siguiente paso (multi-step)
   */
  async nextStep(): Promise<WizardActionResult> {
    const config = getWizardConfig(this);
    const steps = config.steps || ['main'];
    const currentIndex = steps.indexOf(this.currentStep);

    if (currentIndex === -1 || currentIndex >= steps.length - 1) {
      // Último paso, confirmar
      return this.confirm();
    }

    // Validar paso actual
    await this.validateStep(this.currentStep);

    // Avanzar
    const nextStep = steps[currentIndex + 1];
    await this.wizardService.setWizardStep(this.wizardId, this.userId, nextStep);
    this.currentStep = nextStep;

    return {
      type: 'next_step',
      nextStep,
    };
  }

  /**
   * Retroceder al paso anterior
   */
  async previousStep(): Promise<WizardActionResult> {
    const config = getWizardConfig(this);
    const steps = config.steps || ['main'];
    const currentIndex = steps.indexOf(this.currentStep);

    if (currentIndex <= 0) {
      throw new BadRequestException('Ya está en el primer paso');
    }

    const prevStep = steps[currentIndex - 1];
    await this.wizardService.setWizardStep(this.wizardId, this.userId, prevStep);
    this.currentStep = prevStep;

    return {
      type: 'next_step',
      nextStep: prevStep,
    };
  }

  /**
   * Validar paso específico (override en subclases)
   */
  protected async validateStep(step: string): Promise<void> {
    // Implementar en subclases
  }
}

Parte 4: Ejemplos de Implementación

4.1 Wizard Simple: Confirmar Acción

// src/modules/sales/wizards/confirm-order.wizard.ts

import { Injectable } from '@nestjs/common';
import { Wizard, WizardBase, WizardActionResult, WizardContext } from '@common/wizard';

interface ConfirmOrderData {
  saleOrderId: string;
  orderName: string;
  partnerName: string;
  amountTotal: number;
  confirmationNote?: string;
  sendEmail: boolean;
}

@Wizard({
  name: 'confirm_order',
  parentModels: ['sale_orders'],
})
@Injectable()
export class ConfirmOrderWizard extends WizardBase<ConfirmOrderData> {
  constructor(
    wizardService: WizardService,
    dataSource: DataSource,
    private readonly saleOrderService: SaleOrderService,
    private readonly emailService: EmailService,
  ) {
    super(wizardService, dataSource);
  }

  /**
   * Obtener datos del pedido padre
   */
  protected async getDefaultData(context: WizardContext): Promise<ConfirmOrderData> {
    if (!context.activeId) {
      throw new BadRequestException('Se requiere un pedido activo');
    }

    const order = await this.saleOrderService.findOne(context.activeId);

    return {
      saleOrderId: order.id,
      orderName: order.name,
      partnerName: order.partner?.name || '',
      amountTotal: order.amountTotal,
      sendEmail: true,
    };
  }

  /**
   * Validar antes de confirmar
   */
  protected async validate(): Promise<void> {
    const order = await this.saleOrderService.findOne(this.data.saleOrderId);

    if (order.state !== 'draft') {
      throw new BadRequestException('Solo se pueden confirmar pedidos en borrador');
    }

    if (!order.orderLines || order.orderLines.length === 0) {
      throw new BadRequestException('El pedido no tiene líneas');
    }
  }

  /**
   * Confirmar el pedido
   */
  async confirm(): Promise<WizardActionResult> {
    await this.validate();

    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      // Confirmar pedido
      await this.saleOrderService.confirm(this.data.saleOrderId, this.userId);

      // Enviar email si está marcado
      if (this.data.sendEmail) {
        await this.emailService.sendOrderConfirmation(this.data.saleOrderId);
      }

      // Agregar nota si existe
      if (this.data.confirmationNote) {
        await this.saleOrderService.addNote(
          this.data.saleOrderId,
          this.data.confirmationNote,
          this.userId
        );
      }

      await queryRunner.commitTransaction();

      // Eliminar wizard
      await this.cancel();

      return {
        type: 'open_record',
        model: 'sale_orders',
        resId: this.data.saleOrderId,
        message: 'Pedido confirmado exitosamente',
      };
    } catch (error) {
      await queryRunner.rollbackTransaction();
      throw error;
    } finally {
      await queryRunner.release();
    }
  }
}

4.2 Wizard Multi-Step: Crear Factura desde Pedido

// src/modules/accounting/wizards/create-invoice.wizard.ts

interface CreateInvoiceData {
  // Step 1: Selección
  saleOrderId: string;
  orderLines: Array<{
    lineId: string;
    productName: string;
    qtyOrdered: number;
    qtyToInvoice: number;
    selected: boolean;
  }>;

  // Step 2: Configuración
  invoiceDate: Date;
  paymentTermId?: string;
  journalId?: string;

  // Step 3: Revisión
  previewLines: Array<{
    productName: string;
    quantity: number;
    unitPrice: number;
    subtotal: number;
  }>;
  totalAmount: number;
}

@Wizard({
  name: 'create_invoice_from_so',
  steps: ['selection', 'configuration', 'review'],
  parentModels: ['sale_orders'],
})
@Injectable()
export class CreateInvoiceWizard extends WizardBase<CreateInvoiceData> {
  constructor(
    wizardService: WizardService,
    dataSource: DataSource,
    private readonly saleOrderService: SaleOrderService,
    private readonly invoiceService: InvoiceService,
  ) {
    super(wizardService, dataSource);
  }

  protected async getDefaultData(context: WizardContext): Promise<CreateInvoiceData> {
    const order = await this.saleOrderService.findOneWithLines(context.activeId);

    return {
      saleOrderId: order.id,
      orderLines: order.orderLines.map(line => ({
        lineId: line.id,
        productName: line.product?.name || line.name,
        qtyOrdered: line.productQty,
        qtyToInvoice: line.qtyToInvoice,
        selected: line.qtyToInvoice > 0,
      })),
      invoiceDate: new Date(),
      previewLines: [],
      totalAmount: 0,
    };
  }

  /**
   * Validar paso de selección
   */
  protected async validateStep(step: string): Promise<void> {
    if (step === 'selection') {
      const selectedLines = this.data.orderLines.filter(l => l.selected);
      if (selectedLines.length === 0) {
        throw new BadRequestException('Debe seleccionar al menos una línea');
      }

      // Validar cantidades
      for (const line of selectedLines) {
        if (line.qtyToInvoice <= 0) {
          throw new BadRequestException(`La línea ${line.productName} no tiene cantidad a facturar`);
        }
      }
    }

    if (step === 'configuration') {
      if (!this.data.invoiceDate) {
        throw new BadRequestException('La fecha de factura es requerida');
      }

      // Generar preview
      await this.generatePreview();
    }
  }

  /**
   * Generar preview de factura
   */
  private async generatePreview(): Promise<void> {
    const selectedLines = this.data.orderLines.filter(l => l.selected);
    const order = await this.saleOrderService.findOneWithLines(this.data.saleOrderId);

    const previewLines = [];
    let total = 0;

    for (const selected of selectedLines) {
      const orderLine = order.orderLines.find(l => l.id === selected.lineId);
      if (!orderLine) continue;

      const subtotal = selected.qtyToInvoice * orderLine.priceUnit;
      total += subtotal;

      previewLines.push({
        productName: selected.productName,
        quantity: selected.qtyToInvoice,
        unitPrice: orderLine.priceUnit,
        subtotal,
      });
    }

    await this.update({
      previewLines,
      totalAmount: total,
    });
  }

  protected async validate(): Promise<void> {
    if (this.data.previewLines.length === 0) {
      throw new BadRequestException('No hay líneas para facturar');
    }
  }

  async confirm(): Promise<WizardActionResult> {
    await this.validate();

    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      // Crear factura
      const invoice = await this.invoiceService.createFromSaleOrder({
        saleOrderId: this.data.saleOrderId,
        invoiceDate: this.data.invoiceDate,
        paymentTermId: this.data.paymentTermId,
        journalId: this.data.journalId,
        lines: this.data.orderLines
          .filter(l => l.selected)
          .map(l => ({
            saleOrderLineId: l.lineId,
            quantity: l.qtyToInvoice,
          })),
      }, queryRunner);

      await queryRunner.commitTransaction();

      // Eliminar wizard
      await this.cancel();

      return {
        type: 'open_record',
        model: 'account_moves',
        resId: invoice.id,
        message: `Factura ${invoice.name} creada exitosamente`,
      };
    } catch (error) {
      await queryRunner.rollbackTransaction();
      throw error;
    } finally {
      await queryRunner.release();
    }
  }
}

4.3 Wizard de Operación Masiva

// src/modules/common/wizards/mass-update.wizard.ts

interface MassUpdateData {
  targetModel: string;
  targetIds: string[];
  recordCount: number;
  fieldsToUpdate: Array<{
    fieldName: string;
    fieldLabel: string;
    newValue: any;
    updateType: 'set' | 'increment' | 'append';
  }>;
  previewRecords: Array<{
    id: string;
    name: string;
    currentValues: Record<string, any>;
  }>;
}

@Wizard({
  name: 'mass_update',
  steps: ['selection', 'preview'],
})
@Injectable()
export class MassUpdateWizard extends WizardBase<MassUpdateData> {
  constructor(
    wizardService: WizardService,
    dataSource: DataSource,
  ) {
    super(wizardService, dataSource);
  }

  protected async getDefaultData(context: WizardContext): Promise<MassUpdateData> {
    const ids = context.activeIds || (context.activeId ? [context.activeId] : []);

    return {
      targetModel: context.activeModel || '',
      targetIds: ids,
      recordCount: ids.length,
      fieldsToUpdate: [],
      previewRecords: [],
    };
  }

  protected async validateStep(step: string): Promise<void> {
    if (step === 'selection') {
      if (this.data.fieldsToUpdate.length === 0) {
        throw new BadRequestException('Debe seleccionar al menos un campo a actualizar');
      }

      // Generar preview
      await this.generatePreview();
    }
  }

  private async generatePreview(): Promise<void> {
    // Obtener primeros 5 registros como preview
    const previewIds = this.data.targetIds.slice(0, 5);
    const repo = this.dataSource.getRepository(this.data.targetModel);

    const records = await repo.findByIds(previewIds);

    const previewRecords = records.map(record => ({
      id: record.id,
      name: record.name || record.id,
      currentValues: this.data.fieldsToUpdate.reduce((acc, field) => {
        acc[field.fieldName] = record[field.fieldName];
        return acc;
      }, {} as Record<string, any>),
    }));

    await this.update({ previewRecords });
  }

  protected async validate(): Promise<void> {
    if (this.data.targetIds.length === 0) {
      throw new BadRequestException('No hay registros seleccionados');
    }
  }

  async confirm(): Promise<WizardActionResult> {
    await this.validate();

    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      const repo = queryRunner.manager.getRepository(this.data.targetModel);

      // Construir update
      const updateData: Record<string, any> = {};

      for (const field of this.data.fieldsToUpdate) {
        if (field.updateType === 'set') {
          updateData[field.fieldName] = field.newValue;
        }
        // increment y append requieren lógica especial por registro
      }

      // Update masivo
      if (Object.keys(updateData).length > 0) {
        await repo.update(
          { id: In(this.data.targetIds) },
          updateData
        );
      }

      // Procesar increment/append individualmente
      for (const field of this.data.fieldsToUpdate) {
        if (field.updateType === 'increment') {
          await queryRunner.manager.query(`
            UPDATE ${this.data.targetModel}
            SET ${field.fieldName} = ${field.fieldName} + $1
            WHERE id = ANY($2)
          `, [field.newValue, this.data.targetIds]);
        }
      }

      await queryRunner.commitTransaction();

      // Eliminar wizard
      await this.cancel();

      return {
        type: 'reload',
        message: `${this.data.recordCount} registros actualizados`,
      };
    } catch (error) {
      await queryRunner.rollbackTransaction();
      throw error;
    } finally {
      await queryRunner.release();
    }
  }
}

Parte 5: API REST

5.1 Controlador de Wizards

// src/common/wizard/wizard.controller.ts

@Controller('api/v1/wizards')
@UseGuards(JwtAuthGuard)
@ApiTags('Wizards')
export class WizardController {
  constructor(
    private readonly wizardService: WizardService,
    private readonly wizardRegistry: WizardRegistryService,
  ) {}

  @Post(':wizardType/create')
  @ApiOperation({ summary: 'Crear nuevo wizard' })
  async createWizard(
    @Param('wizardType') wizardType: string,
    @Body() dto: CreateWizardDto,
    @CurrentUser() user: User,
  ): Promise<WizardResponseDto> {
    const wizard = this.wizardRegistry.getWizardHandler(wizardType);

    const result = await wizard.initialize(user.id, {
      activeId: dto.activeId,
      activeIds: dto.activeIds,
      activeModel: dto.activeModel,
      defaultValues: dto.defaultValues,
    });

    return {
      wizardId: result.wizardId,
      wizardType,
      data: result.data,
      currentStep: wizard.currentStep,
    };
  }

  @Get(':wizardId')
  @ApiOperation({ summary: 'Obtener wizard' })
  async getWizard(
    @Param('wizardId', ParseUUIDPipe) wizardId: string,
    @CurrentUser() user: User,
  ): Promise<WizardResponseDto> {
    const wizardRecord = await this.wizardService.getWizard(wizardId, user.id);

    return {
      wizardId: wizardRecord.id,
      wizardType: wizardRecord.wizardType,
      data: wizardRecord.data,
      currentStep: wizardRecord.currentStep,
    };
  }

  @Patch(':wizardId')
  @ApiOperation({ summary: 'Actualizar datos del wizard' })
  async updateWizard(
    @Param('wizardId', ParseUUIDPipe) wizardId: string,
    @Body() dto: UpdateWizardDto,
    @CurrentUser() user: User,
  ): Promise<WizardResponseDto> {
    const wizardRecord = await this.wizardService.getWizard(wizardId, user.id);
    const wizard = this.wizardRegistry.getWizardHandler(wizardRecord.wizardType);

    await wizard.load(wizardId, user.id);
    const data = await wizard.update(dto.data);

    return {
      wizardId,
      wizardType: wizardRecord.wizardType,
      data,
      currentStep: wizard.currentStep,
    };
  }

  @Post(':wizardId/next')
  @ApiOperation({ summary: 'Siguiente paso (multi-step)' })
  async nextStep(
    @Param('wizardId', ParseUUIDPipe) wizardId: string,
    @Body() dto: UpdateWizardDto,
    @CurrentUser() user: User,
  ): Promise<WizardActionResponseDto> {
    const wizardRecord = await this.wizardService.getWizard(wizardId, user.id);
    const wizard = this.wizardRegistry.getWizardHandler(wizardRecord.wizardType);

    await wizard.load(wizardId, user.id);

    // Actualizar datos si se enviaron
    if (dto.data) {
      await wizard.update(dto.data);
    }

    const result = await wizard.nextStep();

    return this.mapActionResult(result, wizardId);
  }

  @Post(':wizardId/previous')
  @ApiOperation({ summary: 'Paso anterior (multi-step)' })
  async previousStep(
    @Param('wizardId', ParseUUIDPipe) wizardId: string,
    @CurrentUser() user: User,
  ): Promise<WizardActionResponseDto> {
    const wizardRecord = await this.wizardService.getWizard(wizardId, user.id);
    const wizard = this.wizardRegistry.getWizardHandler(wizardRecord.wizardType);

    await wizard.load(wizardId, user.id);
    const result = await wizard.previousStep();

    return this.mapActionResult(result, wizardId);
  }

  @Post(':wizardId/confirm')
  @ApiOperation({ summary: 'Confirmar y procesar wizard' })
  async confirm(
    @Param('wizardId', ParseUUIDPipe) wizardId: string,
    @Body() dto: UpdateWizardDto,
    @CurrentUser() user: User,
  ): Promise<WizardActionResponseDto> {
    const wizardRecord = await this.wizardService.getWizard(wizardId, user.id);
    const wizard = this.wizardRegistry.getWizardHandler(wizardRecord.wizardType);

    await wizard.load(wizardId, user.id);

    // Actualizar datos finales si se enviaron
    if (dto.data) {
      await wizard.update(dto.data);
    }

    const result = await wizard.confirm();

    return this.mapActionResult(result, wizardId);
  }

  @Delete(':wizardId')
  @ApiOperation({ summary: 'Cancelar wizard' })
  async cancel(
    @Param('wizardId', ParseUUIDPipe) wizardId: string,
    @CurrentUser() user: User,
  ): Promise<void> {
    const wizardRecord = await this.wizardService.getWizard(wizardId, user.id);
    const wizard = this.wizardRegistry.getWizardHandler(wizardRecord.wizardType);

    await wizard.load(wizardId, user.id);
    await wizard.cancel();
  }

  private mapActionResult(
    result: WizardActionResult,
    wizardId: string
  ): WizardActionResponseDto {
    return {
      action: result.type,
      wizardId: result.type === 'next_step' ? wizardId : undefined,
      nextStep: result.nextStep,
      targetModel: result.model,
      targetId: result.resId,
      targetIds: result.resIds,
      message: result.message,
    };
  }
}

Parte 6: Frontend - Componente Modal

6.1 WizardModal Component

// Frontend: WizardModal.tsx

interface WizardModalProps {
  wizardType: string;
  context: WizardContext;
  onClose: () => void;
  onComplete: (result: WizardActionResult) => void;
}

export const WizardModal: React.FC<WizardModalProps> = ({
  wizardType,
  context,
  onClose,
  onComplete,
}) => {
  const [wizardId, setWizardId] = useState<string | null>(null);
  const [data, setData] = useState<Record<string, any>>({});
  const [currentStep, setCurrentStep] = useState<string>('');
  const [steps, setSteps] = useState<string[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  // Inicializar wizard
  useEffect(() => {
    const initWizard = async () => {
      setLoading(true);
      try {
        const response = await api.post(`/wizards/${wizardType}/create`, {
          activeId: context.activeId,
          activeIds: context.activeIds,
          activeModel: context.activeModel,
        });

        setWizardId(response.data.wizardId);
        setData(response.data.data);
        setCurrentStep(response.data.currentStep);
        setSteps(response.data.steps || []);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    initWizard();
  }, [wizardType, context]);

  // Actualizar datos
  const handleDataChange = (newData: Partial<Record<string, any>>) => {
    setData(prev => ({ ...prev, ...newData }));
  };

  // Siguiente paso
  const handleNext = async () => {
    setLoading(true);
    setError(null);
    try {
      const response = await api.post(`/wizards/${wizardId}/next`, { data });

      if (response.data.action === 'next_step') {
        setCurrentStep(response.data.nextStep);
        // Recargar datos
        const wizardData = await api.get(`/wizards/${wizardId}`);
        setData(wizardData.data.data);
      } else {
        onComplete(response.data);
      }
    } catch (err) {
      setError(err.response?.data?.message || err.message);
    } finally {
      setLoading(false);
    }
  };

  // Paso anterior
  const handlePrevious = async () => {
    setLoading(true);
    try {
      const response = await api.post(`/wizards/${wizardId}/previous`);
      setCurrentStep(response.data.nextStep);

      const wizardData = await api.get(`/wizards/${wizardId}`);
      setData(wizardData.data.data);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };

  // Confirmar
  const handleConfirm = async () => {
    setLoading(true);
    setError(null);
    try {
      const response = await api.post(`/wizards/${wizardId}/confirm`, { data });
      onComplete(response.data);
    } catch (err) {
      setError(err.response?.data?.message || err.message);
    } finally {
      setLoading(false);
    }
  };

  // Cancelar
  const handleCancel = async () => {
    if (wizardId) {
      await api.delete(`/wizards/${wizardId}`);
    }
    onClose();
  };

  // Renderizar contenido del step
  const renderStepContent = () => {
    const WizardForm = getWizardFormComponent(wizardType, currentStep);
    return (
      <WizardForm
        data={data}
        onChange={handleDataChange}
        disabled={loading}
      />
    );
  };

  const currentStepIndex = steps.indexOf(currentStep);
  const isLastStep = currentStepIndex === steps.length - 1 || steps.length === 0;
  const isFirstStep = currentStepIndex === 0;

  return (
    <Modal open onClose={handleCancel} maxWidth="md">
      <ModalHeader>
        <Typography variant="h6">{getWizardTitle(wizardType)}</Typography>
        {steps.length > 1 && (
          <Stepper activeStep={currentStepIndex}>
            {steps.map((step) => (
              <Step key={step}>
                <StepLabel>{getStepLabel(wizardType, step)}</StepLabel>
              </Step>
            ))}
          </Stepper>
        )}
      </ModalHeader>

      <ModalContent>
        {error && (
          <Alert severity="error" onClose={() => setError(null)}>
            {error}
          </Alert>
        )}

        {loading ? (
          <CircularProgress />
        ) : (
          renderStepContent()
        )}
      </ModalContent>

      <ModalFooter>
        <Button onClick={handleCancel} disabled={loading}>
          Cancelar
        </Button>

        {!isFirstStep && steps.length > 1 && (
          <Button onClick={handlePrevious} disabled={loading}>
            Anterior
          </Button>
        )}

        {isLastStep ? (
          <Button
            variant="contained"
            onClick={handleConfirm}
            disabled={loading}
          >
            Confirmar
          </Button>
        ) : (
          <Button
            variant="contained"
            onClick={handleNext}
            disabled={loading}
          >
            Siguiente
          </Button>
        )}
      </ModalFooter>
    </Modal>
  );
};

Apéndice A: Wizards Predefinidos

Wizard Modelo Padre Propósito
confirm_order sale_orders Confirmar pedido de venta
create_invoice sale_orders Crear factura desde pedido
create_delivery sale_orders Crear entrega desde pedido
register_payment account_moves Registrar pago de factura
bank_reconcile bank_statements Conciliación bancaria
mass_update * Actualización masiva
import_data * Importar datos desde archivo
export_data * Exportar datos a archivo

Apéndice B: Checklist de Implementación

  • Tabla wizard_registry (migración)
  • WizardService base
  • Decorador @Wizard
  • Clase WizardBase abstracta
  • WizardRegistryService (registro de handlers)
  • API REST controller
  • DTOs y validaciones
  • Cron job de cleanup
  • Componente WizardModal (frontend)
  • Hook useWizard
  • Wizards predefinidos:
    • ConfirmOrderWizard
    • CreateInvoiceWizard
    • RegisterPaymentWizard
    • MassUpdateWizard
  • Tests unitarios
  • Tests de integración
  • Documentación de creación de wizards

Documento generado como parte del análisis de gaps ERP Core vs Odoo 18 Referencia: Patrón TransientModel/Wizard - Asistentes Temporales