erp-core/docs/04-modelado/especificaciones-tecnicas/generate_et.py

2419 lines
70 KiB
Python

#!/usr/bin/env python3
"""
Script para generar Especificaciones Técnicas (ET) a partir de Requerimientos Funcionales (RF).
Genera 2 archivos por RF: ET-BACKEND y ET-FRONTEND
"""
import os
import re
from pathlib import Path
from typing import Dict, List, Tuple
# Configuración base
BASE_DIR = Path(__file__).parent
RF_DIR = BASE_DIR.parent / "requerimientos-funcionales"
BACKEND_DIR = BASE_DIR / "backend"
FRONTEND_DIR = BASE_DIR / "frontend"
SCHEMAS_DIR = BASE_DIR.parent / "database-design" / "schemas"
# Mapeo de módulos a schemas
MODULE_TO_SCHEMA = {
"MGN-001": "auth",
"MGN-002": "auth",
"MGN-003": "core",
"MGN-004": "financial",
"MGN-005": "inventory",
"MGN-006": "purchase",
"MGN-007": "sales",
"MGN-008": "analytics",
"MGN-009": "sales", # CRM en sales schema
"MGN-010": "core", # RRHH en core schema
"MGN-011": "projects",
"MGN-012": "system",
"MGN-013": "system",
"MGN-014": "system",
}
# Mapeo de módulos a recursos API
MODULE_RESOURCES = {
"MGN-001": {
"001": "auth/login",
"002": "roles",
"003": "users",
"004": "tenants",
"005": "auth/reset-password",
"006": "auth/signup",
"007": "auth/sessions",
"008": "record-rules",
},
"MGN-002": {
"001": "companies",
"002": "companies/:id/settings",
"003": "companies/:id/users",
"004": "companies/hierarchy",
"005": "companies/templates",
},
"MGN-003": {
"001": "partners",
"002": "countries",
"003": "currencies",
"004": "uom",
"005": "product-categories",
"006": "payment-terms",
},
"MGN-004": {
"001": "chart-of-accounts",
"002": "journals",
"003": "accounting-entries",
"004": "taxes",
"005": "invoices/customer",
"006": "invoices/vendor",
"007": "payments",
"008": "reports/financial",
},
"MGN-005": {
"001": "products",
"002": "warehouses",
"003": "stock-moves",
"004": "pickings",
"005": "lots-serial-numbers",
"006": "inventory-valuation",
"007": "inventory-adjustments",
},
"MGN-006": {
"001": "purchase/rfq",
"002": "purchase/orders",
"003": "purchase/approvals",
"004": "purchase/receipts",
"005": "purchase/invoices",
"006": "purchase/reports",
},
"MGN-007": {
"001": "sales/quotations",
"002": "sales/quotations/convert",
"003": "sales/orders",
"004": "sales/deliveries",
"005": "sales/invoices",
"006": "sales/reports",
},
"MGN-008": {
"001": "analytic-accounts",
"002": "analytic-lines",
"003": "analytic-distributions",
"004": "analytic-tags",
"005": "analytic-reports",
},
"MGN-009": {
"001": "crm/leads",
"002": "crm/pipeline",
"003": "crm/activities",
"004": "crm/lead-scoring",
"005": "crm/convert-to-quote",
},
"MGN-010": {
"001": "hr/employees",
"002": "hr/departments",
"003": "hr/contracts",
"004": "hr/attendances",
"005": "hr/leaves",
},
"MGN-011": {
"001": "projects",
"002": "projects/tasks",
"003": "projects/milestones",
"004": "projects/timesheets",
"005": "projects/gantt",
},
"MGN-012": {
"001": "dashboards",
"002": "reports/custom",
"003": "exports",
"004": "charts",
},
"MGN-013": {
"001": "portal/access",
"002": "portal/documents",
"003": "portal/signatures",
"004": "portal/messages",
},
"MGN-014": {
"001": "messages",
"002": "notifications",
"003": "tracking",
"004": "activities",
"005": "followers",
"006": "email-templates",
},
}
def parse_rf_file(rf_path: Path) -> Dict:
"""Extrae información clave de un RF"""
content = rf_path.read_text()
# Extraer título
title_match = re.search(r'# RF-MGN-(\d+)-(\d+): (.+)', content)
if not title_match:
return None
module = f"MGN-{title_match.group(1)}"
rf_num = title_match.group(2)
title = title_match.group(3).strip()
# Extraer metadata
priority_match = re.search(r'\*\*Prioridad:\*\* (.+)', content)
sp_match = re.search(r'\*\*Story Points:\*\* (\d+)', content)
# Extraer entidades involucradas
entities_section = re.search(r'## Entidades Involucradas\n\n(.+?)(?=\n##)', content, re.DOTALL)
entities = []
if entities_section:
entities = re.findall(r'- \*\*Principales:\*\*\n\s+- (.+)', entities_section.group(1))
if not entities:
entities = re.findall(r'- (.+)', entities_section.group(1))
# Extraer reglas de negocio
rn_section = re.search(r'## Reglas de Negocio\n\n(.+?)(?=\n##)', content, re.DOTALL)
rules = []
if rn_section:
rules = re.findall(r'- \*\*RN-\d+:\*\* (.+)', rn_section.group(1))
return {
"module": module,
"rf_num": rf_num,
"title": title,
"priority": priority_match.group(1) if priority_match else "P0",
"story_points": int(sp_match.group(1)) if sp_match else 8,
"entities": entities,
"rules": rules,
"content": content,
}
def generate_backend_et(rf_data: Dict, resource: str) -> str:
"""Genera contenido del ET-Backend"""
module = rf_data["module"]
rf_num = rf_data["rf_num"]
title = rf_data["title"]
sp = rf_data["story_points"]
sp_backend = int(sp * 0.5) # 50% backend
sp_testing = int(sp * 0.3) # 30% testing
sp_review = int(sp * 0.2) # 20% review
schema = MODULE_TO_SCHEMA.get(module, "core")
# Convertir título a PascalCase para nombres de clases
entity_name = "".join(word.capitalize() for word in title.lower().split() if word not in ["de", "del", "la", "el", "y", "a"])
entity_name = entity_name.replace("(", "").replace(")", "").replace("/", "")
# Ejemplo de tabla
table_example = rf_data["entities"][0] if rf_data["entities"] else f"{schema}.{entity_name.lower()}"
template = f'''# ET-BACKEND-{module}-{rf_num}: {title}
**RF Asociado:** [RF-{module}-{rf_num}](../../requerimientos-funcionales/{module.lower()}/RF-{module}-{rf_num}-{title.lower().replace(" ", "-").replace("(", "").replace(")", "").replace("/", "-")}.md)
**Módulo:** {module}
**Complejidad:** {'Alta' if sp >= 13 else 'Media' if sp >= 8 else 'Baja'}
**Story Points:** {sp_backend} SP (Backend)
**Estado:** Diseñado
**Fecha:** 2025-11-24
## Resumen Técnico
Backend API para {title.lower()}. Implementa endpoints RESTful con NestJS, validaciones de negocio, persistencia con Prisma ORM y soporte multi-tenancy con Row Level Security (RLS).
## Stack Tecnológico
- **Runtime:** Node.js 20 LTS
- **Framework:** NestJS 10.x
- **ORM:** Prisma 5.x (con multi-schema support)
- **Validación:** class-validator + class-transformer
- **Autenticación:** JWT (Passport.js)
- **Base de Datos:** PostgreSQL 16 (multi-schema + RLS)
- **Testing:** Jest + Supertest
- **Documentación:** Swagger/OpenAPI 3.0
## Arquitectura del Endpoint
### API Endpoints
#### POST /api/v1/{resource}
```typescript
// Request
POST /api/v1/{resource}
Authorization: Bearer <JWT>
Content-Type: application/json
{{
// Payload según RF
}}
// Response 201 Created
{{
"data": {{ ... }},
"message": "{entity_name} creado exitosamente",
"timestamp": "2025-11-24T..."
}}
// Response 400 Bad Request
{{
"error": "ValidationError",
"message": "Datos inválidos",
"details": [...]
}}
// Response 403 Forbidden
{{
"error": "ForbiddenError",
"message": "Sin permisos para crear {entity_name.lower()}"
}}
```
#### GET /api/v1/{resource}
```typescript
// Request
GET /api/v1/{resource}?page=1&limit=20&sortBy=createdAt&sortOrder=desc
Authorization: Bearer <JWT>
// Response 200 OK
{{
"data": [...],
"meta": {{
"page": 1,
"limit": 20,
"total": 100,
"totalPages": 5
}}
}}
```
#### GET /api/v1/{resource}/:id
```typescript
// Request
GET /api/v1/{resource}/{{id}}
Authorization: Bearer <JWT>
// Response 200 OK
{{
"data": {{ ... }}
}}
// Response 404 Not Found
{{
"error": "NotFoundError",
"message": "{entity_name} no encontrado"
}}
```
#### PUT /api/v1/{resource}/:id
```typescript
// Request
PUT /api/v1/{resource}/{{id}}
Authorization: Bearer <JWT>
Content-Type: application/json
{{
// Payload de actualización
}}
// Response 200 OK
{{
"data": {{ ... }},
"message": "{entity_name} actualizado exitosamente"
}}
```
#### DELETE /api/v1/{resource}/:id
```typescript
// Request
DELETE /api/v1/{resource}/{{id}}
Authorization: Bearer <JWT>
// Response 200 OK (soft delete)
{{
"message": "{entity_name} eliminado exitosamente"
}}
// Response 409 Conflict
{{
"error": "ConflictError",
"message": "No se puede eliminar: tiene registros relacionados"
}}
```
## Modelo de Datos (Prisma Schema)
```prisma
// Basado en el DDL SQL: {schema}-schema-ddl.sql
model {entity_name} {{
id String @id @default(uuid())
tenantId String @map("tenant_id")
// Campos específicos del negocio
name String @db.VarChar(255)
code String? @db.VarChar(50)
// Auditoría
createdAt DateTime @default(now()) @map("created_at")
createdBy String? @map("created_by")
updatedAt DateTime? @updatedAt @map("updated_at")
updatedBy String? @map("updated_by")
deletedAt DateTime? @map("deleted_at")
deletedBy String? @map("deleted_by")
// Relations
tenant Tenant @relation(fields: [tenantId], references: [id])
@@map("{schema}.{table_example.split('.')[-1] if '.' in table_example else entity_name.lower()}")
@@index([tenantId])
@@unique([tenantId, code])
}}
```
## DTOs (Data Transfer Objects)
### Create DTO
```typescript
// src/modules/{module.lower()}/dto/create-{entity_name.lower()}.dto.ts
import {{ IsString, IsEmail, IsOptional, MinLength, MaxLength, IsUUID }} from 'class-validator';
export class Create{entity_name}Dto {{
@IsString()
@MinLength(3)
@MaxLength(255)
name: string;
@IsString()
@IsOptional()
@MaxLength(50)
code?: string;
// Campos adicionales según RF
}}
```
### Update DTO
```typescript
// src/modules/{module.lower()}/dto/update-{entity_name.lower()}.dto.ts
import {{ PartialType }} from '@nestjs/mapped-types';
import {{ Create{entity_name}Dto }} from './create-{entity_name.lower()}.dto';
export class Update{entity_name}Dto extends PartialType(Create{entity_name}Dto) {{}}
```
### Response DTO
```typescript
// src/modules/{module.lower()}/dto/{entity_name.lower()}-response.dto.ts
export class {entity_name}ResponseDto {{
id: string;
tenantId: string;
name: string;
code?: string;
createdAt: Date;
updatedAt?: Date;
}}
```
### Filter DTO
```typescript
// src/modules/{module.lower()}/dto/filter-{entity_name.lower()}.dto.ts
import {{ IsOptional, IsString, IsInt, Min, Max }} from 'class-validator';
import {{ Type }} from 'class-transformer';
export class Filter{entity_name}Dto {{
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;
@IsOptional()
@IsString()
sortBy?: string = 'createdAt';
@IsOptional()
@IsString()
sortOrder?: 'asc' | 'desc' = 'desc';
}}
```
## Servicios de Negocio
### Service Principal
```typescript
// src/modules/{module.lower()}/services/{entity_name.lower()}.service.ts
import {{ Injectable, NotFoundException, BadRequestException, ConflictException }} from '@nestjs/common';
import {{ PrismaService }} from '@shared/prisma/prisma.service';
import {{ Create{entity_name}Dto, Update{entity_name}Dto, Filter{entity_name}Dto }} from '../dto';
@Injectable()
export class {entity_name}Service {{
constructor(private readonly prisma: PrismaService) {{}}
async create(tenantId: string, userId: string, dto: Create{entity_name}Dto) {{
// 1. Validaciones de negocio
await this.validateBusinessRules(tenantId, dto);
// 2. Verificar duplicados
if (dto.code) {{
const existing = await this.prisma.{entity_name.lower()}.findFirst({{
where: {{
tenantId,
code: dto.code,
deletedAt: null,
}},
}});
if (existing) {{
throw new ConflictException(`{entity_name} con código ${{dto.code}} ya existe`);
}}
}}
// 3. Crear en BD (RLS automático por tenantId)
const entity = await this.prisma.{entity_name.lower()}.create({{
data: {{
tenantId,
createdBy: userId,
...dto,
}},
}});
// 4. Eventos/side effects (si aplica)
// await this.eventEmitter.emit('{entity_name.lower()}.created', entity);
return entity;
}}
async findAll(tenantId: string, filters: Filter{entity_name}Dto) {{
const {{ page, limit, sortBy, sortOrder, search }} = filters;
const skip = (page - 1) * limit;
const where = {{
tenantId,
deletedAt: null,
...(search && {{
OR: [
{{ name: {{ contains: search, mode: 'insensitive' }} }},
{{ code: {{ contains: search, mode: 'insensitive' }} }},
],
}}),
}};
const [data, total] = await Promise.all([
this.prisma.{entity_name.lower()}.findMany({{
where,
skip,
take: limit,
orderBy: {{ [sortBy]: sortOrder }},
}}),
this.prisma.{entity_name.lower()}.count({{ where }}),
]);
return {{
data,
meta: {{
page,
limit,
total,
totalPages: Math.ceil(total / limit),
}},
}};
}}
async findOne(tenantId: string, id: string) {{
const entity = await this.prisma.{entity_name.lower()}.findFirst({{
where: {{
id,
tenantId,
deletedAt: null,
}},
}});
if (!entity) {{
throw new NotFoundException(`{entity_name} con ID ${{id}} no encontrado`);
}}
return entity;
}}
async update(tenantId: string, userId: string, id: string, dto: Update{entity_name}Dto) {{
// 1. Verificar existencia
await this.findOne(tenantId, id);
// 2. Validar cambios
await this.validateBusinessRules(tenantId, dto, id);
// 3. Actualizar
const entity = await this.prisma.{entity_name.lower()}.update({{
where: {{ id }},
data: {{
...dto,
updatedBy: userId,
updatedAt: new Date(),
}},
}});
// 4. Eventos
// await this.eventEmitter.emit('{entity_name.lower()}.updated', entity);
return entity;
}}
async remove(tenantId: string, userId: string, id: string) {{
// 1. Verificar existencia
await this.findOne(tenantId, id);
// 2. Verificar dependencias (opcional)
// await this.checkDependencies(id);
// 3. Soft delete
await this.prisma.{entity_name.lower()}.update({{
where: {{ id }},
data: {{
deletedAt: new Date(),
deletedBy: userId,
}},
}});
// 4. Eventos
// await this.eventEmitter.emit('{entity_name.lower()}.deleted', {{ id }});
}}
private async validateBusinessRules(
tenantId: string,
dto: Partial<Create{entity_name}Dto>,
excludeId?: string,
) {{
// Implementar reglas de negocio del RF
// Ejemplo:
// if (dto.field && !await this.isValidField(dto.field)) {{
// throw new BadRequestException('Field inválido');
// }}
}}
}}
```
## Controller
```typescript
// src/modules/{module.lower()}/controllers/{entity_name.lower()}.controller.ts
import {{
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
HttpCode,
HttpStatus
}} from '@nestjs/common';
import {{ ApiTags, ApiOperation, ApiBearerAuth, ApiResponse }} from '@nestjs/swagger';
import {{ JwtAuthGuard }} from '@modules/auth/guards/jwt-auth.guard';
import {{ PermissionsGuard }} from '@modules/auth/guards/permissions.guard';
import {{ RequirePermissions }} from '@shared/decorators/require-permissions.decorator';
import {{ CurrentTenant }} from '@shared/decorators/current-tenant.decorator';
import {{ CurrentUser }} from '@shared/decorators/current-user.decorator';
import {{ {entity_name}Service }} from '../services/{entity_name.lower()}.service';
import {{ Create{entity_name}Dto, Update{entity_name}Dto, Filter{entity_name}Dto, {entity_name}ResponseDto }} from '../dto';
@ApiTags('{entity_name}')
@ApiBearerAuth()
@Controller('api/v1/{resource}')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class {entity_name}Controller {{
constructor(private readonly service: {entity_name}Service) {{}}
@Post()
@RequirePermissions('{module.lower()}.{entity_name.lower()}.create')
@ApiOperation({{ summary: 'Crear {entity_name.lower()}' }})
@ApiResponse({{ status: 201, description: '{entity_name} creado exitosamente', type: {entity_name}ResponseDto }})
@ApiResponse({{ status: 400, description: 'Datos inválidos' }})
@ApiResponse({{ status: 403, description: 'Sin permisos' }})
async create(
@CurrentTenant() tenantId: string,
@CurrentUser('id') userId: string,
@Body() dto: Create{entity_name}Dto,
) {{
const entity = await this.service.create(tenantId, userId, dto);
return {{
data: entity,
message: '{entity_name} creado exitosamente',
}};
}}
@Get()
@RequirePermissions('{module.lower()}.{entity_name.lower()}.read')
@ApiOperation({{ summary: 'Listar {entity_name.lower()}s' }})
@ApiResponse({{ status: 200, description: 'Lista de {entity_name.lower()}s', type: [{entity_name}ResponseDto] }})
async findAll(
@CurrentTenant() tenantId: string,
@Query() filters: Filter{entity_name}Dto,
) {{
return this.service.findAll(tenantId, filters);
}}
@Get(':id')
@RequirePermissions('{module.lower()}.{entity_name.lower()}.read')
@ApiOperation({{ summary: 'Obtener {entity_name.lower()} por ID' }})
@ApiResponse({{ status: 200, description: '{entity_name} encontrado', type: {entity_name}ResponseDto }})
@ApiResponse({{ status: 404, description: '{entity_name} no encontrado' }})
async findOne(
@CurrentTenant() tenantId: string,
@Param('id') id: string,
) {{
const entity = await this.service.findOne(tenantId, id);
return {{ data: entity }};
}}
@Put(':id')
@RequirePermissions('{module.lower()}.{entity_name.lower()}.update')
@ApiOperation({{ summary: 'Actualizar {entity_name.lower()}' }})
@ApiResponse({{ status: 200, description: '{entity_name} actualizado exitosamente' }})
@ApiResponse({{ status: 404, description: '{entity_name} no encontrado' }})
async update(
@CurrentTenant() tenantId: string,
@CurrentUser('id') userId: string,
@Param('id') id: string,
@Body() dto: Update{entity_name}Dto,
) {{
const entity = await this.service.update(tenantId, userId, id, dto);
return {{
data: entity,
message: '{entity_name} actualizado exitosamente',
}};
}}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@RequirePermissions('{module.lower()}.{entity_name.lower()}.delete')
@ApiOperation({{ summary: 'Eliminar {entity_name.lower()}' }})
@ApiResponse({{ status: 204, description: '{entity_name} eliminado exitosamente' }})
@ApiResponse({{ status: 404, description: '{entity_name} no encontrado' }})
async remove(
@CurrentTenant() tenantId: string,
@CurrentUser('id') userId: string,
@Param('id') id: string,
) {{
await this.service.remove(tenantId, userId, id);
}}
}}
```
## Reglas de Negocio (Implementación)
'''
# Agregar reglas de negocio del RF
if rf_data["rules"]:
for i, rule in enumerate(rf_data["rules"][:5], 1): # Max 5 reglas
template += f'''
### RN-{i}: {rule}
```typescript
private async validateRN{i}(dto: Partial<Create{entity_name}Dto>): Promise<void> {{
// Implementación de la regla: {rule}
// TODO: Implementar validación específica
}}
```
'''
template += f'''
## Seguridad
### Multi-Tenancy (RLS)
- Prisma middleware automático que agrega `tenantId` a todas las queries
- Row Level Security en PostgreSQL valida acceso
- Soft delete: usa `deletedAt` en lugar de borrado físico
### Permisos (RBAC)
```typescript
@UseGuards(JwtAuthGuard, PermissionsGuard)
@RequirePermissions('{module.lower()}.{entity_name.lower()}.create')
@Post()
async create(...) {{ ... }}
```
Permisos requeridos:
- `{module.lower()}.{entity_name.lower()}.create` - Crear registros
- `{module.lower()}.{entity_name.lower()}.read` - Leer registros
- `{module.lower()}.{entity_name.lower()}.update` - Actualizar registros
- `{module.lower()}.{entity_name.lower()}.delete` - Eliminar registros
### Validación de Input
- DTOs con class-validator para validación automática
- Sanitización de strings (XSS prevention)
- Rate limiting: 100 requests/min por usuario
- SQL Injection prevention: Prisma ORM con prepared statements
### Auditoría
Todos los cambios registran:
- `createdBy` / `updatedBy` / `deletedBy` - Usuario que ejecuta acción
- `createdAt` / `updatedAt` / `deletedAt` - Timestamp de acción
- Integración con MGN-014 (Tracking Automático de Cambios)
## Testing
### Unit Tests
```typescript
// {entity_name.lower()}.service.spec.ts
import {{ Test, TestingModule }} from '@nestjs/testing';
import {{ {entity_name}Service }} from './{entity_name.lower()}.service';
import {{ PrismaService }} from '@shared/prisma/prisma.service';
import {{ NotFoundException, ConflictException }} from '@nestjs/common';
describe('{entity_name}Service', () => {{
let service: {entity_name}Service;
let prisma: PrismaService;
const mockPrisma = {{
{entity_name.lower()}: {{
create: jest.fn(),
findMany: jest.fn(),
findFirst: jest.fn(),
update: jest.fn(),
count: jest.fn(),
}},
}};
beforeEach(async () => {{
const module: TestingModule = await Test.createTestingModule({{
providers: [
{entity_name}Service,
{{ provide: PrismaService, useValue: mockPrisma }},
],
}}).compile();
service = module.get<{entity_name}Service>({entity_name}Service);
prisma = module.get<PrismaService>(PrismaService);
}});
afterEach(() => {{
jest.clearAllMocks();
}});
describe('create', () => {{
it('should create {entity_name.lower()} with valid data', async () => {{
const tenantId = 'tenant-123';
const userId = 'user-456';
const dto = {{ name: 'Test {entity_name}', code: 'TEST' }};
const expected = {{ id: 'id-789', tenantId, ...dto }};
mockPrisma.{entity_name.lower()}.findFirst.mockResolvedValue(null);
mockPrisma.{entity_name.lower()}.create.mockResolvedValue(expected);
const result = await service.create(tenantId, userId, dto);
expect(result).toEqual(expected);
expect(mockPrisma.{entity_name.lower()}.create).toHaveBeenCalledWith({{
data: {{ tenantId, createdBy: userId, ...dto }},
}});
}});
it('should throw ConflictException when code already exists', async () => {{
const tenantId = 'tenant-123';
const userId = 'user-456';
const dto = {{ name: 'Test {entity_name}', code: 'DUPLICATE' }};
mockPrisma.{entity_name.lower()}.findFirst.mockResolvedValue({{ id: 'existing' }});
await expect(service.create(tenantId, userId, dto)).rejects.toThrow(ConflictException);
}});
}});
describe('findOne', () => {{
it('should return {entity_name.lower()} when found', async () => {{
const tenantId = 'tenant-123';
const id = 'id-789';
const expected = {{ id, tenantId, name: 'Test' }};
mockPrisma.{entity_name.lower()}.findFirst.mockResolvedValue(expected);
const result = await service.findOne(tenantId, id);
expect(result).toEqual(expected);
}});
it('should throw NotFoundException when not found', async () => {{
const tenantId = 'tenant-123';
const id = 'non-existent';
mockPrisma.{entity_name.lower()}.findFirst.mockResolvedValue(null);
await expect(service.findOne(tenantId, id)).rejects.toThrow(NotFoundException);
}});
}});
describe('update', () => {{
it('should update {entity_name.lower()} successfully', async () => {{
const tenantId = 'tenant-123';
const userId = 'user-456';
const id = 'id-789';
const dto = {{ name: 'Updated Name' }};
const existing = {{ id, tenantId, name: 'Old Name' }};
const expected = {{ ...existing, ...dto }};
mockPrisma.{entity_name.lower()}.findFirst.mockResolvedValue(existing);
mockPrisma.{entity_name.lower()}.update.mockResolvedValue(expected);
const result = await service.update(tenantId, userId, id, dto);
expect(result).toEqual(expected);
}});
}});
describe('remove', () => {{
it('should soft delete {entity_name.lower()}', async () => {{
const tenantId = 'tenant-123';
const userId = 'user-456';
const id = 'id-789';
const existing = {{ id, tenantId, name: 'To Delete' }};
mockPrisma.{entity_name.lower()}.findFirst.mockResolvedValue(existing);
mockPrisma.{entity_name.lower()}.update.mockResolvedValue({{ ...existing, deletedAt: new Date() }});
await service.remove(tenantId, userId, id);
expect(mockPrisma.{entity_name.lower()}.update).toHaveBeenCalledWith({{
where: {{ id }},
data: {{
deletedAt: expect.any(Date),
deletedBy: userId,
}},
}});
}});
}});
}});
```
### Integration Tests (e2e)
```typescript
// {entity_name.lower()}.controller.e2e-spec.ts
import {{ Test, TestingModule }} from '@nestjs/testing';
import {{ INestApplication }} from '@nestjs/common';
import * as request from 'supertest';
import {{ AppModule }} from '@/app.module';
describe('{entity_name}Controller (e2e)', () => {{
let app: INestApplication;
let authToken: string;
let tenantId: string;
beforeAll(async () => {{
const moduleFixture: TestingModule = await Test.createTestingModule({{
imports: [AppModule],
}}).compile();
app = moduleFixture.createNestApplication();
await app.init();
// Login para obtener token
const loginResponse = await request(app.getHttpServer())
.post('/api/v1/auth/login')
.send({{ email: 'test@example.com', password: 'Test1234!' }});
authToken = loginResponse.body.data.accessToken;
tenantId = loginResponse.body.data.user.tenantId;
}});
afterAll(async () => {{
await app.close();
}});
describe('POST /api/v1/{resource}', () => {{
it('should create {entity_name.lower()} successfully', () => {{
return request(app.getHttpServer())
.post('/api/v1/{resource}')
.set('Authorization', `Bearer ${{authToken}}`)
.send({{
name: 'Test {entity_name}',
code: 'TEST001',
}})
.expect(201)
.expect((res) => {{
expect(res.body.data).toHaveProperty('id');
expect(res.body.data.name).toBe('Test {entity_name}');
expect(res.body.message).toContain('creado exitosamente');
}});
}});
it('should return 400 for invalid data', () => {{
return request(app.getHttpServer())
.post('/api/v1/{resource}')
.set('Authorization', `Bearer ${{authToken}}`)
.send({{ name: '' }}) // Invalid: name too short
.expect(400);
}});
it('should return 401 without token', () => {{
return request(app.getHttpServer())
.post('/api/v1/{resource}')
.send({{ name: 'Test' }})
.expect(401);
}});
}});
describe('GET /api/v1/{resource}', () => {{
it('should return list of {entity_name.lower()}s', () => {{
return request(app.getHttpServer())
.get('/api/v1/{resource}')
.set('Authorization', `Bearer ${{authToken}}`)
.expect(200)
.expect((res) => {{
expect(res.body.data).toBeInstanceOf(Array);
expect(res.body.meta).toHaveProperty('page');
expect(res.body.meta).toHaveProperty('total');
}});
}});
it('should filter by search term', () => {{
return request(app.getHttpServer())
.get('/api/v1/{resource}?search=test')
.set('Authorization', `Bearer ${{authToken}}`)
.expect(200);
}});
}});
describe('GET /api/v1/{resource}/:id', () => {{
it('should return {entity_name.lower()} by ID', async () => {{
// Crear primero
const createRes = await request(app.getHttpServer())
.post('/api/v1/{resource}')
.set('Authorization', `Bearer ${{authToken}}`)
.send({{ name: 'Find By ID Test' }});
const id = createRes.body.data.id;
return request(app.getHttpServer())
.get(`/api/v1/{resource}/${{id}}`)
.set('Authorization', `Bearer ${{authToken}}`)
.expect(200)
.expect((res) => {{
expect(res.body.data.id).toBe(id);
}});
}});
it('should return 404 for non-existent ID', () => {{
return request(app.getHttpServer())
.get('/api/v1/{resource}/non-existent-id')
.set('Authorization', `Bearer ${{authToken}}`)
.expect(404);
}});
}});
describe('PUT /api/v1/{resource}/:id', () => {{
it('should update {entity_name.lower()} successfully', async () => {{
// Crear primero
const createRes = await request(app.getHttpServer())
.post('/api/v1/{resource}')
.set('Authorization', `Bearer ${{authToken}}`)
.send({{ name: 'Original Name' }});
const id = createRes.body.data.id;
return request(app.getHttpServer())
.put(`/api/v1/{resource}/${{id}}`)
.set('Authorization', `Bearer ${{authToken}}`)
.send({{ name: 'Updated Name' }})
.expect(200)
.expect((res) => {{
expect(res.body.data.name).toBe('Updated Name');
}});
}});
}});
describe('DELETE /api/v1/{resource}/:id', () => {{
it('should soft delete {entity_name.lower()}', async () => {{
// Crear primero
const createRes = await request(app.getHttpServer())
.post('/api/v1/{resource}')
.set('Authorization', `Bearer ${{authToken}}`)
.send({{ name: 'To Delete' }});
const id = createRes.body.data.id;
await request(app.getHttpServer())
.delete(`/api/v1/{resource}/${{id}}`)
.set('Authorization', `Bearer ${{authToken}}`)
.expect(204);
// Verificar que no aparece en listados
const listRes = await request(app.getHttpServer())
.get('/api/v1/{resource}')
.set('Authorization', `Bearer ${{authToken}}`);
const deleted = listRes.body.data.find(item => item.id === id);
expect(deleted).toBeUndefined();
}});
}});
}});
```
## Performance
### Índices Necesarios
```sql
-- Índices definidos en {schema}-schema-ddl.sql
CREATE INDEX idx_{entity_name.lower()}_tenant_code ON {schema}.{entity_name.lower()}(tenant_id, code);
CREATE INDEX idx_{entity_name.lower()}_tenant_name ON {schema}.{entity_name.lower()}(tenant_id, name);
CREATE INDEX idx_{entity_name.lower()}_created_at ON {schema}.{entity_name.lower()}(created_at DESC);
-- Para soft delete
CREATE INDEX idx_{entity_name.lower()}_deleted_at ON {schema}.{entity_name.lower()}(deleted_at) WHERE deleted_at IS NULL;
```
### Caching (Redis)
```typescript
// Para listas maestras estáticas
@Cacheable({{ ttl: 3600 }}) // 1 hora
async findAll(tenantId: string, filters: Filter{entity_name}Dto) {{
// ...
}}
// Invalidación en cambios
@CacheEvict({{ pattern: '{entity_name.lower()}:*' }})
async update(...) {{ ... }}
@CacheEvict({{ pattern: '{entity_name.lower()}:*' }})
async remove(...) {{ ... }}
```
### Paginación
- **Default:** 20 items por página
- **Máximo:** 100 items por página
- **Cursor-based:** Para listados grandes (opcional)
### Query Optimization
```typescript
// Usar select para reducir payload
async findAll(tenantId: string, filters: Filter{entity_name}Dto) {{
return this.prisma.{entity_name.lower()}.findMany({{
where: {{ ... }},
select: {{
id: true,
name: true,
code: true,
createdAt: true,
// Omitir campos pesados
}},
}});
}}
```
## Logging y Monitoreo
```typescript
import {{ Logger }} from '@nestjs/common';
export class {entity_name}Service {{
private readonly logger = new Logger({entity_name}Service.name);
async create(tenantId: string, userId: string, dto: Create{entity_name}Dto) {{
this.logger.log({{
action: '{entity_name.lower()}.create',
tenantId,
userId,
data: dto,
}});
try {{
const entity = await this.prisma.{entity_name.lower()}.create({{ ... }});
this.logger.log({{
action: '{entity_name.lower()}.created',
tenantId,
userId,
entityId: entity.id,
}});
return entity;
}} catch (error) {{
this.logger.error({{
action: '{entity_name.lower()}.create.error',
tenantId,
userId,
error: error.message,
stack: error.stack,
}});
throw error;
}}
}}
}}
```
### Métricas (Prometheus)
- `http_requests_total{{method="POST",endpoint="/api/v1/{resource}",status="201"}}`
- `http_request_duration_seconds{{endpoint="/api/v1/{resource}"}}`
- `{entity_name.lower()}_created_total{{tenant_id="xxx"}}`
## Referencias
- [RF Asociado](../../requerimientos-funcionales/{module.lower()}/RF-{module}-{rf_num}-{title.lower().replace(" ", "-").replace("(", "").replace(")", "").replace("/", "-")}.md)
- [Database Schema](../database-design/schemas/{schema}-schema-ddl.sql)
- [Domain Model](../domain-models/{schema}-domain.md)
- [ADR-001: Stack Tecnológico](../../adr/ADR-001-stack-tecnologico.md)
- [ADR-005: API Design](../../adr/ADR-005-api-design.md)
## Dependencias
### Módulos NestJS
- `AuthModule` - Autenticación y autorización
- `PrismaModule` - ORM y acceso a BD
- `LoggerModule` - Logging centralizado
- `EventsModule` - Event emitter (opcional)
### RF Bloqueantes
- RF-MGN-001-001 (Autenticación de Usuarios)
- RF-MGN-001-004 (Multi-Tenancy)
### RF Dependientes
- [Listar RF que dependen de este]
## Notas de Implementación
- [ ] Crear módulo NestJS: `nest g module {module.lower()}/{entity_name.lower()}`
- [ ] Crear service: `nest g service {module.lower()}/{entity_name.lower()}`
- [ ] Crear controller: `nest g controller {module.lower()}/{entity_name.lower()}`
- [ ] Crear DTOs en `{module.lower()}/dto/` folder
- [ ] Definir Prisma model en `schema.prisma`
- [ ] Implementar unit tests (>80% coverage)
- [ ] Implementar e2e tests
- [ ] Documentar endpoints en Swagger
- [ ] Validar con criterios de aceptación del RF
- [ ] Code review por Tech Lead
- [ ] QA testing
## Estimación
- **Backend Development:** {sp_backend} SP
- **Testing (Unit + e2e):** {sp_testing} SP
- **Code Review + QA:** {sp_review} SP
- **Total:** {sp} SP
---
**Documento generado:** 2025-11-24
**Versión:** 1.0
**Estado:** Diseñado
**Próximo paso:** Implementación
'''
return template
def generate_frontend_et(rf_data: Dict, resource: str) -> str:
"""Genera contenido del ET-Frontend"""
module = rf_data["module"]
rf_num = rf_data["rf_num"]
title = rf_data["title"]
sp = rf_data["story_points"]
sp_frontend = int(sp * 0.5) # 50% frontend
sp_testing = int(sp * 0.3) # 30% testing
sp_review = int(sp * 0.2) # 20% review
# Convertir título a PascalCase
entity_name = "".join(word.capitalize() for word in title.lower().split() if word not in ["de", "del", "la", "el", "y", "a"])
entity_name = entity_name.replace("(", "").replace(")", "").replace("/", "")
entity_name_lower = entity_name[0].lower() + entity_name[1:] if entity_name else "entity"
template = f'''# ET-FRONTEND-{module}-{rf_num}: {title}
**RF Asociado:** [RF-{module}-{rf_num}](../../requerimientos-funcionales/{module.lower()}/RF-{module}-{rf_num}-{title.lower().replace(" ", "-").replace("(", "").replace(")", "").replace("/", "-")}.md)
**ET Backend:** [ET-BACKEND-{module}-{rf_num}](../backend/{module.lower()}/ET-BACKEND-{module}-{rf_num}-{title.lower().replace(" ", "-").replace("(", "").replace(")", "").replace("/", "-")}.md)
**Módulo:** {module}
**Complejidad:** {'Alta' if sp >= 13 else 'Media' if sp >= 8 else 'Baja'}
**Story Points:** {sp_frontend} SP (Frontend)
**Estado:** Diseñado
**Fecha:** 2025-11-24
## Resumen Técnico
Implementación frontend para {title.lower()}. Incluye componentes React con TypeScript, formularios con validación, integración con API backend, y arquitectura Feature-Sliced Design (FSD).
## Stack Tecnológico
- **Framework:** React 18.x + TypeScript 5.x
- **Build Tool:** Vite 5.x
- **UI Library:** Ant Design 5.x (AntD)
- **State Management:** Zustand + React Query (TanStack Query)
- **Routing:** React Router 6.x
- **Forms:** React Hook Form + Zod validation
- **HTTP Client:** Axios (con interceptors para auth)
- **Testing:** Vitest + React Testing Library + Playwright
## Arquitectura Frontend (FSD)
Estructura basada en Feature-Sliced Design:
```
src/
├── app/ # App-level config
│ ├── providers/
│ │ └── router.tsx
│ └── styles/
│ └── theme.ts
├── pages/ # Route pages
│ └── {entity_name}Page/
│ ├── index.tsx
│ └── {entity_name}Page.tsx
├── widgets/ # Complex UI blocks
│ └── {entity_name}Table/
│ ├── ui/
│ │ └── {entity_name}Table.tsx
│ └── index.ts
├── features/ # User interactions
│ ├── create{entity_name}/
│ │ ├── ui/
│ │ │ └── Create{entity_name}Form.tsx
│ │ ├── model/
│ │ │ └── use{entity_name}Actions.ts
│ │ └── index.ts
│ ├── update{entity_name}/
│ └── delete{entity_name}/
├── entities/ # Business entities
│ └── {entity_name_lower}/
│ ├── model/
│ │ ├── types.ts
│ │ ├── schemas.ts
│ │ └── {entity_name_lower}.store.ts
│ ├── api/
│ │ ├── {entity_name_lower}.api.ts
│ │ └── {entity_name_lower}.queries.ts
│ ├── ui/
│ │ └── {entity_name}Card.tsx
│ └── index.ts
└── shared/ # Shared code
├── ui/ # UI kit
│ ├── Button/
│ ├── Modal/
│ └── Table/
├── api/
│ └── client.ts
└── lib/
└── utils.ts
```
## Rutas
```typescript
// src/app/routes/{module.lower()}.routes.tsx
export const {entity_name}Routes = {{
list: '/{module.lower()}/{entity_name_lower}',
create: '/{module.lower()}/{entity_name_lower}/create',
edit: '/{module.lower()}/{entity_name_lower}/:id/edit',
view: '/{module.lower()}/{entity_name_lower}/:id',
}};
// Integración en Router
<Route path="/{module.lower()}">
<Route path="{entity_name_lower}" element={{<{entity_name}Page />}} />
<Route path="{entity_name_lower}/create" element={{<Create{entity_name}Page />}} />
<Route path="{entity_name_lower}/:id/edit" element={{<Edit{entity_name}Page />}} />
<Route path="{entity_name_lower}/:id" element={{<View{entity_name}Page />}} />
</Route>
```
## Types / Interfaces
```typescript
// src/entities/{entity_name_lower}/model/types.ts
export interface {entity_name} {{
id: string;
tenantId: string;
name: string;
code?: string;
createdAt: string;
updatedAt?: string;
deletedAt?: string;
}}
export interface Create{entity_name}Dto {{
name: string;
code?: string;
}}
export type Update{entity_name}Dto = Partial<Create{entity_name}Dto>;
export interface {entity_name}Filters {{
search?: string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}}
export interface {entity_name}ListResponse {{
data: {entity_name}[];
meta: {{
page: number;
limit: number;
total: number;
totalPages: number;
}};
}}
```
## Schemas de Validación (Zod)
```typescript
// src/entities/{entity_name_lower}/model/schemas.ts
import {{ z }} from 'zod';
export const create{entity_name}Schema = z.object({{
name: z.string()
.min(3, 'El nombre debe tener al menos 3 caracteres')
.max(255, 'El nombre no puede exceder 255 caracteres'),
code: z.string()
.min(2, 'El código debe tener al menos 2 caracteres')
.max(50, 'El código no puede exceder 50 caracteres')
.optional(),
}});
export const update{entity_name}Schema = create{entity_name}Schema.partial();
export type Create{entity_name}FormData = z.infer<typeof create{entity_name}Schema>;
export type Update{entity_name}FormData = z.infer<typeof update{entity_name}Schema>;
// Validación personalizada (ejemplo)
export const validate{entity_name}Code = (code: string): boolean => {{
return /^[A-Z0-9-]+$/.test(code);
}};
```
## API Client
```typescript
// src/entities/{entity_name_lower}/api/{entity_name_lower}.api.ts
import {{ apiClient }} from '@shared/api/client';
import type {{
{entity_name},
Create{entity_name}Dto,
Update{entity_name}Dto,
{entity_name}Filters,
{entity_name}ListResponse
}} from '../model/types';
const BASE_URL = '/api/v1/{resource}';
export const {entity_name_lower}Api = {{
getAll: async (filters?: {entity_name}Filters): Promise<{entity_name}ListResponse> => {{
const {{ data }} = await apiClient.get<{entity_name}ListResponse>(BASE_URL, {{
params: filters,
}});
return data;
}},
getById: async (id: string): Promise<{entity_name}> => {{
const {{ data }} = await apiClient.get<{{ data: {entity_name} }}>(`${{BASE_URL}}/${{id}}`);
return data.data;
}},
create: async (dto: Create{entity_name}Dto): Promise<{entity_name}> => {{
const {{ data }} = await apiClient.post<{{ data: {entity_name} }}>(BASE_URL, dto);
return data.data;
}},
update: async (id: string, dto: Update{entity_name}Dto): Promise<{entity_name}> => {{
const {{ data }} = await apiClient.put<{{ data: {entity_name} }}>(`${{BASE_URL}}/${{id}}`, dto);
return data.data;
}},
delete: async (id: string): Promise<void> => {{
await apiClient.delete(`${{BASE_URL}}/${{id}}`);
}},
}};
// Configuración de Axios client
// src/shared/api/client.ts
import axios from 'axios';
import {{ message }} from 'antd';
export const apiClient = axios.create({{
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000',
timeout: 30000,
}});
// Request interceptor: agregar auth token
apiClient.interceptors.request.use(
(config) => {{
const token = localStorage.getItem('accessToken');
if (token) {{
config.headers.Authorization = `Bearer ${{token}}`;
}}
return config;
}},
(error) => Promise.reject(error)
);
// Response interceptor: manejar errores globales
apiClient.interceptors.response.use(
(response) => response,
(error) => {{
const {{ response }} = error;
if (response?.status === 401) {{
// Token expirado: redirigir a login
localStorage.removeItem('accessToken');
window.location.href = '/login';
}} else if (response?.status === 403) {{
message.error('No tienes permisos para realizar esta acción');
}} else if (response?.status === 500) {{
message.error('Error interno del servidor');
}}
return Promise.reject(error);
}}
);
```
## State Management (Zustand + React Query)
### Zustand Store (estado local UI)
```typescript
// src/entities/{entity_name_lower}/model/{entity_name_lower}.store.ts
import {{ create }} from 'zustand';
import {{ {entity_name} }} from './types';
interface {entity_name}Store {{
selected{entity_name}: {entity_name} | null;
isModalOpen: boolean;
modalMode: 'create' | 'edit' | 'view' | null;
setSelected{entity_name}: (entity: {entity_name} | null) => void;
openModal: (mode: 'create' | 'edit' | 'view', entity?: {entity_name}) => void;
closeModal: () => void;
}}
export const use{entity_name}Store = create<{entity_name}Store>((set) => ({{
selected{entity_name}: null,
isModalOpen: false,
modalMode: null,
setSelected{entity_name}: (entity) => set({{ selected{entity_name}: entity }}),
openModal: (mode, entity) => set({{
isModalOpen: true,
modalMode: mode,
selected{entity_name}: entity || null,
}}),
closeModal: () => set({{
isModalOpen: false,
modalMode: null,
selected{entity_name}: null,
}}),
}}));
```
### React Query Hooks (servidor state)
```typescript
// src/entities/{entity_name_lower}/api/{entity_name_lower}.queries.ts
import {{ useQuery, useMutation, useQueryClient }} from '@tanstack/react-query';
import {{ message }} from 'antd';
import {{ {entity_name_lower}Api }} from './{entity_name_lower}.api';
import type {{ Create{entity_name}Dto, Update{entity_name}Dto, {entity_name}Filters }} from '../model/types';
const QUERY_KEY = '{entity_name_lower}';
// Query: obtener lista
export const use{entity_name}s = (filters?: {entity_name}Filters) => {{
return useQuery({{
queryKey: [QUERY_KEY, filters],
queryFn: () => {entity_name_lower}Api.getAll(filters),
staleTime: 5 * 60 * 1000, // 5 minutos
}});
}};
// Query: obtener por ID
export const use{entity_name} = (id: string) => {{
return useQuery({{
queryKey: [QUERY_KEY, id],
queryFn: () => {entity_name_lower}Api.getById(id),
enabled: !!id,
}});
}};
// Mutation: crear
export const useCreate{entity_name} = () => {{
const queryClient = useQueryClient();
return useMutation({{
mutationFn: (dto: Create{entity_name}Dto) => {entity_name_lower}Api.create(dto),
onSuccess: () => {{
queryClient.invalidateQueries({{ queryKey: [QUERY_KEY] }});
message.success('{entity_name} creado exitosamente');
}},
onError: (error: any) => {{
const errorMsg = error.response?.data?.message || 'Error al crear {entity_name.lower()}';
message.error(errorMsg);
}},
}});
}};
// Mutation: actualizar
export const useUpdate{entity_name} = () => {{
const queryClient = useQueryClient();
return useMutation({{
mutationFn: ({{ id, dto }}: {{ id: string; dto: Update{entity_name}Dto }}) =>
{entity_name_lower}Api.update(id, dto),
onSuccess: (data) => {{
queryClient.invalidateQueries({{ queryKey: [QUERY_KEY] }});
queryClient.setQueryData([QUERY_KEY, data.id], data);
message.success('{entity_name} actualizado exitosamente');
}},
onError: (error: any) => {{
const errorMsg = error.response?.data?.message || 'Error al actualizar {entity_name.lower()}';
message.error(errorMsg);
}},
}});
}};
// Mutation: eliminar
export const useDelete{entity_name} = () => {{
const queryClient = useQueryClient();
return useMutation({{
mutationFn: (id: string) => {entity_name_lower}Api.delete(id),
onSuccess: () => {{
queryClient.invalidateQueries({{ queryKey: [QUERY_KEY] }});
message.success('{entity_name} eliminado exitosamente');
}},
onError: (error: any) => {{
const errorMsg = error.response?.data?.message || 'Error al eliminar {entity_name.lower()}';
message.error(errorMsg);
}},
}});
}};
```
## Components UI
### Tabla de Listado
```typescript
// src/widgets/{entity_name}Table/ui/{entity_name}Table.tsx
import React, {{ useState }} from 'react';
import {{ Table, Button, Space, Input, Modal }} from 'antd';
import {{ EditOutlined, DeleteOutlined, EyeOutlined, PlusOutlined }} from '@ant-design/icons';
import type {{ ColumnsType }} from 'antd/es/table';
import {{ use{entity_name}s, useDelete{entity_name} }} from '@entities/{entity_name_lower}';
import {{ use{entity_name}Store }} from '@entities/{entity_name_lower}';
import type {{ {entity_name}, {entity_name}Filters }} from '@entities/{entity_name_lower}';
const {{ Search }} = Input;
export const {entity_name}Table: React.FC = () => {{
const [filters, setFilters] = useState<{entity_name}Filters>({{
page: 1,
limit: 20,
sortBy: 'createdAt',
sortOrder: 'desc',
}});
const {{ data, isLoading }} = use{entity_name}s(filters);
const deleteMutation = useDelete{entity_name}();
const {{ openModal }} = use{entity_name}Store();
const handleSearch = (value: string) => {{
setFilters((prev) => ({{ ...prev, search: value, page: 1 }}));
}};
const handleTableChange = (pagination: any, filters: any, sorter: any) => {{
setFilters((prev) => ({{
...prev,
page: pagination.current,
limit: pagination.pageSize,
sortBy: sorter.field || 'createdAt',
sortOrder: sorter.order === 'ascend' ? 'asc' : 'desc',
}}));
}};
const handleDelete = (id: string, name: string) => {{
Modal.confirm({{
title: '¿Confirmar eliminación?',
content: `¿Está seguro de eliminar "${{name}}"? Esta acción no se puede deshacer.`,
okText: 'Eliminar',
okType: 'danger',
cancelText: 'Cancelar',
onOk: () => deleteMutation.mutate(id),
}});
}};
const columns: ColumnsType<{entity_name}> = [
{{
title: 'Nombre',
dataIndex: 'name',
key: 'name',
sorter: true,
width: '40%',
}},
{{
title: 'Código',
dataIndex: 'code',
key: 'code',
width: '20%',
}},
{{
title: 'Fecha Creación',
dataIndex: 'createdAt',
key: 'createdAt',
sorter: true,
width: '20%',
render: (date: string) => new Date(date).toLocaleDateString('es-ES'),
}},
{{
title: 'Acciones',
key: 'actions',
width: '20%',
render: (_, record) => (
<Space>
<Button
icon={{<EyeOutlined />}}
onClick={{() => openModal('view', record)}}
title="Ver detalles"
/>
<Button
icon={{<EditOutlined />}}
onClick={{() => openModal('edit', record)}}
title="Editar"
/>
<Button
icon={{<DeleteOutlined />}}
danger
onClick={{() => handleDelete(record.id, record.name)}}
title="Eliminar"
/>
</Space>
),
}},
];
return (
<div>
<Space style={{{{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}}}>
<Search
placeholder="Buscar por nombre o código"
onSearch={{handleSearch}}
style={{{{ width: 300 }}}}
allowClear
/>
<Button
type="primary"
icon={{<PlusOutlined />}}
onClick={{() => openModal('create')}}
>
Crear {entity_name}
</Button>
</Space>
<Table
columns={{columns}}
dataSource={{data?.data || []}}
loading={{isLoading || deleteMutation.isPending}}
rowKey="id"
pagination={{{{
current: filters.page,
pageSize: filters.limit,
total: data?.meta.total || 0,
showSizeChanger: true,
showTotal: (total) => `Total: ${{total}} registros`,
}}}}
onChange={{handleTableChange}}
/>
</div>
);
}};
```
### Formulario de Creación/Edición
```typescript
// src/features/create{entity_name}/ui/Create{entity_name}Form.tsx
import React, {{ useEffect }} from 'react';
import {{ Form, Input, Button, Space }} from 'antd';
import {{ useForm, Controller }} from 'react-hook-form';
import {{ zodResolver }} from '@hookform/resolvers/zod';
import {{
create{entity_name}Schema,
update{entity_name}Schema,
type Create{entity_name}FormData
}} from '@entities/{entity_name_lower}';
import {{ useCreate{entity_name}, useUpdate{entity_name} }} from '@entities/{entity_name_lower}';
import type {{ {entity_name} }} from '@entities/{entity_name_lower}';
interface {entity_name}FormProps {{
mode: 'create' | 'edit';
initialData?: {entity_name};
onSuccess?: () => void;
}}
export const {entity_name}Form: React.FC<{entity_name}FormProps> = ({{
mode,
initialData,
onSuccess,
}}) => {{
const isEditMode = mode === 'edit';
const schema = isEditMode ? update{entity_name}Schema : create{entity_name}Schema;
const {{ control, handleSubmit, formState: {{ errors }}, reset }} = useForm<Create{entity_name}FormData>({{
resolver: zodResolver(schema),
defaultValues: initialData || {{
name: '',
code: '',
}},
}});
const createMutation = useCreate{entity_name}();
const updateMutation = useUpdate{entity_name}();
const mutation = isEditMode ? updateMutation : createMutation;
useEffect(() => {{
if (initialData) {{
reset(initialData);
}}
}}, [initialData, reset]);
const onSubmit = (data: Create{entity_name}FormData) => {{
if (isEditMode && initialData) {{
updateMutation.mutate(
{{ id: initialData.id, dto: data }},
{{ onSuccess: () => onSuccess?.() }}
);
}} else {{
createMutation.mutate(data, {{
onSuccess: () => {{
reset();
onSuccess?.();
}},
}});
}}
}};
return (
<Form layout="vertical" onFinish={{handleSubmit(onSubmit)}}>
<Controller
name="name"
control={{control}}
render={{({{ field }}) => (
<Form.Item
label="Nombre"
validateStatus={{errors.name ? 'error' : ''}}
help={{errors.name?.message}}
required
>
<Input {{...field}} placeholder="Ingrese el nombre" />
</Form.Item>
)}}
/>
<Controller
name="code"
control={{control}}
render={{({{ field }}) => (
<Form.Item
label="Código"
validateStatus={{errors.code ? 'error' : ''}}
help={{errors.code?.message}}
>
<Input {{...field}} placeholder="Código único (opcional)" />
</Form.Item>
)}}
/>
<Form.Item>
<Space>
<Button
type="primary"
htmlType="submit"
loading={{mutation.isPending}}
>
{{isEditMode ? 'Actualizar' : 'Crear'}}
</Button>
<Button onClick={{() => reset()}}>
Limpiar
</Button>
</Space>
</Form.Item>
</Form>
);
}};
```
### Modal Wrapper
```typescript
// src/features/create{entity_name}/ui/Create{entity_name}Modal.tsx
import React from 'react';
import {{ Modal }} from 'antd';
import {{ use{entity_name}Store }} from '@entities/{entity_name_lower}';
import {{ {entity_name}Form }} from './Create{entity_name}Form';
export const {entity_name}Modal: React.FC = () => {{
const {{ isModalOpen, modalMode, selected{entity_name}, closeModal }} = use{entity_name}Store();
const title = {{
create: 'Crear {entity_name}',
edit: 'Editar {entity_name}',
view: 'Ver {entity_name}',
}}[modalMode || 'create'];
return (
<Modal
title={{title}}
open={{isModalOpen}}
onCancel={{closeModal}}
footer={{null}}
width={{600}}
destroyOnClose
>
{{modalMode !== 'view' ? (
<{entity_name}Form
mode={{modalMode === 'edit' ? 'edit' : 'create'}}
initialData={{selected{entity_name} || undefined}}
onSuccess={{closeModal}}
/>
) : (
<div>
<p><strong>Nombre:</strong> {{selected{entity_name}?.name}}</p>
<p><strong>Código:</strong> {{selected{entity_name}?.code || 'N/A'}}</p>
<p><strong>Creado:</strong> {{new Date(selected{entity_name}?.createdAt || '').toLocaleString('es-ES')}}</p>
</div>
)}}
</Modal>
);
}};
```
### Página Principal
```typescript
// src/pages/{entity_name}Page/{entity_name}Page.tsx
import React from 'react';
import {{ Card }} from 'antd';
import {{ {entity_name}Table }} from '@widgets/{entity_name}Table';
import {{ {entity_name}Modal }} from '@features/create{entity_name}';
export const {entity_name}Page: React.FC = () => {{
return (
<div style={{{{ padding: 24 }}}}>
<Card title="{title}">
<{entity_name}Table />
</Card>
<{entity_name}Modal />
</div>
);
}};
```
## Validaciones del Cliente
### Validación en Tiempo Real
```typescript
// Las validaciones Zod se ejecutan automáticamente con React Hook Form
// Validación custom adicional (ejemplo)
const validateUnique{entity_name}Code = async (code: string): Promise<boolean> => {{
try {{
const {{ data }} = await {entity_name_lower}Api.getAll({{ search: code }});
return data.data.length === 0;
}} catch {{
return false;
}}
}};
// Uso en formulario
<Controller
name="code"
control={{control}}
rules={{{{
validate: async (value) => {{
if (value && !(await validateUnique{entity_name}Code(value))) {{
return 'Este código ya existe';
}}
return true;
}},
}}}}
render={{...}}
/>
```
### Mensajes de Error
- Español claro y conciso
- Sugerencias de corrección cuando sea posible
- Highlight visual de campos con error (Ant Design `validateStatus`)
## UX/UI
### Diseño Visual
- **Layout:** Ant Design Pro Layout (Header + Sidebar + Content)
- **Colores:**
- Primary: `#1890ff` (Ant Design default)
- Success: `#52c41a`
- Warning: `#faad14`
- Error: `#f5222d`
- **Tipografía:**
- Headings: Inter font family
- Body: Roboto font family
- **Iconos:** Ant Design Icons
### Feedback al Usuario
- **Loading States:** Spinners en botones (`Button loading={{true}}`) y tablas (`Table loading={{true}}`)
- **Success Messages:** `message.success('Operación exitosa')`
- **Error Messages:** `message.error('Error: detalles...')`
- **Confirmations:** `Modal.confirm()` para acciones destructivas (delete)
- **Progress:** `Progress` para operaciones largas
### Responsiveness
- **Desktop (1200px+):** Tabla completa con todas las columnas
- **Tablet (768-1199px):** Tabla adaptada, algunas columnas ocultas
- **Mobile (<768px):** Reemplazar tabla por cards (`<List>` de Ant Design)
```typescript
// Ejemplo responsive
import {{ useMediaQuery }} from '@shared/hooks/useMediaQuery';
const isMobile = useMediaQuery('(max-width: 768px)');
return isMobile ? (
<List
dataSource={{data?.data}}
renderItem={{(item) => (
<List.Item>
<Card>{{/* Card layout */}}</Card>
</List.Item>
)}}
/>
) : (
<Table ... />
);
```
## Permisos (RBAC en UI)
```typescript
// src/shared/hooks/usePermissions.ts
import {{ useAuth }} from '@modules/auth';
export const usePermissions = () => {{
const {{ user }} = useAuth();
const can = (permission: string): boolean => {{
return user?.permissions?.includes(permission) ?? false;
}};
const hasRole = (role: string): boolean => {{
return user?.roles?.includes(role) ?? false;
}};
return {{ can, hasRole }};
}};
// Uso en componentes
import {{ usePermissions }} from '@shared/hooks/usePermissions';
const {{ can }} = usePermissions();
{{can('{module.lower()}.{entity_name_lower}.create') && (
<Button type="primary" onClick={{handleCreate}}>
Crear {entity_name}
</Button>
)}}
{{can('{module.lower()}.{entity_name_lower}.delete') && (
<Button danger onClick={{handleDelete}}>
Eliminar
</Button>
)}}
```
## Testing
### Component Tests (Vitest + React Testing Library)
```typescript
// src/widgets/{entity_name}Table/ui/{entity_name}Table.test.tsx
import {{ render, screen, waitFor }} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {{ QueryClient, QueryClientProvider }} from '@tanstack/react-query';
import {{ {entity_name}Table }} from './{entity_name}Table';
import {{ {entity_name_lower}Api }} from '@entities/{entity_name_lower}';
// Mock API
vi.mock('@entities/{entity_name_lower}', () => ({{
{entity_name_lower}Api: {{
getAll: vi.fn(),
delete: vi.fn(),
}},
}}));
describe('{entity_name}Table', () => {{
const queryClient = new QueryClient({{
defaultOptions: {{
queries: {{ retry: false }},
}},
}});
const wrapper = ({{ children }}) => (
<QueryClientProvider client={{queryClient}}>
{{children}}
</QueryClientProvider>
);
beforeEach(() => {{
vi.clearAllMocks();
}});
it('should render table with data', async () => {{
const mockData = {{
data: [
{{ id: '1', name: 'Test {entity_name} 1', code: 'TEST1', createdAt: '2025-11-24' }},
{{ id: '2', name: 'Test {entity_name} 2', code: 'TEST2', createdAt: '2025-11-24' }},
],
meta: {{ page: 1, limit: 20, total: 2, totalPages: 1 }},
}};
vi.mocked({entity_name_lower}Api.getAll).mockResolvedValue(mockData);
render(<{entity_name}Table />, {{ wrapper }});
await waitFor(() => {{
expect(screen.getByText('Test {entity_name} 1')).toBeInTheDocument();
expect(screen.getByText('Test {entity_name} 2')).toBeInTheDocument();
}});
}});
it('should call delete mutation on delete button click', async () => {{
const user = userEvent.setup();
const mockData = {{
data: [{{ id: '1', name: 'To Delete', code: 'DEL', createdAt: '2025-11-24' }}],
meta: {{ page: 1, limit: 20, total: 1, totalPages: 1 }},
}};
vi.mocked({entity_name_lower}Api.getAll).mockResolvedValue(mockData);
vi.mocked({entity_name_lower}Api.delete).mockResolvedValue();
render(<{entity_name}Table />, {{ wrapper }});
await waitFor(() => {{
expect(screen.getByText('To Delete')).toBeInTheDocument();
}});
const deleteBtn = screen.getByTitle('Eliminar');
await user.click(deleteBtn);
// Confirmar modal
const confirmBtn = screen.getByText('Eliminar');
await user.click(confirmBtn);
await waitFor(() => {{
expect({entity_name_lower}Api.delete).toHaveBeenCalledWith('1');
}});
}});
it('should filter data on search', async () => {{
const user = userEvent.setup();
vi.mocked({entity_name_lower}Api.getAll).mockResolvedValue({{
data: [],
meta: {{ page: 1, limit: 20, total: 0, totalPages: 0 }},
}});
render(<{entity_name}Table />, {{ wrapper }});
const searchInput = screen.getByPlaceholderText('Buscar por nombre o código');
await user.type(searchInput, 'test');
await user.keyboard('{{Enter}}');
await waitFor(() => {{
expect({entity_name_lower}Api.getAll).toHaveBeenCalledWith(
expect.objectContaining({{ search: 'test' }})
);
}});
}});
}});
```
### E2E Tests (Playwright)
```typescript
// e2e/{entity_name_lower}.spec.ts
import {{ test, expect }} from '@playwright/test';
test.describe('{entity_name} Management', () => {{
test.beforeEach(async ({{ page }}) => {{
// Login
await page.goto('/login');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="password"]', 'Test1234!');
await page.click('button[type="submit"]');
await page.waitForURL('/{module.lower()}/{entity_name_lower}');
}});
test('should create new {entity_name_lower}', async ({{ page }}) => {{
await page.goto('/{module.lower()}/{entity_name_lower}');
// Click create button
await page.click('text=Crear {entity_name}');
// Fill form
await page.fill('[name="name"]', 'E2E Test {entity_name}');
await page.fill('[name="code"]', 'E2E001');
// Submit
await page.click('button[type="submit"]');
// Verify success message
await expect(page.locator('.ant-message-success')).toContainText('creado exitosamente');
// Verify in table
await expect(page.locator('table')).toContainText('E2E Test {entity_name}');
}});
test('should edit existing {entity_name_lower}', async ({{ page }}) => {{
await page.goto('/{module.lower()}/{entity_name_lower}');
// Click edit on first row
await page.click('table tbody tr:first-child button[title="Editar"]');
// Update name
await page.fill('[name="name"]', 'Updated Name');
// Submit
await page.click('button[type="submit"]');
// Verify success
await expect(page.locator('.ant-message-success')).toContainText('actualizado exitosamente');
}});
test('should delete {entity_name_lower} with confirmation', async ({{ page }}) => {{
await page.goto('/{module.lower()}/{entity_name_lower}');
// Click delete on first row
await page.click('table tbody tr:first-child button[title="Eliminar"]');
// Confirm deletion
await page.click('.ant-modal-confirm button.ant-btn-dangerous');
// Verify success
await expect(page.locator('.ant-message-success')).toContainText('eliminado exitosamente');
}});
test('should validate required fields', async ({{ page }}) => {{
await page.goto('/{module.lower()}/{entity_name_lower}');
// Click create button
await page.click('text=Crear {entity_name}');
// Try to submit empty form
await page.click('button[type="submit"]');
// Verify validation errors
await expect(page.locator('.ant-form-item-explain-error')).toContainText('al menos 3 caracteres');
}});
test('should search and filter {entity_name_lower}s', async ({{ page }}) => {{
await page.goto('/{module.lower()}/{entity_name_lower}');
// Search
await page.fill('[placeholder="Buscar por nombre o código"]', 'test');
await page.keyboard.press('Enter');
// Verify API call (check network tab or wait for results)
await page.waitForTimeout(500);
// Results should be filtered
const rows = page.locator('table tbody tr');
await expect(rows.first()).toBeVisible();
}});
}});
```
## Performance
### Optimizaciones React
```typescript
// 1. Lazy loading de páginas
const {entity_name}Page = React.lazy(() => import('@pages/{entity_name}Page'));
// 2. Memo para componentes pesados
export const {entity_name}Card = React.memo<{entity_name}CardProps>(({{ data }}) => {{
// ...
}});
// 3. useMemo para cálculos pesados
const filteredData = useMemo(() => {{
return data?.data.filter((item) => item.status === 'active');
}}, [data]);
// 4. useCallback para funciones pasadas como props
const handleEdit = useCallback((id: string) => {{
openModal('edit', data.find((item) => item.id === id));
}}, [data, openModal]);
```
### Virtualización para Listas Largas
```typescript
// Para tablas con >100 rows
import {{ FixedSizeList }} from 'react-window';
<FixedSizeList
height={{600}}
itemCount={{data?.data.length || 0}}
itemSize={{50}}
width={{'100%'}}
>
{{({{ index, style }}) => (
<div style={{style}}>{{/* Row content */}}</div>
)}}
</FixedSizeList>
```
### Debounce en Búsqueda
```typescript
import {{ useDebouncedCallback }} from 'use-debounce';
const debouncedSearch = useDebouncedCallback(
(value: string) => {{
setFilters((prev) => ({{ ...prev, search: value, page: 1 }}));
}},
300
);
<Search onChange={{(e) => debouncedSearch(e.target.value)}} />
```
### Bundle Size
- **Code splitting:** Lazy loading de páginas (`React.lazy()`)
- **Tree shaking:** Importar solo lo necesario de Ant Design
- **Chunk optimization:** Vite automático
- **Target bundle size:** <200 KB por chunk
## Referencias
- [RF Asociado](../../requerimientos-funcionales/{module.lower()}/RF-{module}-{rf_num}-{title.lower().replace(" ", "-").replace("(", "").replace(")", "").replace("/", "-")}.md)
- [ET Backend](../backend/{module.lower()}/ET-BACKEND-{module}-{rf_num}-{title.lower().replace(" ", "-").replace("(", "").replace(")", "").replace("/", "-")}.md)
- [Gamilit Frontend Patterns](../../00-analisis-referencias/gamilit/frontend-patterns.md)
- [ADR-009: Frontend Architecture](../../adr/ADR-009-frontend-architecture.md)
## Dependencias
### Módulos Frontend
- `AuthModule` - Autenticación y autorización
- `SharedModule` - Componentes y utilities compartidos
### RF Bloqueantes
- RF-MGN-001-001 (Autenticación de Usuarios)
- RF-{module}-{rf_num} Backend completado
## Notas de Implementación
- [ ] Crear estructura FSD: `entities/{entity_name_lower}`, `features/create{entity_name}`, `widgets/{entity_name}Table`
- [ ] Definir types e interfaces en `entities/{entity_name_lower}/model/types.ts`
- [ ] Crear schemas de validación Zod
- [ ] Implementar API client con axios
- [ ] Crear React Query hooks (queries + mutations)
- [ ] Implementar Zustand store para UI state
- [ ] Crear componentes UI (Table, Form, Modal)
- [ ] Implementar rutas en React Router
- [ ] Crear tests (componentes + e2e)
- [ ] Validar responsiveness (desktop + tablet + mobile)
- [ ] Validar con criterios de aceptación del RF
- [ ] Code review por Tech Lead
- [ ] QA testing
## Estimación
- **Frontend Development:** {sp_frontend} SP
- **Testing (Unit + e2e):** {sp_testing} SP
- **Code Review + QA:** {sp_review} SP
- **Total:** {sp} SP
---
**Documento generado:** 2025-11-24
**Versión:** 1.0
**Estado:** Diseñado
**Próximo paso:** Implementación
'''
return template
def main():
"""Genera todos los ET Backend y Frontend"""
print("🚀 Generando Especificaciones Técnicas (ET)...")
print("=" * 80)
total_backend = 0
total_frontend = 0
# Iterar por todos los módulos
for module_num in range(1, 15): # MGN-001 a MGN-014
module = f"MGN-{module_num:03d}"
module_lower = module.lower()
rf_module_dir = RF_DIR / module_lower
if not rf_module_dir.exists():
print(f"⚠️ Módulo {module} no encontrado, saltando...")
continue
backend_module_dir = BACKEND_DIR / module_lower
frontend_module_dir = FRONTEND_DIR / module_lower
# Obtener todos los RF del módulo
rf_files = sorted(rf_module_dir.glob("RF-*.md"))
print(f"\n📦 Módulo {module}: {len(rf_files)} RF")
for rf_file in rf_files:
# Parsear RF
rf_data = parse_rf_file(rf_file)
if not rf_data:
print(f" ⚠️ Error parseando {rf_file.name}")
continue
# Obtener recurso API
rf_num = rf_data["rf_num"]
resource = MODULE_RESOURCES.get(module, {}).get(rf_num, "resource")
# Generar ET-Backend
backend_content = generate_backend_et(rf_data, resource)
backend_filename = f"ET-BACKEND-{module}-{rf_num}-{rf_data['title'].lower().replace(' ', '-').replace('(', '').replace(')', '').replace('/', '-')}.md"
backend_path = backend_module_dir / backend_filename
backend_path.write_text(backend_content, encoding='utf-8')
total_backend += 1
print(f" ✅ Backend: {backend_filename}")
# Generar ET-Frontend
frontend_content = generate_frontend_et(rf_data, resource)
frontend_filename = f"ET-FRONTEND-{module}-{rf_num}-{rf_data['title'].lower().replace(' ', '-').replace('(', '').replace(')', '').replace('/', '-')}.md"
frontend_path = frontend_module_dir / frontend_filename
frontend_path.write_text(frontend_content, encoding='utf-8')
total_frontend += 1
print(f" ✅ Frontend: {frontend_filename}")
print("\n" + "=" * 80)
print(f"✨ Generación completada:")
print(f" - ET-Backend: {total_backend} archivos")
print(f" - ET-Frontend: {total_frontend} archivos")
print(f" - Total: {total_backend + total_frontend} archivos")
print("=" * 80)
if __name__ == "__main__":
main()