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