#!/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 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 // 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 // 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 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 // 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, 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): Promise {{ // 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); }}); 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 }} /> }} /> }} /> }} /> ``` ## 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; 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; export type Update{entity_name}FormData = z.infer; // 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 => {{ 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) => ( `Total: ${{total}} registros`, }}}} onChange={{handleTableChange}} /> ); }}; ``` ### 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({{ 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 (
( )}} /> ( )}} /> ); }}; ``` ### 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 ( {{modalMode !== 'view' ? ( <{entity_name}Form mode={{modalMode === 'edit' ? 'edit' : 'create'}} initialData={{selected{entity_name} || undefined}} onSuccess={{closeModal}} /> ) : (

Nombre: {{selected{entity_name}?.name}}

Código: {{selected{entity_name}?.code || 'N/A'}}

Creado: {{new Date(selected{entity_name}?.createdAt || '').toLocaleString('es-ES')}}

)}}
); }}; ``` ### 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 (
<{entity_name}Table /> <{entity_name}Modal />
); }}; ``` ## 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 => {{ try {{ const {{ data }} = await {entity_name_lower}Api.getAll({{ search: code }}); return data.data.length === 0; }} catch {{ return false; }} }}; // Uso en formulario {{ 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 (`` de Ant Design) ```typescript // Ejemplo responsive import {{ useMediaQuery }} from '@shared/hooks/useMediaQuery'; const isMobile = useMediaQuery('(max-width: 768px)'); return isMobile ? ( ( {{/* Card layout */}} )}} /> ) : (
); ``` ## 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') && ( )}} {{can('{module.lower()}.{entity_name_lower}.delete') && ( )}} ``` ## 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 }}) => ( {{children}} ); 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'; {{({{ index, style }}) => (
{{/* Row content */}}
)}}
``` ### Debounce en Búsqueda ```typescript import {{ useDebouncedCallback }} from 'use-debounce'; const debouncedSearch = useDebouncedCallback( (value: string) => {{ setFilters((prev) => ({{ ...prev, search: value, page: 1 }})); }}, 300 ); 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()