erp-core/orchestration/templates/TEMPLATE-ESPECIFICACION-BACKEND.md

857 lines
19 KiB
Markdown

# 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<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`
```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*