19 KiB
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}
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
// 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
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
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
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:
{
"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:
{
"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:
{
"error": {
"code": "RES-001",
"message": "{Recurso} no encontrado"
}
}
POST /api/v1/{recursos}
Descripcion: Crear nuevo {recurso}
Request Body: Create{Nombre}Dto
{
"name": "Nuevo recurso",
"internalCode": "CODE-002",
"{related}Id": "uuid-relacion"
}
Response 201:
{
"data": {
"id": "uuid-nuevo",
"name": "Nuevo recurso",
"status": "draft",
"createdAt": "2025-01-01T00:00:00Z"
},
"message": "{Recurso} creado exitosamente"
}
Response 400 (Validacion):
{
"error": {
"code": "VAL-001",
"message": "Datos de entrada invalidos",
"details": [
{
"field": "name",
"message": "Nombre debe tener entre 3 y 200 caracteres"
}
]
}
}
Response 409 (Duplicado):
{
"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)
{
"name": "Nombre actualizado"
}
Response 200:
{
"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:
{
"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)
{
"notifyCustomer": true
}
Response 200:
{
"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
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<PaginatedResponse<{Nombre}Entity>> {
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<void> {
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
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
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