erp-core/docs/04-modelado/especificaciones-tecnicas/backend/mgn-009/ET-BACKEND-MGN-009-005-conversión-a-cotización.md

29 KiB

ET-BACKEND-MGN-009-005: Conversión a Cotización

RF Asociado: RF-MGN-009-005 Módulo: MGN-009 Complejidad: Baja Story Points: 2 SP (Backend) Estado: Diseñado Fecha: 2025-11-24

Resumen Técnico

Backend API para conversión a cotización. Implementa endpoints RESTful con NestJS, validaciones de negocio, persistencia con Prisma ORM y soporte multi-tenancy con Row Level Security (RLS).

Stack Tecnológico

  • Runtime: Node.js 20 LTS
  • Framework: NestJS 10.x
  • ORM: Prisma 5.x (con multi-schema support)
  • Validación: class-validator + class-transformer
  • Autenticación: JWT (Passport.js)
  • Base de Datos: PostgreSQL 16 (multi-schema + RLS)
  • Testing: Jest + Supertest
  • Documentación: Swagger/OpenAPI 3.0

Arquitectura del Endpoint

API Endpoints

POST /api/v1/crm/convert-to-quote

// Request
POST /api/v1/crm/convert-to-quote
Authorization: Bearer <JWT>
Content-Type: application/json

{
  // Payload según RF
}

// Response 201 Created
{
  "data": { ... },
  "message": "ConversiónCotización creado exitosamente",
  "timestamp": "2025-11-24T..."
}

// Response 400 Bad Request
{
  "error": "ValidationError",
  "message": "Datos inválidos",
  "details": [...]
}

// Response 403 Forbidden
{
  "error": "ForbiddenError",
  "message": "Sin permisos para crear conversióncotización"
}

GET /api/v1/crm/convert-to-quote

// Request
GET /api/v1/crm/convert-to-quote?page=1&limit=20&sortBy=createdAt&sortOrder=desc
Authorization: Bearer <JWT>

// Response 200 OK
{
  "data": [...],
  "meta": {
    "page": 1,
    "limit": 20,
    "total": 100,
    "totalPages": 5
  }
}

GET /api/v1/crm/convert-to-quote/:id

// Request
GET /api/v1/crm/convert-to-quote/{id}
Authorization: Bearer <JWT>

// Response 200 OK
{
  "data": { ... }
}

// Response 404 Not Found
{
  "error": "NotFoundError",
  "message": "ConversiónCotización no encontrado"
}

PUT /api/v1/crm/convert-to-quote/:id

// Request
PUT /api/v1/crm/convert-to-quote/{id}
Authorization: Bearer <JWT>
Content-Type: application/json

{
  // Payload de actualización
}

// Response 200 OK
{
  "data": { ... },
  "message": "ConversiónCotización actualizado exitosamente"
}

DELETE /api/v1/crm/convert-to-quote/:id

// Request
DELETE /api/v1/crm/convert-to-quote/{id}
Authorization: Bearer <JWT>

// Response 200 OK (soft delete)
{
  "message": "ConversiónCotización eliminado exitosamente"
}

// Response 409 Conflict
{
  "error": "ConflictError",
  "message": "No se puede eliminar: tiene registros relacionados"
}

Modelo de Datos (Prisma Schema)

// Basado en el DDL SQL: sales-schema-ddl.sql
model ConversiónCotización {
  id         String   @id @default(uuid())
  tenantId   String   @map("tenant_id")

  // Campos específicos del negocio
  name       String   @db.VarChar(255)
  code       String?  @db.VarChar(50)

  // Auditoría
  createdAt  DateTime @default(now()) @map("created_at")
  createdBy  String?  @map("created_by")
  updatedAt  DateTime? @updatedAt @map("updated_at")
  updatedBy  String?  @map("updated_by")
  deletedAt  DateTime? @map("deleted_at")
  deletedBy  String?  @map("deleted_by")

  // Relations
  tenant     Tenant   @relation(fields: [tenantId], references: [id])

  @@map("sales.quotations")
  @@index([tenantId])
  @@unique([tenantId, code])
}

DTOs (Data Transfer Objects)

Create DTO

// src/modules/mgn-009/dto/create-conversióncotización.dto.ts
import { IsString, IsEmail, IsOptional, MinLength, MaxLength, IsUUID } from 'class-validator';

export class CreateConversiónCotizaciónDto {
  @IsString()
  @MinLength(3)
  @MaxLength(255)
  name: string;

  @IsString()
  @IsOptional()
  @MaxLength(50)
  code?: string;

  // Campos adicionales según RF
}

Update DTO

// src/modules/mgn-009/dto/update-conversióncotización.dto.ts
import { PartialType } from '@nestjs/mapped-types';
import { CreateConversiónCotizaciónDto } from './create-conversióncotización.dto';

export class UpdateConversiónCotizaciónDto extends PartialType(CreateConversiónCotizaciónDto) {}

Response DTO

// src/modules/mgn-009/dto/conversióncotización-response.dto.ts
export class ConversiónCotizaciónResponseDto {
  id: string;
  tenantId: string;
  name: string;
  code?: string;
  createdAt: Date;
  updatedAt?: Date;
}

Filter DTO

// src/modules/mgn-009/dto/filter-conversióncotización.dto.ts
import { IsOptional, IsString, IsInt, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';

export class FilterConversiónCotizaciónDto {
  @IsOptional()
  @IsString()
  search?: string;

  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  page?: number = 1;

  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  @Max(100)
  limit?: number = 20;

  @IsOptional()
  @IsString()
  sortBy?: string = 'createdAt';

  @IsOptional()
  @IsString()
  sortOrder?: 'asc' | 'desc' = 'desc';
}

Servicios de Negocio

Service Principal

// src/modules/mgn-009/services/conversióncotización.service.ts
import { Injectable, NotFoundException, BadRequestException, ConflictException } from '@nestjs/common';
import { PrismaService } from '@shared/prisma/prisma.service';
import { CreateConversiónCotizaciónDto, UpdateConversiónCotizaciónDto, FilterConversiónCotizaciónDto } from '../dto';

@Injectable()
export class ConversiónCotizaciónService {
  constructor(private readonly prisma: PrismaService) {}

  async create(tenantId: string, userId: string, dto: CreateConversiónCotizaciónDto) {
    // 1. Validaciones de negocio
    await this.validateBusinessRules(tenantId, dto);

    // 2. Verificar duplicados
    if (dto.code) {
      const existing = await this.prisma.conversióncotización.findFirst({
        where: {
          tenantId,
          code: dto.code,
          deletedAt: null,
        },
      });

      if (existing) {
        throw new ConflictException(`ConversiónCotización con código ${dto.code} ya existe`);
      }
    }

    // 3. Crear en BD (RLS automático por tenantId)
    const entity = await this.prisma.conversióncotización.create({
      data: {
        tenantId,
        createdBy: userId,
        ...dto,
      },
    });

    // 4. Eventos/side effects (si aplica)
    // await this.eventEmitter.emit('conversióncotización.created', entity);

    return entity;
  }

  async findAll(tenantId: string, filters: FilterConversiónCotizaciónDto) {
    const { page, limit, sortBy, sortOrder, search } = filters;
    const skip = (page - 1) * limit;

    const where = {
      tenantId,
      deletedAt: null,
      ...(search && {
        OR: [
          { name: { contains: search, mode: 'insensitive' } },
          { code: { contains: search, mode: 'insensitive' } },
        ],
      }),
    };

    const [data, total] = await Promise.all([
      this.prisma.conversióncotización.findMany({
        where,
        skip,
        take: limit,
        orderBy: { [sortBy]: sortOrder },
      }),
      this.prisma.conversióncotización.count({ where }),
    ]);

    return {
      data,
      meta: {
        page,
        limit,
        total,
        totalPages: Math.ceil(total / limit),
      },
    };
  }

  async findOne(tenantId: string, id: string) {
    const entity = await this.prisma.conversióncotización.findFirst({
      where: {
        id,
        tenantId,
        deletedAt: null,
      },
    });

    if (!entity) {
      throw new NotFoundException(`ConversiónCotización con ID ${id} no encontrado`);
    }

    return entity;
  }

  async update(tenantId: string, userId: string, id: string, dto: UpdateConversiónCotizaciónDto) {
    // 1. Verificar existencia
    await this.findOne(tenantId, id);

    // 2. Validar cambios
    await this.validateBusinessRules(tenantId, dto, id);

    // 3. Actualizar
    const entity = await this.prisma.conversióncotización.update({
      where: { id },
      data: {
        ...dto,
        updatedBy: userId,
        updatedAt: new Date(),
      },
    });

    // 4. Eventos
    // await this.eventEmitter.emit('conversióncotización.updated', entity);

    return entity;
  }

  async remove(tenantId: string, userId: string, id: string) {
    // 1. Verificar existencia
    await this.findOne(tenantId, id);

    // 2. Verificar dependencias (opcional)
    // await this.checkDependencies(id);

    // 3. Soft delete
    await this.prisma.conversióncotización.update({
      where: { id },
      data: {
        deletedAt: new Date(),
        deletedBy: userId,
      },
    });

    // 4. Eventos
    // await this.eventEmitter.emit('conversióncotización.deleted', { id });
  }

  private async validateBusinessRules(
    tenantId: string,
    dto: Partial<CreateConversiónCotizaciónDto>,
    excludeId?: string,
  ) {
    // Implementar reglas de negocio del RF
    // Ejemplo:
    // if (dto.field && !await this.isValidField(dto.field)) {
    //   throw new BadRequestException('Field inválido');
    // }
  }
}

Controller

// src/modules/mgn-009/controllers/conversióncotización.controller.ts
import {
  Controller,
  Get,
  Post,
  Put,
  Delete,
  Body,
  Param,
  Query,
  UseGuards,
  HttpCode,
  HttpStatus
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard';
import { PermissionsGuard } from '@modules/auth/guards/permissions.guard';
import { RequirePermissions } from '@shared/decorators/require-permissions.decorator';
import { CurrentTenant } from '@shared/decorators/current-tenant.decorator';
import { CurrentUser } from '@shared/decorators/current-user.decorator';
import { ConversiónCotizaciónService } from '../services/conversióncotización.service';
import { CreateConversiónCotizaciónDto, UpdateConversiónCotizaciónDto, FilterConversiónCotizaciónDto, ConversiónCotizaciónResponseDto } from '../dto';

@ApiTags('ConversiónCotización')
@ApiBearerAuth()
@Controller('api/v1/crm/convert-to-quote')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class ConversiónCotizaciónController {
  constructor(private readonly service: ConversiónCotizaciónService) {}

  @Post()
  @RequirePermissions('mgn-009.conversióncotización.create')
  @ApiOperation({ summary: 'Crear conversióncotización' })
  @ApiResponse({ status: 201, description: 'ConversiónCotización creado exitosamente', type: ConversiónCotizaciónResponseDto })
  @ApiResponse({ status: 400, description: 'Datos inválidos' })
  @ApiResponse({ status: 403, description: 'Sin permisos' })
  async create(
    @CurrentTenant() tenantId: string,
    @CurrentUser('id') userId: string,
    @Body() dto: CreateConversiónCotizaciónDto,
  ) {
    const entity = await this.service.create(tenantId, userId, dto);
    return {
      data: entity,
      message: 'ConversiónCotización creado exitosamente',
    };
  }

  @Get()
  @RequirePermissions('mgn-009.conversióncotización.read')
  @ApiOperation({ summary: 'Listar conversióncotizacións' })
  @ApiResponse({ status: 200, description: 'Lista de conversióncotizacións', type: [ConversiónCotizaciónResponseDto] })
  async findAll(
    @CurrentTenant() tenantId: string,
    @Query() filters: FilterConversiónCotizaciónDto,
  ) {
    return this.service.findAll(tenantId, filters);
  }

  @Get(':id')
  @RequirePermissions('mgn-009.conversióncotización.read')
  @ApiOperation({ summary: 'Obtener conversióncotización por ID' })
  @ApiResponse({ status: 200, description: 'ConversiónCotización encontrado', type: ConversiónCotizaciónResponseDto })
  @ApiResponse({ status: 404, description: 'ConversiónCotización no encontrado' })
  async findOne(
    @CurrentTenant() tenantId: string,
    @Param('id') id: string,
  ) {
    const entity = await this.service.findOne(tenantId, id);
    return { data: entity };
  }

  @Put(':id')
  @RequirePermissions('mgn-009.conversióncotización.update')
  @ApiOperation({ summary: 'Actualizar conversióncotización' })
  @ApiResponse({ status: 200, description: 'ConversiónCotización actualizado exitosamente' })
  @ApiResponse({ status: 404, description: 'ConversiónCotización no encontrado' })
  async update(
    @CurrentTenant() tenantId: string,
    @CurrentUser('id') userId: string,
    @Param('id') id: string,
    @Body() dto: UpdateConversiónCotizaciónDto,
  ) {
    const entity = await this.service.update(tenantId, userId, id, dto);
    return {
      data: entity,
      message: 'ConversiónCotización actualizado exitosamente',
    };
  }

  @Delete(':id')
  @HttpCode(HttpStatus.NO_CONTENT)
  @RequirePermissions('mgn-009.conversióncotización.delete')
  @ApiOperation({ summary: 'Eliminar conversióncotización' })
  @ApiResponse({ status: 204, description: 'ConversiónCotización eliminado exitosamente' })
  @ApiResponse({ status: 404, description: 'ConversiónCotización no encontrado' })
  async remove(
    @CurrentTenant() tenantId: string,
    @CurrentUser('id') userId: string,
    @Param('id') id: string,
  ) {
    await this.service.remove(tenantId, userId, id);
  }
}

Reglas de Negocio (Implementación)

RN-1: Validaciones de integridad según modelo de datos

private async validateRN1(dto: Partial<CreateConversiónCotizaciónDto>): Promise<void> {
  // Implementación de la regla: Validaciones de integridad según modelo de datos
  // TODO: Implementar validación específica
}

RN-2: RLS filtra registros por tenant y empresa

private async validateRN2(dto: Partial<CreateConversiónCotizaciónDto>): Promise<void> {
  // Implementación de la regla: RLS filtra registros por tenant y empresa
  // TODO: Implementar validación específica
}

RN-3: Cambios quedan registrados en audit log

private async validateRN3(dto: Partial<CreateConversiónCotizaciónDto>): Promise<void> {
  // Implementación de la regla: Cambios quedan registrados en audit log
  // TODO: Implementar validación específica
}

RN-4: Notificaciones según configuración de usuario

private async validateRN4(dto: Partial<CreateConversiónCotizaciónDto>): Promise<void> {
  // Implementación de la regla: Notificaciones según configuración de usuario
  // TODO: Implementar validación específica
}

Seguridad

Multi-Tenancy (RLS)

  • Prisma middleware automático que agrega tenantId a todas las queries
  • Row Level Security en PostgreSQL valida acceso
  • Soft delete: usa deletedAt en lugar de borrado físico

Permisos (RBAC)

@UseGuards(JwtAuthGuard, PermissionsGuard)
@RequirePermissions('mgn-009.conversióncotización.create')
@Post()
async create(...) { ... }

Permisos requeridos:

  • mgn-009.conversióncotización.create - Crear registros
  • mgn-009.conversióncotización.read - Leer registros
  • mgn-009.conversióncotización.update - Actualizar registros
  • mgn-009.conversióncotización.delete - Eliminar registros

Validación de Input

  • DTOs con class-validator para validación automática
  • Sanitización de strings (XSS prevention)
  • Rate limiting: 100 requests/min por usuario
  • SQL Injection prevention: Prisma ORM con prepared statements

Auditoría

Todos los cambios registran:

  • createdBy / updatedBy / deletedBy - Usuario que ejecuta acción
  • createdAt / updatedAt / deletedAt - Timestamp de acción
  • Integración con MGN-014 (Tracking Automático de Cambios)

Testing

Unit Tests

// conversióncotización.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { ConversiónCotizaciónService } from './conversióncotización.service';
import { PrismaService } from '@shared/prisma/prisma.service';
import { NotFoundException, ConflictException } from '@nestjs/common';

describe('ConversiónCotizaciónService', () => {
  let service: ConversiónCotizaciónService;
  let prisma: PrismaService;

  const mockPrisma = {
    conversióncotización: {
      create: jest.fn(),
      findMany: jest.fn(),
      findFirst: jest.fn(),
      update: jest.fn(),
      count: jest.fn(),
    },
  };

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        ConversiónCotizaciónService,
        { provide: PrismaService, useValue: mockPrisma },
      ],
    }).compile();

    service = module.get<ConversiónCotizaciónService>(ConversiónCotizaciónService);
    prisma = module.get<PrismaService>(PrismaService);
  });

  afterEach(() => {
    jest.clearAllMocks();
  });

  describe('create', () => {
    it('should create conversióncotización with valid data', async () => {
      const tenantId = 'tenant-123';
      const userId = 'user-456';
      const dto = { name: 'Test ConversiónCotización', code: 'TEST' };
      const expected = { id: 'id-789', tenantId, ...dto };

      mockPrisma.conversióncotización.findFirst.mockResolvedValue(null);
      mockPrisma.conversióncotización.create.mockResolvedValue(expected);

      const result = await service.create(tenantId, userId, dto);

      expect(result).toEqual(expected);
      expect(mockPrisma.conversióncotización.create).toHaveBeenCalledWith({
        data: { tenantId, createdBy: userId, ...dto },
      });
    });

    it('should throw ConflictException when code already exists', async () => {
      const tenantId = 'tenant-123';
      const userId = 'user-456';
      const dto = { name: 'Test ConversiónCotización', code: 'DUPLICATE' };

      mockPrisma.conversióncotización.findFirst.mockResolvedValue({ id: 'existing' });

      await expect(service.create(tenantId, userId, dto)).rejects.toThrow(ConflictException);
    });
  });

  describe('findOne', () => {
    it('should return conversióncotización when found', async () => {
      const tenantId = 'tenant-123';
      const id = 'id-789';
      const expected = { id, tenantId, name: 'Test' };

      mockPrisma.conversióncotización.findFirst.mockResolvedValue(expected);

      const result = await service.findOne(tenantId, id);

      expect(result).toEqual(expected);
    });

    it('should throw NotFoundException when not found', async () => {
      const tenantId = 'tenant-123';
      const id = 'non-existent';

      mockPrisma.conversióncotización.findFirst.mockResolvedValue(null);

      await expect(service.findOne(tenantId, id)).rejects.toThrow(NotFoundException);
    });
  });

  describe('update', () => {
    it('should update conversióncotización successfully', async () => {
      const tenantId = 'tenant-123';
      const userId = 'user-456';
      const id = 'id-789';
      const dto = { name: 'Updated Name' };
      const existing = { id, tenantId, name: 'Old Name' };
      const expected = { ...existing, ...dto };

      mockPrisma.conversióncotización.findFirst.mockResolvedValue(existing);
      mockPrisma.conversióncotización.update.mockResolvedValue(expected);

      const result = await service.update(tenantId, userId, id, dto);

      expect(result).toEqual(expected);
    });
  });

  describe('remove', () => {
    it('should soft delete conversióncotización', async () => {
      const tenantId = 'tenant-123';
      const userId = 'user-456';
      const id = 'id-789';
      const existing = { id, tenantId, name: 'To Delete' };

      mockPrisma.conversióncotización.findFirst.mockResolvedValue(existing);
      mockPrisma.conversióncotización.update.mockResolvedValue({ ...existing, deletedAt: new Date() });

      await service.remove(tenantId, userId, id);

      expect(mockPrisma.conversióncotización.update).toHaveBeenCalledWith({
        where: { id },
        data: {
          deletedAt: expect.any(Date),
          deletedBy: userId,
        },
      });
    });
  });
});

Integration Tests (e2e)

// conversióncotización.controller.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '@/app.module';

describe('ConversiónCotizaciónController (e2e)', () => {
  let app: INestApplication;
  let authToken: string;
  let tenantId: string;

  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    await app.init();

    // Login para obtener token
    const loginResponse = await request(app.getHttpServer())
      .post('/api/v1/auth/login')
      .send({ email: 'test@example.com', password: 'Test1234!' });

    authToken = loginResponse.body.data.accessToken;
    tenantId = loginResponse.body.data.user.tenantId;
  });

  afterAll(async () => {
    await app.close();
  });

  describe('POST /api/v1/crm/convert-to-quote', () => {
    it('should create conversióncotización successfully', () => {
      return request(app.getHttpServer())
        .post('/api/v1/crm/convert-to-quote')
        .set('Authorization', `Bearer ${authToken}`)
        .send({
          name: 'Test ConversiónCotización',
          code: 'TEST001',
        })
        .expect(201)
        .expect((res) => {
          expect(res.body.data).toHaveProperty('id');
          expect(res.body.data.name).toBe('Test ConversiónCotización');
          expect(res.body.message).toContain('creado exitosamente');
        });
    });

    it('should return 400 for invalid data', () => {
      return request(app.getHttpServer())
        .post('/api/v1/crm/convert-to-quote')
        .set('Authorization', `Bearer ${authToken}`)
        .send({ name: '' }) // Invalid: name too short
        .expect(400);
    });

    it('should return 401 without token', () => {
      return request(app.getHttpServer())
        .post('/api/v1/crm/convert-to-quote')
        .send({ name: 'Test' })
        .expect(401);
    });
  });

  describe('GET /api/v1/crm/convert-to-quote', () => {
    it('should return list of conversióncotizacións', () => {
      return request(app.getHttpServer())
        .get('/api/v1/crm/convert-to-quote')
        .set('Authorization', `Bearer ${authToken}`)
        .expect(200)
        .expect((res) => {
          expect(res.body.data).toBeInstanceOf(Array);
          expect(res.body.meta).toHaveProperty('page');
          expect(res.body.meta).toHaveProperty('total');
        });
    });

    it('should filter by search term', () => {
      return request(app.getHttpServer())
        .get('/api/v1/crm/convert-to-quote?search=test')
        .set('Authorization', `Bearer ${authToken}`)
        .expect(200);
    });
  });

  describe('GET /api/v1/crm/convert-to-quote/:id', () => {
    it('should return conversióncotización by ID', async () => {
      // Crear primero
      const createRes = await request(app.getHttpServer())
        .post('/api/v1/crm/convert-to-quote')
        .set('Authorization', `Bearer ${authToken}`)
        .send({ name: 'Find By ID Test' });

      const id = createRes.body.data.id;

      return request(app.getHttpServer())
        .get(`/api/v1/crm/convert-to-quote/${id}`)
        .set('Authorization', `Bearer ${authToken}`)
        .expect(200)
        .expect((res) => {
          expect(res.body.data.id).toBe(id);
        });
    });

    it('should return 404 for non-existent ID', () => {
      return request(app.getHttpServer())
        .get('/api/v1/crm/convert-to-quote/non-existent-id')
        .set('Authorization', `Bearer ${authToken}`)
        .expect(404);
    });
  });

  describe('PUT /api/v1/crm/convert-to-quote/:id', () => {
    it('should update conversióncotización successfully', async () => {
      // Crear primero
      const createRes = await request(app.getHttpServer())
        .post('/api/v1/crm/convert-to-quote')
        .set('Authorization', `Bearer ${authToken}`)
        .send({ name: 'Original Name' });

      const id = createRes.body.data.id;

      return request(app.getHttpServer())
        .put(`/api/v1/crm/convert-to-quote/${id}`)
        .set('Authorization', `Bearer ${authToken}`)
        .send({ name: 'Updated Name' })
        .expect(200)
        .expect((res) => {
          expect(res.body.data.name).toBe('Updated Name');
        });
    });
  });

  describe('DELETE /api/v1/crm/convert-to-quote/:id', () => {
    it('should soft delete conversióncotización', async () => {
      // Crear primero
      const createRes = await request(app.getHttpServer())
        .post('/api/v1/crm/convert-to-quote')
        .set('Authorization', `Bearer ${authToken}`)
        .send({ name: 'To Delete' });

      const id = createRes.body.data.id;

      await request(app.getHttpServer())
        .delete(`/api/v1/crm/convert-to-quote/${id}`)
        .set('Authorization', `Bearer ${authToken}`)
        .expect(204);

      // Verificar que no aparece en listados
      const listRes = await request(app.getHttpServer())
        .get('/api/v1/crm/convert-to-quote')
        .set('Authorization', `Bearer ${authToken}`);

      const deleted = listRes.body.data.find(item => item.id === id);
      expect(deleted).toBeUndefined();
    });
  });
});

Performance

Índices Necesarios

-- Índices definidos en sales-schema-ddl.sql
CREATE INDEX idx_conversióncotización_tenant_code ON sales.conversióncotización(tenant_id, code);
CREATE INDEX idx_conversióncotización_tenant_name ON sales.conversióncotización(tenant_id, name);
CREATE INDEX idx_conversióncotización_created_at ON sales.conversióncotización(created_at DESC);

-- Para soft delete
CREATE INDEX idx_conversióncotización_deleted_at ON sales.conversióncotización(deleted_at) WHERE deleted_at IS NULL;

Caching (Redis)

// Para listas maestras estáticas
@Cacheable({ ttl: 3600 }) // 1 hora
async findAll(tenantId: string, filters: FilterConversiónCotizaciónDto) {
  // ...
}

// Invalidación en cambios
@CacheEvict({ pattern: 'conversióncotización:*' })
async update(...) { ... }

@CacheEvict({ pattern: 'conversióncotización:*' })
async remove(...) { ... }

Paginación

  • Default: 20 items por página
  • Máximo: 100 items por página
  • Cursor-based: Para listados grandes (opcional)

Query Optimization

// Usar select para reducir payload
async findAll(tenantId: string, filters: FilterConversiónCotizaciónDto) {
  return this.prisma.conversióncotización.findMany({
    where: { ... },
    select: {
      id: true,
      name: true,
      code: true,
      createdAt: true,
      // Omitir campos pesados
    },
  });
}

Logging y Monitoreo

import { Logger } from '@nestjs/common';

export class ConversiónCotizaciónService {
  private readonly logger = new Logger(ConversiónCotizaciónService.name);

  async create(tenantId: string, userId: string, dto: CreateConversiónCotizaciónDto) {
    this.logger.log({
      action: 'conversióncotización.create',
      tenantId,
      userId,
      data: dto,
    });

    try {
      const entity = await this.prisma.conversióncotización.create({ ... });

      this.logger.log({
        action: 'conversióncotización.created',
        tenantId,
        userId,
        entityId: entity.id,
      });

      return entity;
    } catch (error) {
      this.logger.error({
        action: 'conversióncotización.create.error',
        tenantId,
        userId,
        error: error.message,
        stack: error.stack,
      });
      throw error;
    }
  }
}

Métricas (Prometheus)

  • http_requests_total{method="POST",endpoint="/api/v1/crm/convert-to-quote",status="201"}
  • http_request_duration_seconds{endpoint="/api/v1/crm/convert-to-quote"}
  • conversióncotización_created_total{tenant_id="xxx"}

Referencias

Dependencias

Módulos NestJS

  • AuthModule - Autenticación y autorización
  • PrismaModule - ORM y acceso a BD
  • LoggerModule - Logging centralizado
  • EventsModule - Event emitter (opcional)

RF Bloqueantes

  • RF-MGN-001-001 (Autenticación de Usuarios)
  • RF-MGN-001-004 (Multi-Tenancy)

RF Dependientes

  • [Listar RF que dependen de este]

Notas de Implementación

  • Crear módulo NestJS: nest g module mgn-009/conversióncotización
  • Crear service: nest g service mgn-009/conversióncotización
  • Crear controller: nest g controller mgn-009/conversióncotización
  • Crear DTOs en mgn-009/dto/ folder
  • Definir Prisma model en schema.prisma
  • Implementar unit tests (>80% coverage)
  • Implementar e2e tests
  • Documentar endpoints en Swagger
  • Validar con criterios de aceptación del RF
  • Code review por Tech Lead
  • QA testing

Estimación

  • Backend Development: 2 SP
  • Testing (Unit + e2e): 1 SP
  • Code Review + QA: 1 SP
  • Total: 5 SP

Documento generado: 2025-11-24 Versión: 1.0 Estado: Diseñado Próximo paso: Implementación