erp-core/docs/04-modelado/especificaciones-tecnicas/backend/mgn-006/ET-BACKEND-MGN-006-001-solicitudes-de-cotización-rfq.md

29 KiB

ET-BACKEND-MGN-006-001: Solicitudes de Cotización (RFQ)

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

Resumen Técnico

Backend API para solicitudes de cotización (rfq). 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/purchase/rfq

// Request
POST /api/v1/purchase/rfq
Authorization: Bearer <JWT>
Content-Type: application/json

{
  // Payload según RF
}

// Response 201 Created
{
  "data": { ... },
  "message": "SolicitudesCotizaciónrfq 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 solicitudescotizaciónrfq"
}

GET /api/v1/purchase/rfq

// Request
GET /api/v1/purchase/rfq?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/purchase/rfq/:id

// Request
GET /api/v1/purchase/rfq/{id}
Authorization: Bearer <JWT>

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

// Response 404 Not Found
{
  "error": "NotFoundError",
  "message": "SolicitudesCotizaciónrfq no encontrado"
}

PUT /api/v1/purchase/rfq/:id

// Request
PUT /api/v1/purchase/rfq/{id}
Authorization: Bearer <JWT>
Content-Type: application/json

{
  // Payload de actualización
}

// Response 200 OK
{
  "data": { ... },
  "message": "SolicitudesCotizaciónrfq actualizado exitosamente"
}

DELETE /api/v1/purchase/rfq/:id

// Request
DELETE /api/v1/purchase/rfq/{id}
Authorization: Bearer <JWT>

// Response 200 OK (soft delete)
{
  "message": "SolicitudesCotizaciónrfq 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: purchase-schema-ddl.sql
model SolicitudesCotizaciónrfq {
  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("purchase.rfq_lines")
  @@index([tenantId])
  @@unique([tenantId, code])
}

DTOs (Data Transfer Objects)

Create DTO

// src/modules/mgn-006/dto/create-solicitudescotizaciónrfq.dto.ts
import { IsString, IsEmail, IsOptional, MinLength, MaxLength, IsUUID } from 'class-validator';

export class CreateSolicitudesCotizaciónrfqDto {
  @IsString()
  @MinLength(3)
  @MaxLength(255)
  name: string;

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

  // Campos adicionales según RF
}

Update DTO

// src/modules/mgn-006/dto/update-solicitudescotizaciónrfq.dto.ts
import { PartialType } from '@nestjs/mapped-types';
import { CreateSolicitudesCotizaciónrfqDto } from './create-solicitudescotizaciónrfq.dto';

export class UpdateSolicitudesCotizaciónrfqDto extends PartialType(CreateSolicitudesCotizaciónrfqDto) {}

Response DTO

// src/modules/mgn-006/dto/solicitudescotizaciónrfq-response.dto.ts
export class SolicitudesCotizaciónrfqResponseDto {
  id: string;
  tenantId: string;
  name: string;
  code?: string;
  createdAt: Date;
  updatedAt?: Date;
}

Filter DTO

// src/modules/mgn-006/dto/filter-solicitudescotizaciónrfq.dto.ts
import { IsOptional, IsString, IsInt, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';

export class FilterSolicitudesCotizaciónrfqDto {
  @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-006/services/solicitudescotizaciónrfq.service.ts
import { Injectable, NotFoundException, BadRequestException, ConflictException } from '@nestjs/common';
import { PrismaService } from '@shared/prisma/prisma.service';
import { CreateSolicitudesCotizaciónrfqDto, UpdateSolicitudesCotizaciónrfqDto, FilterSolicitudesCotizaciónrfqDto } from '../dto';

@Injectable()
export class SolicitudesCotizaciónrfqService {
  constructor(private readonly prisma: PrismaService) {}

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

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

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

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

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

    return entity;
  }

  async findAll(tenantId: string, filters: FilterSolicitudesCotizaciónrfqDto) {
    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.solicitudescotizaciónrfq.findMany({
        where,
        skip,
        take: limit,
        orderBy: { [sortBy]: sortOrder },
      }),
      this.prisma.solicitudescotizaciónrfq.count({ where }),
    ]);

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

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

    if (!entity) {
      throw new NotFoundException(`SolicitudesCotizaciónrfq con ID ${id} no encontrado`);
    }

    return entity;
  }

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

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

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

    // 4. Eventos
    // await this.eventEmitter.emit('solicitudescotizaciónrfq.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.solicitudescotizaciónrfq.update({
      where: { id },
      data: {
        deletedAt: new Date(),
        deletedBy: userId,
      },
    });

    // 4. Eventos
    // await this.eventEmitter.emit('solicitudescotizaciónrfq.deleted', { id });
  }

  private async validateBusinessRules(
    tenantId: string,
    dto: Partial<CreateSolicitudesCotizaciónrfqDto>,
    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-006/controllers/solicitudescotizaciónrfq.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 { SolicitudesCotizaciónrfqService } from '../services/solicitudescotizaciónrfq.service';
import { CreateSolicitudesCotizaciónrfqDto, UpdateSolicitudesCotizaciónrfqDto, FilterSolicitudesCotizaciónrfqDto, SolicitudesCotizaciónrfqResponseDto } from '../dto';

@ApiTags('SolicitudesCotizaciónrfq')
@ApiBearerAuth()
@Controller('api/v1/purchase/rfq')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class SolicitudesCotizaciónrfqController {
  constructor(private readonly service: SolicitudesCotizaciónrfqService) {}

  @Post()
  @RequirePermissions('mgn-006.solicitudescotizaciónrfq.create')
  @ApiOperation({ summary: 'Crear solicitudescotizaciónrfq' })
  @ApiResponse({ status: 201, description: 'SolicitudesCotizaciónrfq creado exitosamente', type: SolicitudesCotizaciónrfqResponseDto })
  @ApiResponse({ status: 400, description: 'Datos inválidos' })
  @ApiResponse({ status: 403, description: 'Sin permisos' })
  async create(
    @CurrentTenant() tenantId: string,
    @CurrentUser('id') userId: string,
    @Body() dto: CreateSolicitudesCotizaciónrfqDto,
  ) {
    const entity = await this.service.create(tenantId, userId, dto);
    return {
      data: entity,
      message: 'SolicitudesCotizaciónrfq creado exitosamente',
    };
  }

  @Get()
  @RequirePermissions('mgn-006.solicitudescotizaciónrfq.read')
  @ApiOperation({ summary: 'Listar solicitudescotizaciónrfqs' })
  @ApiResponse({ status: 200, description: 'Lista de solicitudescotizaciónrfqs', type: [SolicitudesCotizaciónrfqResponseDto] })
  async findAll(
    @CurrentTenant() tenantId: string,
    @Query() filters: FilterSolicitudesCotizaciónrfqDto,
  ) {
    return this.service.findAll(tenantId, filters);
  }

  @Get(':id')
  @RequirePermissions('mgn-006.solicitudescotizaciónrfq.read')
  @ApiOperation({ summary: 'Obtener solicitudescotizaciónrfq por ID' })
  @ApiResponse({ status: 200, description: 'SolicitudesCotizaciónrfq encontrado', type: SolicitudesCotizaciónrfqResponseDto })
  @ApiResponse({ status: 404, description: 'SolicitudesCotizaciónrfq 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-006.solicitudescotizaciónrfq.update')
  @ApiOperation({ summary: 'Actualizar solicitudescotizaciónrfq' })
  @ApiResponse({ status: 200, description: 'SolicitudesCotizaciónrfq actualizado exitosamente' })
  @ApiResponse({ status: 404, description: 'SolicitudesCotizaciónrfq no encontrado' })
  async update(
    @CurrentTenant() tenantId: string,
    @CurrentUser('id') userId: string,
    @Param('id') id: string,
    @Body() dto: UpdateSolicitudesCotizaciónrfqDto,
  ) {
    const entity = await this.service.update(tenantId, userId, id, dto);
    return {
      data: entity,
      message: 'SolicitudesCotizaciónrfq actualizado exitosamente',
    };
  }

  @Delete(':id')
  @HttpCode(HttpStatus.NO_CONTENT)
  @RequirePermissions('mgn-006.solicitudescotizaciónrfq.delete')
  @ApiOperation({ summary: 'Eliminar solicitudescotizaciónrfq' })
  @ApiResponse({ status: 204, description: 'SolicitudesCotizaciónrfq eliminado exitosamente' })
  @ApiResponse({ status: 404, description: 'SolicitudesCotizaciónrfq 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<CreateSolicitudesCotizaciónrfqDto>): 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<CreateSolicitudesCotizaciónrfqDto>): 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<CreateSolicitudesCotizaciónrfqDto>): 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<CreateSolicitudesCotizaciónrfqDto>): 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-006.solicitudescotizaciónrfq.create')
@Post()
async create(...) { ... }

Permisos requeridos:

  • mgn-006.solicitudescotizaciónrfq.create - Crear registros
  • mgn-006.solicitudescotizaciónrfq.read - Leer registros
  • mgn-006.solicitudescotizaciónrfq.update - Actualizar registros
  • mgn-006.solicitudescotizaciónrfq.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

// solicitudescotizaciónrfq.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { SolicitudesCotizaciónrfqService } from './solicitudescotizaciónrfq.service';
import { PrismaService } from '@shared/prisma/prisma.service';
import { NotFoundException, ConflictException } from '@nestjs/common';

describe('SolicitudesCotizaciónrfqService', () => {
  let service: SolicitudesCotizaciónrfqService;
  let prisma: PrismaService;

  const mockPrisma = {
    solicitudescotizaciónrfq: {
      create: jest.fn(),
      findMany: jest.fn(),
      findFirst: jest.fn(),
      update: jest.fn(),
      count: jest.fn(),
    },
  };

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

    service = module.get<SolicitudesCotizaciónrfqService>(SolicitudesCotizaciónrfqService);
    prisma = module.get<PrismaService>(PrismaService);
  });

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

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

      mockPrisma.solicitudescotizaciónrfq.findFirst.mockResolvedValue(null);
      mockPrisma.solicitudescotizaciónrfq.create.mockResolvedValue(expected);

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

      expect(result).toEqual(expected);
      expect(mockPrisma.solicitudescotizaciónrfq.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 SolicitudesCotizaciónrfq', code: 'DUPLICATE' };

      mockPrisma.solicitudescotizaciónrfq.findFirst.mockResolvedValue({ id: 'existing' });

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

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

      mockPrisma.solicitudescotizaciónrfq.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.solicitudescotizaciónrfq.findFirst.mockResolvedValue(null);

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

  describe('update', () => {
    it('should update solicitudescotizaciónrfq 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.solicitudescotizaciónrfq.findFirst.mockResolvedValue(existing);
      mockPrisma.solicitudescotizaciónrfq.update.mockResolvedValue(expected);

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

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

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

      mockPrisma.solicitudescotizaciónrfq.findFirst.mockResolvedValue(existing);
      mockPrisma.solicitudescotizaciónrfq.update.mockResolvedValue({ ...existing, deletedAt: new Date() });

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

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

Integration Tests (e2e)

// solicitudescotizaciónrfq.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('SolicitudesCotizaciónrfqController (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/purchase/rfq', () => {
    it('should create solicitudescotizaciónrfq successfully', () => {
      return request(app.getHttpServer())
        .post('/api/v1/purchase/rfq')
        .set('Authorization', `Bearer ${authToken}`)
        .send({
          name: 'Test SolicitudesCotizaciónrfq',
          code: 'TEST001',
        })
        .expect(201)
        .expect((res) => {
          expect(res.body.data).toHaveProperty('id');
          expect(res.body.data.name).toBe('Test SolicitudesCotizaciónrfq');
          expect(res.body.message).toContain('creado exitosamente');
        });
    });

    it('should return 400 for invalid data', () => {
      return request(app.getHttpServer())
        .post('/api/v1/purchase/rfq')
        .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/purchase/rfq')
        .send({ name: 'Test' })
        .expect(401);
    });
  });

  describe('GET /api/v1/purchase/rfq', () => {
    it('should return list of solicitudescotizaciónrfqs', () => {
      return request(app.getHttpServer())
        .get('/api/v1/purchase/rfq')
        .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/purchase/rfq?search=test')
        .set('Authorization', `Bearer ${authToken}`)
        .expect(200);
    });
  });

  describe('GET /api/v1/purchase/rfq/:id', () => {
    it('should return solicitudescotizaciónrfq by ID', async () => {
      // Crear primero
      const createRes = await request(app.getHttpServer())
        .post('/api/v1/purchase/rfq')
        .set('Authorization', `Bearer ${authToken}`)
        .send({ name: 'Find By ID Test' });

      const id = createRes.body.data.id;

      return request(app.getHttpServer())
        .get(`/api/v1/purchase/rfq/${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/purchase/rfq/non-existent-id')
        .set('Authorization', `Bearer ${authToken}`)
        .expect(404);
    });
  });

  describe('PUT /api/v1/purchase/rfq/:id', () => {
    it('should update solicitudescotizaciónrfq successfully', async () => {
      // Crear primero
      const createRes = await request(app.getHttpServer())
        .post('/api/v1/purchase/rfq')
        .set('Authorization', `Bearer ${authToken}`)
        .send({ name: 'Original Name' });

      const id = createRes.body.data.id;

      return request(app.getHttpServer())
        .put(`/api/v1/purchase/rfq/${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/purchase/rfq/:id', () => {
    it('should soft delete solicitudescotizaciónrfq', async () => {
      // Crear primero
      const createRes = await request(app.getHttpServer())
        .post('/api/v1/purchase/rfq')
        .set('Authorization', `Bearer ${authToken}`)
        .send({ name: 'To Delete' });

      const id = createRes.body.data.id;

      await request(app.getHttpServer())
        .delete(`/api/v1/purchase/rfq/${id}`)
        .set('Authorization', `Bearer ${authToken}`)
        .expect(204);

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

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

Performance

Índices Necesarios

-- Índices definidos en purchase-schema-ddl.sql
CREATE INDEX idx_solicitudescotizaciónrfq_tenant_code ON purchase.solicitudescotizaciónrfq(tenant_id, code);
CREATE INDEX idx_solicitudescotizaciónrfq_tenant_name ON purchase.solicitudescotizaciónrfq(tenant_id, name);
CREATE INDEX idx_solicitudescotizaciónrfq_created_at ON purchase.solicitudescotizaciónrfq(created_at DESC);

-- Para soft delete
CREATE INDEX idx_solicitudescotizaciónrfq_deleted_at ON purchase.solicitudescotizaciónrfq(deleted_at) WHERE deleted_at IS NULL;

Caching (Redis)

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

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

@CacheEvict({ pattern: 'solicitudescotizaciónrfq:*' })
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: FilterSolicitudesCotizaciónrfqDto) {
  return this.prisma.solicitudescotizaciónrfq.findMany({
    where: { ... },
    select: {
      id: true,
      name: true,
      code: true,
      createdAt: true,
      // Omitir campos pesados
    },
  });
}

Logging y Monitoreo

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

export class SolicitudesCotizaciónrfqService {
  private readonly logger = new Logger(SolicitudesCotizaciónrfqService.name);

  async create(tenantId: string, userId: string, dto: CreateSolicitudesCotizaciónrfqDto) {
    this.logger.log({
      action: 'solicitudescotizaciónrfq.create',
      tenantId,
      userId,
      data: dto,
    });

    try {
      const entity = await this.prisma.solicitudescotizaciónrfq.create({ ... });

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

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

Métricas (Prometheus)

  • http_requests_total{method="POST",endpoint="/api/v1/purchase/rfq",status="201"}
  • http_request_duration_seconds{endpoint="/api/v1/purchase/rfq"}
  • solicitudescotizaciónrfq_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-006/solicitudescotizaciónrfq
  • Crear service: nest g service mgn-006/solicitudescotizaciónrfq
  • Crear controller: nest g controller mgn-006/solicitudescotizaciónrfq
  • Crear DTOs en mgn-006/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