39 KiB
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:
- TransientModel: Entidades temporales con limpieza automática
- Wizard Single-Step: Asistentes de confirmación simples
- Wizard Multi-Step: Asistentes con múltiples pasos/estados
- Batch Operations: Operaciones masivas sobre múltiples registros
- Context Passing: Transferencia de datos entre registro padre y wizard
- 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