# ET-{modulo}-backend: Especificacion Tecnica Backend > **INSTRUCCIONES:** Copiar este template para cada modulo. > Eliminar esta seccion antes de guardar. --- ## Metadatos | Campo | Valor | |-------|-------| | **Modulo** | {MGN-NNN / MAI-NNN} | | **Nombre** | {nombre del modulo} | | **Estado** | {Borrador / Aprobado / Implementado} | | **Version** | 1.0.0 | | **Autor** | {nombre} | | **Fecha** | {YYYY-MM-DD} | --- ## Resumen de Implementacion ### Estructura de Modulo ``` modules/{modulo}/ ├── {modulo}.module.ts ├── {modulo}.controller.ts ├── {modulo}.service.ts ├── entities/ │ ├── {entidad}.entity.ts │ └── index.ts ├── dto/ │ ├── create-{entidad}.dto.ts │ ├── update-{entidad}.dto.ts │ ├── query-{entidad}.dto.ts │ └── index.ts ├── interfaces/ │ └── {modulo}.interface.ts └── __tests__/ ├── {modulo}.service.spec.ts └── {modulo}.controller.spec.ts ``` ### Dependencias del Modulo | Modulo | Tipo | Proposito | |--------|------|-----------| | `AuthModule` | Import | Autenticacion | | `TenantsModule` | Import | Multi-tenant | | `{OtroModule}` | Import | {proposito} | --- ## Entities ### {NombreEntity} **Archivo:** `entities/{nombre}.entity.ts` **Tabla:** `{schema}.{tabla}` ```typescript import { Entity, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm'; import { BaseEntity } from '@shared/entities/base.entity'; /** * Entity para {descripcion} * * @see database/ddl/schemas/{schema}/tables/{NN}-{tabla}.sql * @see docs/01-requerimientos/RF-{modulo}/ */ @Entity({ schema: '{schema}', name: '{tabla}' }) export class {Nombre}Entity extends BaseEntity { /** * {Descripcion del campo} * @example "{ejemplo}" */ @Column({ length: 200 }) name: string; /** * {Descripcion} */ @Column({ name: 'internal_code', length: 50, nullable: true }) internalCode?: string; /** * Estado del registro */ @Column({ type: 'enum', enum: {Nombre}Status, default: {Nombre}Status.DRAFT }) status: {Nombre}Status; // Relaciones @ManyToOne(() => {RelatedEntity}) @JoinColumn({ name: '{fk_column}' }) {related}: {RelatedEntity}; @OneToMany(() => {ChildEntity}, child => child.{parent}) {children}: {ChildEntity}[]; } ``` ### Enums ```typescript // enums/{modulo}.enum.ts export enum {Nombre}Status { DRAFT = 'draft', ACTIVE = 'active', INACTIVE = 'inactive', CANCELLED = 'cancelled' } export enum {Nombre}Type { TYPE_A = 'type_a', TYPE_B = 'type_b' } ``` --- ## DTOs ### Create{Nombre}Dto **Archivo:** `dto/create-{nombre}.dto.ts` ```typescript import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsString, IsOptional, IsUUID, IsEnum, Length, IsNumber, Min, Max, IsBoolean, IsArray, ValidateNested } from 'class-validator'; import { Type } from 'class-transformer'; export class Create{Nombre}Dto { @ApiProperty({ description: '{Descripcion del campo}', example: '{ejemplo}', minLength: 3, maxLength: 200 }) @IsString() @Length(3, 200, { message: 'Nombre debe tener entre 3 y 200 caracteres' }) name: string; @ApiPropertyOptional({ description: '{Descripcion}', example: '{ejemplo}' }) @IsOptional() @IsString() @Length(0, 50) internalCode?: string; @ApiPropertyOptional({ description: 'ID de {relacion}', example: 'uuid-example' }) @IsOptional() @IsUUID('4', { message: '{Relacion} ID debe ser UUID valido' }) {related}Id?: string; @ApiPropertyOptional({ description: '{Descripcion}', type: [Create{Child}Dto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => Create{Child}Dto) {children}?: Create{Child}Dto[]; } ``` ### Update{Nombre}Dto ```typescript import { PartialType } from '@nestjs/swagger'; import { Create{Nombre}Dto } from './create-{nombre}.dto'; export class Update{Nombre}Dto extends PartialType(Create{Nombre}Dto) {} ``` ### Query{Nombre}Dto ```typescript import { ApiPropertyOptional } from '@nestjs/swagger'; import { IsOptional, IsEnum, IsUUID, IsString } from 'class-validator'; import { PaginationDto } from '@shared/dto/pagination.dto'; export class Query{Nombre}Dto extends PaginationDto { @ApiPropertyOptional({ description: 'Filtrar por estado' }) @IsOptional() @IsEnum({Nombre}Status) status?: {Nombre}Status; @ApiPropertyOptional({ description: 'Filtrar por {relacion}' }) @IsOptional() @IsUUID() {related}Id?: string; @ApiPropertyOptional({ description: 'Buscar por nombre' }) @IsOptional() @IsString() search?: string; } ``` --- ## API Endpoints ### Resumen de Endpoints | Metodo | Ruta | Descripcion | Permisos | |--------|------|-------------|----------| | GET | `/api/v1/{recursos}` | Lista paginada | `{modulo}:read` | | GET | `/api/v1/{recursos}/:id` | Obtener por ID | `{modulo}:read` | | POST | `/api/v1/{recursos}` | Crear nuevo | `{modulo}:create` | | PATCH | `/api/v1/{recursos}/:id` | Actualizar parcial | `{modulo}:update` | | PUT | `/api/v1/{recursos}/:id` | Actualizar completo | `{modulo}:update` | | DELETE | `/api/v1/{recursos}/:id` | Eliminar (soft) | `{modulo}:delete` | | POST | `/api/v1/{recursos}/:id/{accion}` | Accion especifica | `{modulo}:{accion}` | --- ### GET /api/v1/{recursos} **Descripcion:** Lista paginada de {recursos} **Query Parameters:** | Param | Tipo | Requerido | Default | Descripcion | |-------|------|-----------|---------|-------------| | `page` | number | No | 1 | Pagina actual | | `per_page` | number | No | 20 | Items por pagina (max 100) | | `sort_by` | string | No | created_at | Campo de ordenamiento | | `sort_order` | string | No | desc | asc/desc | | `filter[status]` | string | No | - | Filtrar por estado | | `filter[{related}Id]` | uuid | No | - | Filtrar por relacion | | `search` | string | No | - | Busqueda por nombre | **Response 200:** ```json { "data": [ { "id": "uuid", "name": "Nombre ejemplo", "internalCode": "CODE-001", "status": "active", "{related}": { "id": "uuid", "name": "Relacionado" }, "createdAt": "2025-01-01T00:00:00Z", "updatedAt": "2025-01-01T00:00:00Z" } ], "meta": { "currentPage": 1, "perPage": 20, "totalPages": 5, "totalCount": 100, "hasNextPage": true, "hasPrevPage": false } } ``` --- ### GET /api/v1/{recursos}/:id **Descripcion:** Obtener {recurso} por ID **Path Parameters:** | Param | Tipo | Descripcion | |-------|------|-------------| | `id` | uuid | ID del {recurso} | **Response 200:** ```json { "data": { "id": "uuid", "name": "Nombre ejemplo", "internalCode": "CODE-001", "status": "active", "{related}": { "id": "uuid", "name": "Relacionado" }, "{children}": [ { "id": "uuid", "description": "Detalle" } ], "createdAt": "2025-01-01T00:00:00Z", "updatedAt": "2025-01-01T00:00:00Z" } } ``` **Response 404:** ```json { "error": { "code": "RES-001", "message": "{Recurso} no encontrado" } } ``` --- ### POST /api/v1/{recursos} **Descripcion:** Crear nuevo {recurso} **Request Body:** `Create{Nombre}Dto` ```json { "name": "Nuevo recurso", "internalCode": "CODE-002", "{related}Id": "uuid-relacion" } ``` **Response 201:** ```json { "data": { "id": "uuid-nuevo", "name": "Nuevo recurso", "status": "draft", "createdAt": "2025-01-01T00:00:00Z" }, "message": "{Recurso} creado exitosamente" } ``` **Response 400 (Validacion):** ```json { "error": { "code": "VAL-001", "message": "Datos de entrada invalidos", "details": [ { "field": "name", "message": "Nombre debe tener entre 3 y 200 caracteres" } ] } } ``` **Response 409 (Duplicado):** ```json { "error": { "code": "RES-002", "message": "Ya existe un {recurso} con ese codigo" } } ``` --- ### PATCH /api/v1/{recursos}/:id **Descripcion:** Actualizar {recurso} parcialmente **Request Body:** `Update{Nombre}Dto` (campos opcionales) ```json { "name": "Nombre actualizado" } ``` **Response 200:** ```json { "data": { "id": "uuid", "name": "Nombre actualizado", "updatedAt": "2025-01-02T00:00:00Z" }, "message": "{Recurso} actualizado exitosamente" } ``` --- ### DELETE /api/v1/{recursos}/:id **Descripcion:** Eliminar {recurso} (soft delete) **Response 200:** ```json { "data": null, "message": "{Recurso} eliminado exitosamente" } ``` --- ### POST /api/v1/{recursos}/:id/{accion} **Descripcion:** Ejecutar accion sobre {recurso} **Ejemplo:** `POST /api/v1/sale-orders/:id/confirm` **Request Body:** (opcional, segun accion) ```json { "notifyCustomer": true } ``` **Response 200:** ```json { "data": { "id": "uuid", "status": "confirmed", "{accion}At": "2025-01-02T00:00:00Z", "{accion}By": "user-uuid" }, "message": "{Recurso} {accion} exitosamente" } ``` --- ## Service ### {Nombre}Service **Archivo:** `{modulo}.service.ts` ```typescript import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource } from 'typeorm'; import { {Nombre}Entity } from './entities/{nombre}.entity'; import { Create{Nombre}Dto, Update{Nombre}Dto, Query{Nombre}Dto } from './dto'; import { PaginatedResponse } from '@shared/interfaces/pagination.interface'; @Injectable() export class {Nombre}Service { constructor( @InjectRepository({Nombre}Entity) private readonly repository: Repository<{Nombre}Entity>, private readonly dataSource: DataSource, ) {} /** * Lista paginada con filtros */ async findAll( tenantId: string, query: Query{Nombre}Dto ): Promise> { const { page = 1, perPage = 20, sortBy = 'createdAt', sortOrder = 'DESC' } = query; const qb = this.repository.createQueryBuilder('entity') .where('entity.tenantId = :tenantId', { tenantId }) .andWhere('entity.isActive = :isActive', { isActive: true }); // Aplicar filtros if (query.status) { qb.andWhere('entity.status = :status', { status: query.status }); } if (query.search) { qb.andWhere('entity.name ILIKE :search', { search: `%${query.search}%` }); } // Ordenamiento y paginacion qb.orderBy(`entity.${sortBy}`, sortOrder as 'ASC' | 'DESC') .skip((page - 1) * perPage) .take(perPage); const [data, totalCount] = await qb.getManyAndCount(); return { data, meta: { currentPage: page, perPage, totalPages: Math.ceil(totalCount / perPage), totalCount, hasNextPage: page * perPage < totalCount, hasPrevPage: page > 1, }, }; } /** * Obtener por ID */ async findOne(tenantId: string, id: string): Promise<{Nombre}Entity> { const entity = await this.repository.findOne({ where: { id, tenantId, isActive: true }, relations: ['{related}', '{children}'], }); if (!entity) { throw new NotFoundException({ code: 'RES-001', message: '{Recurso} no encontrado', }); } return entity; } /** * Crear nuevo */ async create( tenantId: string, userId: string, dto: Create{Nombre}Dto ): Promise<{Nombre}Entity> { // Validar unicidad si aplica if (dto.internalCode) { const exists = await this.repository.findOne({ where: { tenantId, internalCode: dto.internalCode, isActive: true }, }); if (exists) { throw new ConflictException({ code: 'RES-002', message: 'Ya existe un {recurso} con ese codigo', }); } } const entity = this.repository.create({ ...dto, tenantId, createdBy: userId, updatedBy: userId, }); return this.repository.save(entity); } /** * Actualizar */ async update( tenantId: string, userId: string, id: string, dto: Update{Nombre}Dto ): Promise<{Nombre}Entity> { const entity = await this.findOne(tenantId, id); Object.assign(entity, dto, { updatedBy: userId }); return this.repository.save(entity); } /** * Eliminar (soft delete) */ async remove(tenantId: string, userId: string, id: string): Promise { const entity = await this.findOne(tenantId, id); entity.isActive = false; entity.updatedBy = userId; await this.repository.save(entity); } /** * Accion: {accion} */ async {accion}(tenantId: string, userId: string, id: string): Promise<{Nombre}Entity> { const entity = await this.findOne(tenantId, id); // Validar transicion de estado if (entity.status !== {Nombre}Status.DRAFT) { throw new BadRequestException({ code: 'BIZ-002', message: 'Solo se puede {accion} un {recurso} en estado borrador', }); } entity.status = {Nombre}Status.ACTIVE; entity.updatedBy = userId; return this.repository.save(entity); } } ``` --- ## Controller ### {Nombre}Controller **Archivo:** `{modulo}.controller.ts` ```typescript import { Controller, Get, Post, Patch, Delete, Body, Param, Query, UseGuards, ParseUUIDPipe, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam, ApiQuery, } from '@nestjs/swagger'; import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard'; import { PermissionsGuard } from '@modules/auth/guards/permissions.guard'; import { RequirePermissions } from '@modules/auth/decorators/permissions.decorator'; import { CurrentUser } from '@modules/auth/decorators/current-user.decorator'; import { TenantId } from '@shared/decorators/tenant.decorator'; import { {Nombre}Service } from './{modulo}.service'; import { Create{Nombre}Dto, Update{Nombre}Dto, Query{Nombre}Dto } from './dto'; @Controller('{recursos}') @ApiTags('{Recursos}') @ApiBearerAuth() @UseGuards(JwtAuthGuard, PermissionsGuard) export class {Nombre}Controller { constructor(private readonly service: {Nombre}Service) {} @Get() @ApiOperation({ summary: 'Lista paginada de {recursos}' }) @ApiResponse({ status: 200, description: 'Lista obtenida exitosamente' }) @RequirePermissions('{modulo}:read') async findAll( @TenantId() tenantId: string, @Query() query: Query{Nombre}Dto, ) { return this.service.findAll(tenantId, query); } @Get(':id') @ApiOperation({ summary: 'Obtener {recurso} por ID' }) @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) @ApiResponse({ status: 200, description: '{Recurso} encontrado' }) @ApiResponse({ status: 404, description: '{Recurso} no encontrado' }) @RequirePermissions('{modulo}:read') async findOne( @TenantId() tenantId: string, @Param('id', ParseUUIDPipe) id: string, ) { return { data: await this.service.findOne(tenantId, id) }; } @Post() @ApiOperation({ summary: 'Crear nuevo {recurso}' }) @ApiResponse({ status: 201, description: '{Recurso} creado' }) @ApiResponse({ status: 400, description: 'Datos invalidos' }) @ApiResponse({ status: 409, description: '{Recurso} ya existe' }) @RequirePermissions('{modulo}:create') async create( @TenantId() tenantId: string, @CurrentUser('id') userId: string, @Body() dto: Create{Nombre}Dto, ) { const entity = await this.service.create(tenantId, userId, dto); return { data: entity, message: '{Recurso} creado exitosamente' }; } @Patch(':id') @ApiOperation({ summary: 'Actualizar {recurso}' }) @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) @ApiResponse({ status: 200, description: '{Recurso} actualizado' }) @ApiResponse({ status: 404, description: '{Recurso} no encontrado' }) @RequirePermissions('{modulo}:update') async update( @TenantId() tenantId: string, @CurrentUser('id') userId: string, @Param('id', ParseUUIDPipe) id: string, @Body() dto: Update{Nombre}Dto, ) { const entity = await this.service.update(tenantId, userId, id, dto); return { data: entity, message: '{Recurso} actualizado exitosamente' }; } @Delete(':id') @ApiOperation({ summary: 'Eliminar {recurso}' }) @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) @ApiResponse({ status: 200, description: '{Recurso} eliminado' }) @ApiResponse({ status: 404, description: '{Recurso} no encontrado' }) @RequirePermissions('{modulo}:delete') async remove( @TenantId() tenantId: string, @CurrentUser('id') userId: string, @Param('id', ParseUUIDPipe) id: string, ) { await this.service.remove(tenantId, userId, id); return { data: null, message: '{Recurso} eliminado exitosamente' }; } @Post(':id/{accion}') @ApiOperation({ summary: '{Accion} {recurso}' }) @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) @ApiResponse({ status: 200, description: '{Recurso} {accion} exitosamente' }) @RequirePermissions('{modulo}:{accion}') async {accion}( @TenantId() tenantId: string, @CurrentUser('id') userId: string, @Param('id', ParseUUIDPipe) id: string, ) { const entity = await this.service.{accion}(tenantId, userId, id); return { data: entity, message: '{Recurso} {accion} exitosamente' }; } } ``` --- ## Validaciones ### Tabla de Validaciones | Campo | Regla | Mensaje Error | Codigo | |-------|-------|---------------|--------| | `name` | required, 3-200 chars | "Nombre debe tener entre 3 y 200 caracteres" | VAL-001 | | `internalCode` | optional, unique per tenant | "Ya existe un {recurso} con ese codigo" | RES-002 | | `{related}Id` | valid UUID, exists | "{Relacion} no encontrado" | RES-001 | | `status` | valid enum value | "Estado invalido" | VAL-002 | --- ## Tests ### Unit Tests **Archivo:** `__tests__/{modulo}.service.spec.ts` ```typescript describe('{Nombre}Service', () => { describe('findAll', () => { it('should return paginated results', async () => {}); it('should filter by status', async () => {}); it('should search by name', async () => {}); }); describe('findOne', () => { it('should return entity by id', async () => {}); it('should throw NotFoundException if not found', async () => {}); it('should not return inactive entities', async () => {}); }); describe('create', () => { it('should create new entity', async () => {}); it('should throw ConflictException on duplicate code', async () => {}); }); describe('update', () => { it('should update entity', async () => {}); it('should throw NotFoundException if not found', async () => {}); }); describe('remove', () => { it('should soft delete entity', async () => {}); }); describe('{accion}', () => { it('should change status to active', async () => {}); it('should throw if status is not draft', async () => {}); }); }); ``` ### Coverage Minimo | Metrica | Minimo | |---------|--------| | Statements | 80% | | Branches | 70% | | Functions | 80% | | Lines | 80% | --- ## Referencias | Documento | Path | |-----------|------| | DDL Spec | `docs/02-modelado/database-design/DDL-SPEC-{schema}.md` | | Requerimientos | `docs/01-requerimientos/RF-{modulo}/` | | API Standards | `orchestration/directivas/ESTANDARES-API-REST-GENERICO.md` | --- ## Checklist Pre-Implementacion - [ ] DDL Specification aprobada - [ ] Requerimientos funcionales aprobados - [ ] DTOs con validaciones completas - [ ] Endpoints documentados con Swagger - [ ] Tests unitarios definidos - [ ] Permisos identificados --- ## Historial | Version | Fecha | Autor | Cambios | |---------|-------|-------|---------| | 1.0 | {YYYY-MM-DD} | {nombre} | Creacion inicial | --- *Template version 1.0 - ERP Suite*