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

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