# 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 ```sql -- 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 ```typescript // src/common/wizard/wizard-base.entity.ts /** * Interfaz base para todos los wizards */ export interface IWizard { id: string; wizardType: string; userId: string; data: Record; currentStep?: string; parentModel?: string; parentId?: string; createdAt: Date; expiresAt: Date; } /** * Contexto del wizard */ export interface WizardContext { activeId?: string; activeIds?: string[]; activeModel?: string; defaultValues?: Record; [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 ```typescript // 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, ) {} /** * Crear instancia de wizard */ async createWizard>( wizardType: string, userId: string, context: WizardContext, initialData?: T ): Promise { // 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 { 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>( wizardId: string, userId: string, data: Partial ): Promise { 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 { const wizard = await this.getWizard(wizardId, userId); wizard.currentStep = step; return this.wizardRepo.save(wizard); } /** * Eliminar wizard */ async deleteWizard(wizardId: string, userId: string): Promise { 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> { // 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 { await this.wizardRepo.delete({ expiresAt: LessThan(new Date()) }); } } ``` ### 3.2 Decorador @Wizard ```typescript // 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 ```typescript // src/common/wizard/wizard-base.ts import { BadRequestException } from '@nestjs/common'; /** * Clase base abstracta para implementar wizards específicos */ export abstract class WizardBase> { 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 { 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): Promise { 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; /** * Obtener datos por defecto del padre */ protected abstract getDefaultData(context: WizardContext): Promise; /** * Procesar y confirmar wizard */ abstract confirm(): Promise; /** * Cancelar wizard */ async cancel(): Promise { await this.wizardService.deleteWizard(this.wizardId, this.userId); return { type: 'close' }; } /** * Avanzar al siguiente paso (multi-step) */ async nextStep(): Promise { 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 { 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 { // Implementar en subclases } } ``` --- ## Parte 4: Ejemplos de Implementación ### 4.1 Wizard Simple: Confirmar Acción ```typescript // 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 { 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 { 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 { 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 { 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 ```typescript // 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 { constructor( wizardService: WizardService, dataSource: DataSource, private readonly saleOrderService: SaleOrderService, private readonly invoiceService: InvoiceService, ) { super(wizardService, dataSource); } protected async getDefaultData(context: WizardContext): Promise { 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 { 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 { 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 { if (this.data.previewLines.length === 0) { throw new BadRequestException('No hay líneas para facturar'); } } async confirm(): Promise { 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 ```typescript // 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; }>; } @Wizard({ name: 'mass_update', steps: ['selection', 'preview'], }) @Injectable() export class MassUpdateWizard extends WizardBase { constructor( wizardService: WizardService, dataSource: DataSource, ) { super(wizardService, dataSource); } protected async getDefaultData(context: WizardContext): Promise { 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 { 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 { // 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), })); await this.update({ previewRecords }); } protected async validate(): Promise { if (this.data.targetIds.length === 0) { throw new BadRequestException('No hay registros seleccionados'); } } async confirm(): Promise { 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 = {}; 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 ```typescript // 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 ```typescript // Frontend: WizardModal.tsx interface WizardModalProps { wizardType: string; context: WizardContext; onClose: () => void; onComplete: (result: WizardActionResult) => void; } export const WizardModal: React.FC = ({ wizardType, context, onClose, onComplete, }) => { const [wizardId, setWizardId] = useState(null); const [data, setData] = useState>({}); const [currentStep, setCurrentStep] = useState(''); const [steps, setSteps] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(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>) => { 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 ( ); }; const currentStepIndex = steps.indexOf(currentStep); const isLastStep = currentStepIndex === steps.length - 1 || steps.length === 0; const isFirstStep = currentStepIndex === 0; return ( {getWizardTitle(wizardType)} {steps.length > 1 && ( {steps.map((step) => ( {getStepLabel(wizardType, step)} ))} )} {error && ( setError(null)}> {error} )} {loading ? ( ) : ( renderStepContent() )} {!isFirstStep && steps.length > 1 && ( )} {isLastStep ? ( ) : ( )} ); }; ``` --- ## 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*