# ET-PROJ-001-BACKEND: Especificación Técnica de Backend - Catálogo de Proyectos **Epic:** MAI-002 - Proyectos y Estructura de Obra **RF:** RF-PROJ-001 **Tipo:** Especificación Técnica Backend **Prioridad:** Crítica (P0) **Estado:** En Implementación **Última actualización:** 2025-12-06 --- ## Stack Tecnológico - **Framework:** NestJS 10+ - **ORM:** TypeORM 0.3.17 - **Base de datos:** PostgreSQL 15+ - **Validaciones:** class-validator 0.14+ - **Transformaciones:** class-transformer 0.5+ - **Documentación:** @nestjs/swagger 7+ - **Eventos:** @nestjs/event-emitter 2+ --- ## 1. Arquitectura del Módulo ### 1.1 Estructura de Directorios ``` apps/backend/src/modules/projects/ ├── entities/ │ ├── project.entity.ts │ ├── stage.entity.ts │ ├── project-team-assignment.entity.ts │ └── project-document.entity.ts ├── dto/ │ ├── create-project.dto.ts │ ├── update-project.dto.ts │ ├── change-status.dto.ts │ ├── filter-project.dto.ts │ └── project-response.dto.ts ├── controllers/ │ └── projects.controller.ts ├── services/ │ ├── projects.service.ts │ └── project-metrics.service.ts ├── guards/ │ └── project-access.guard.ts ├── decorators/ │ └── constructora.decorator.ts ├── events/ │ └── project.events.ts ├── listeners/ │ └── project-status.listener.ts ├── projects.module.ts └── __tests__/ ├── projects.service.spec.ts └── projects.controller.spec.ts ``` --- ## 2. Entities TypeORM ### 2.1 Project Entity **Archivo:** `entities/project.entity.ts` ```typescript import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, OneToMany, JoinColumn, CreateDateColumn, UpdateDateColumn, Index, Check, } from 'typeorm'; import { Stage } from './stage.entity'; import { ProjectTeamAssignment } from './project-team-assignment.entity'; import { ProjectDocument } from './project-document.entity'; /** * Enumeración de tipos de proyecto inmobiliario */ export enum ProjectType { FRACCIONAMIENTO_HORIZONTAL = 'fraccionamiento_horizontal', CONJUNTO_HABITACIONAL = 'conjunto_habitacional', EDIFICIO_VERTICAL = 'edificio_vertical', MIXTO = 'mixto', } /** * Enumeración de estados del ciclo de vida del proyecto * Flujo: licitacion → adjudicado → ejecucion → entregado → cerrado */ export enum ProjectStatus { LICITACION = 'licitacion', ADJUDICADO = 'adjudicado', EJECUCION = 'ejecucion', ENTREGADO = 'entregado', CERRADO = 'cerrado', } /** * Enumeración de tipos de cliente */ export enum ClientType { PUBLICO = 'publico', PRIVADO = 'privado', MIXTO = 'mixto', } /** * Enumeración de tipos de contrato */ export enum ContractType { LLAVE_EN_MANO = 'llave_en_mano', PRECIO_ALZADO = 'precio_alzado', ADMINISTRACION = 'administracion', MIXTO = 'mixto', } /** * Entidad Project - Representa un proyecto de construcción inmobiliaria * * Un proyecto puede ser: * - Fraccionamiento Horizontal: Viviendas unifamiliares en lotes * - Conjunto Habitacional: Viviendas adosadas o dúplex * - Edificio Vertical: Torre multifamiliar con departamentos * - Mixto: Combinación de tipos * * @schema projects */ @Entity('projects', { schema: 'projects' }) @Index('idx_projects_constructora_status', ['constructoraId', 'status']) @Index('idx_projects_code', ['projectCode']) @Index('idx_projects_dates', ['contractStartDate', 'scheduledEndDate']) @Check('"contract_amount" > 0') @Check('"total_area" > 0') @Check('"buildable_area" > 0') @Check('"buildable_area" <= "total_area"') export class Project { @PrimaryGeneratedColumn('uuid') id: string; /** * Código único del proyecto * Formato: PROJ-{YEAR}-{SEQUENCE} * Ejemplo: PROJ-2025-001 */ @Column({ type: 'varchar', length: 20, unique: true, name: 'project_code', }) projectCode: string; /** * Multi-tenant discriminator (tenant = constructora) * Used for Row-Level Security (RLS) to isolate data between constructoras * See: docs/00-overview/GLOSARIO.md for terminology clarification */ @Column({ type: 'uuid', name: 'constructora_id', }) constructoraId: string; // ==================== INFORMACIÓN BÁSICA ==================== @Column({ type: 'varchar', length: 200, }) name: string; @Column({ type: 'text', nullable: true, }) description: string; @Column({ type: 'enum', enum: ProjectType, name: 'project_type', }) projectType: ProjectType; @Column({ type: 'enum', enum: ProjectStatus, default: ProjectStatus.LICITACION, }) status: ProjectStatus; // ==================== DATOS DEL CLIENTE ==================== @Column({ type: 'enum', enum: ClientType, name: 'client_type', }) clientType: ClientType; @Column({ type: 'varchar', length: 200, name: 'client_name', }) clientName: string; @Column({ type: 'varchar', length: 13, name: 'client_rfc', }) clientRFC: string; @Column({ type: 'varchar', length: 100, nullable: true, name: 'client_contact_name', }) clientContactName: string; @Column({ type: 'varchar', length: 100, nullable: true, name: 'client_contact_email', }) clientContactEmail: string; @Column({ type: 'varchar', length: 20, nullable: true, name: 'client_contact_phone', }) clientContactPhone: string; // ==================== INFORMACIÓN CONTRACTUAL ==================== @Column({ type: 'enum', enum: ContractType, name: 'contract_type', }) contractType: ContractType; @Column({ type: 'decimal', precision: 15, scale: 2, name: 'contract_amount', }) contractAmount: number; // ==================== UBICACIÓN ==================== @Column({ type: 'text' }) address: string; @Column({ type: 'varchar', length: 100, }) state: string; @Column({ type: 'varchar', length: 100, }) municipality: string; @Column({ type: 'varchar', length: 5, name: 'postal_code', }) postalCode: string; @Column({ type: 'decimal', precision: 10, scale: 6, nullable: true, }) latitude: number; @Column({ type: 'decimal', precision: 10, scale: 6, nullable: true, }) longitude: number; /** * Superficie total del terreno en m² */ @Column({ type: 'decimal', precision: 12, scale: 2, name: 'total_area', }) totalArea: number; /** * Superficie construible en m² * Debe ser <= totalArea */ @Column({ type: 'decimal', precision: 12, scale: 2, name: 'buildable_area', }) buildableArea: number; // ==================== FECHAS ==================== @Column({ type: 'date', nullable: true, name: 'bidding_date', }) biddingDate: Date; @Column({ type: 'date', nullable: true, name: 'award_date', }) awardDate: Date; @Column({ type: 'date', name: 'contract_start_date', }) contractStartDate: Date; @Column({ type: 'date', nullable: true, name: 'actual_start_date', }) actualStartDate: Date; /** * Duración contractual en meses */ @Column({ type: 'integer', name: 'contract_duration', }) contractDuration: number; /** * Fecha calculada: contractStartDate + contractDuration */ @Column({ type: 'date', name: 'scheduled_end_date', }) scheduledEndDate: Date; @Column({ type: 'date', nullable: true, name: 'actual_end_date', }) actualEndDate: Date; @Column({ type: 'date', nullable: true, name: 'delivery_date', }) deliveryDate: Date; @Column({ type: 'date', nullable: true, name: 'closure_date', }) closureDate: Date; // ==================== INFORMACIÓN LEGAL ==================== @Column({ type: 'varchar', length: 50, nullable: true, name: 'construction_license_number', }) constructionLicenseNumber: string; @Column({ type: 'date', nullable: true, name: 'license_issue_date', }) licenseIssueDate: Date; @Column({ type: 'date', nullable: true, name: 'license_expiration_date', }) licenseExpirationDate: Date; @Column({ type: 'varchar', length: 50, nullable: true, name: 'environmental_impact_number', }) environmentalImpactNumber: string; @Column({ type: 'varchar', length: 20, nullable: true, name: 'land_use_approved', }) landUseApproved: string; @Column({ type: 'varchar', length: 50, nullable: true, name: 'approved_plan_number', }) approvedPlanNumber: string; @Column({ type: 'varchar', length: 50, nullable: true, name: 'infonavit_number', }) infonavitNumber: string; @Column({ type: 'varchar', length: 50, nullable: true, name: 'fovissste_number', }) fovisssteNumber: string; // ==================== MÉTRICAS CALCULADAS ==================== @Column({ type: 'integer', default: 0, name: 'total_housing_units', }) totalHousingUnits: number; @Column({ type: 'integer', default: 0, name: 'delivered_housing_units', }) deliveredHousingUnits: number; /** * Porcentaje de avance físico (0-100) * Calculado a partir del promedio de avances de stages */ @Column({ type: 'decimal', precision: 5, scale: 2, default: 0, name: 'physical_progress', }) physicalProgress: number; /** * Costo ejercido acumulado */ @Column({ type: 'decimal', precision: 15, scale: 2, default: 0, name: 'exercised_cost', }) exercisedCost: number; /** * Desviación presupuestal en porcentaje * Positivo = sobre presupuesto * Negativo = bajo presupuesto */ @Column({ type: 'decimal', precision: 5, scale: 2, default: 0, name: 'budget_deviation', }) budgetDeviation: number; // ==================== METADATA ==================== @CreateDateColumn({ name: 'created_at' }) createdAt: Date; @UpdateDateColumn({ name: 'updated_at' }) updatedAt: Date; @Column({ type: 'uuid', name: 'created_by', }) createdBy: string; @Column({ type: 'uuid', nullable: true, name: 'updated_by', }) updatedBy: string; // ==================== RELACIONES ==================== @OneToMany(() => Stage, (stage) => stage.project, { cascade: true, }) stages: Stage[]; @OneToMany(() => ProjectTeamAssignment, (assignment) => assignment.project, { cascade: true, }) teamAssignments: ProjectTeamAssignment[]; @OneToMany(() => ProjectDocument, (doc) => doc.project, { cascade: true, }) documents: ProjectDocument[]; } ``` ### 2.2 Stage Entity (Entidad relacionada) **Archivo:** `entities/stage.entity.ts` ```typescript import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn, CreateDateColumn, UpdateDateColumn, Index, } from 'typeorm'; import { Project } from './project.entity'; /** * Entidad Stage - Representa una etapa constructiva dentro de un proyecto * Ejemplo: Etapa 1, Etapa 2, etc. */ @Entity('stages', { schema: 'projects' }) @Index('idx_stages_project', ['projectId']) export class Stage { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'uuid', name: 'project_id' }) projectId: string; @Column({ type: 'varchar', length: 100 }) name: string; @Column({ type: 'text', nullable: true }) description: string; @Column({ type: 'integer' }) order: number; @Column({ type: 'decimal', precision: 5, scale: 2, default: 0, name: 'physical_progress' }) physicalProgress: number; @Column({ type: 'date', nullable: true, name: 'start_date' }) startDate: Date; @Column({ type: 'date', nullable: true, name: 'end_date' }) endDate: Date; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; @UpdateDateColumn({ name: 'updated_at' }) updatedAt: Date; @ManyToOne(() => Project, (project) => project.stages, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'project_id' }) project: Project; } ``` --- ## 3. DTOs (Data Transfer Objects) ### 3.1 CreateProjectDto **Archivo:** `dto/create-project.dto.ts` ```typescript import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsString, IsEnum, IsNotEmpty, MinLength, MaxLength, IsNumber, Min, IsDateString, IsOptional, IsEmail, Matches, IsInt, ValidateIf, IsDecimal, } from 'class-validator'; import { ProjectType, ClientType, ContractType, } from '../entities/project.entity'; export class CreateProjectDto { // ==================== INFORMACIÓN BÁSICA ==================== @ApiProperty({ description: 'Nombre del proyecto', example: 'Fraccionamiento Villas del Sol', minLength: 3, maxLength: 200, }) @IsString() @IsNotEmpty() @MinLength(3, { message: 'El nombre debe tener al menos 3 caracteres' }) @MaxLength(200, { message: 'El nombre no puede exceder 200 caracteres' }) name: string; @ApiPropertyOptional({ description: 'Descripción detallada del proyecto', example: 'Desarrollo de 250 viviendas de interés social en 15 hectáreas', }) @IsString() @IsOptional() description?: string; @ApiProperty({ description: 'Tipo de proyecto inmobiliario', enum: ProjectType, example: ProjectType.FRACCIONAMIENTO_HORIZONTAL, }) @IsEnum(ProjectType, { message: 'Tipo de proyecto inválido. Valores permitidos: fraccionamiento_horizontal, conjunto_habitacional, edificio_vertical, mixto', }) projectType: ProjectType; // ==================== DATOS DEL CLIENTE ==================== @ApiProperty({ description: 'Tipo de cliente', enum: ClientType, example: ClientType.PUBLICO, }) @IsEnum(ClientType, { message: 'Tipo de cliente inválido. Valores permitidos: publico, privado, mixto', }) clientType: ClientType; @ApiProperty({ description: 'Nombre o razón social del cliente', example: 'INFONAVIT Jalisco', minLength: 3, maxLength: 200, }) @IsString() @IsNotEmpty() @MinLength(3) @MaxLength(200) clientName: string; @ApiProperty({ description: 'RFC del cliente (12 o 13 caracteres)', example: 'INF850101ABC', minLength: 12, maxLength: 13, }) @IsString() @IsNotEmpty() @Matches(/^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/, { message: 'RFC inválido. Formato esperado: 3-4 letras + 6 dígitos + 3 caracteres alfanuméricos', }) clientRFC: string; @ApiPropertyOptional({ description: 'Nombre del contacto principal del cliente', example: 'Ing. Roberto Martínez', }) @IsString() @IsOptional() @MaxLength(100) clientContactName?: string; @ApiPropertyOptional({ description: 'Email del contacto del cliente', example: 'rmartinez@infonavit.gob.mx', }) @IsEmail({}, { message: 'Email inválido' }) @IsOptional() clientContactEmail?: string; @ApiPropertyOptional({ description: 'Teléfono del contacto del cliente', example: '+52 33 1234 5678', }) @IsString() @IsOptional() @MaxLength(20) clientContactPhone?: string; // ==================== INFORMACIÓN CONTRACTUAL ==================== @ApiProperty({ description: 'Tipo de contrato', enum: ContractType, example: ContractType.LLAVE_EN_MANO, }) @IsEnum(ContractType, { message: 'Tipo de contrato inválido. Valores permitidos: llave_en_mano, precio_alzado, administracion, mixto', }) contractType: ContractType; @ApiProperty({ description: 'Monto contratado en MXN', example: 125000000.00, minimum: 0.01, }) @IsNumber({ maxDecimalPlaces: 2 }, { message: 'El monto debe tener máximo 2 decimales' }) @Min(0.01, { message: 'El monto contratado debe ser mayor a 0' }) contractAmount: number; @ApiProperty({ description: 'Fecha de inicio contractual (ISO 8601)', example: '2025-06-01', }) @IsDateString({}, { message: 'Fecha de inicio inválida. Formato esperado: YYYY-MM-DD' }) contractStartDate: string; @ApiProperty({ description: 'Duración del contrato en meses', example: 24, minimum: 1, }) @IsInt({ message: 'La duración debe ser un número entero' }) @Min(1, { message: 'La duración debe ser al menos 1 mes' }) contractDuration: number; @ApiPropertyOptional({ description: 'Fecha de licitación (ISO 8601)', example: '2025-03-15', }) @IsDateString({}, { message: 'Fecha de licitación inválida' }) @IsOptional() biddingDate?: string; @ApiPropertyOptional({ description: 'Fecha de adjudicación (ISO 8601)', example: '2025-04-30', }) @IsDateString({}, { message: 'Fecha de adjudicación inválida' }) @IsOptional() awardDate?: string; // ==================== UBICACIÓN ==================== @ApiProperty({ description: 'Dirección completa del proyecto', example: 'Carretera Federal 200 Km 45', minLength: 10, }) @IsString() @IsNotEmpty() @MinLength(10, { message: 'La dirección debe tener al menos 10 caracteres' }) address: string; @ApiProperty({ description: 'Estado de la República Mexicana', example: 'Jalisco', }) @IsString() @IsNotEmpty() @MaxLength(100) state: string; @ApiProperty({ description: 'Municipio', example: 'Zapopan', }) @IsString() @IsNotEmpty() @MaxLength(100) municipality: string; @ApiProperty({ description: 'Código postal (5 dígitos)', example: '45100', }) @IsString() @IsNotEmpty() @Matches(/^\d{5}$/, { message: 'El código postal debe tener 5 dígitos' }) postalCode: string; @ApiPropertyOptional({ description: 'Latitud GPS (grados decimales)', example: 20.6736, minimum: -90, maximum: 90, }) @IsNumber() @Min(-90) @Min(90) @IsOptional() latitude?: number; @ApiPropertyOptional({ description: 'Longitud GPS (grados decimales)', example: -103.3927, minimum: -180, maximum: 180, }) @IsNumber() @Min(-180) @Min(180) @IsOptional() longitude?: number; @ApiProperty({ description: 'Superficie total del terreno en m²', example: 150000.00, minimum: 0.01, }) @IsNumber({ maxDecimalPlaces: 2 }) @Min(0.01, { message: 'La superficie total debe ser mayor a 0' }) totalArea: number; @ApiProperty({ description: 'Superficie construible en m²', example: 120000.00, minimum: 0.01, }) @IsNumber({ maxDecimalPlaces: 2 }) @Min(0.01, { message: 'La superficie construible debe ser mayor a 0' }) buildableArea: number; // ==================== INFORMACIÓN LEGAL ==================== @ApiPropertyOptional({ description: 'Número de licencia de construcción', example: 'LIC-2024-ZPN-0456', }) @IsString() @IsOptional() @MaxLength(50) constructionLicenseNumber?: string; @ApiPropertyOptional({ description: 'Fecha de emisión de la licencia', example: '2025-04-15', }) @IsDateString() @IsOptional() licenseIssueDate?: string; @ApiPropertyOptional({ description: 'Fecha de vencimiento de la licencia', example: '2027-04-14', }) @IsDateString() @IsOptional() licenseExpirationDate?: string; @ApiPropertyOptional({ description: 'Número de manifestación de impacto ambiental', example: 'MIA-2024-045', }) @IsString() @IsOptional() @MaxLength(50) environmentalImpactNumber?: string; @ApiPropertyOptional({ description: 'Uso de suelo aprobado', example: 'H4', }) @IsString() @IsOptional() @MaxLength(20) landUseApproved?: string; @ApiPropertyOptional({ description: 'Número de plano autorizado', example: 'PLANO-ZPN-2024-145', }) @IsString() @IsOptional() @MaxLength(50) approvedPlanNumber?: string; @ApiPropertyOptional({ description: 'Número de registro INFONAVIT', example: 'INF-2024-JL-0123', }) @IsString() @IsOptional() @MaxLength(50) infonavitNumber?: string; @ApiPropertyOptional({ description: 'Número de registro FOVISSSTE', example: 'FOV-2024-JL-0089', }) @IsString() @IsOptional() @MaxLength(50) fovisssteNumber?: string; } ``` ### 3.2 UpdateProjectDto **Archivo:** `dto/update-project.dto.ts` ```typescript import { ApiPropertyOptional, PartialType, OmitType } from '@nestjs/swagger'; import { CreateProjectDto } from './create-project.dto'; import { IsEnum, IsOptional } from 'class-validator'; import { ProjectStatus } from '../entities/project.entity'; /** * DTO para actualización de proyecto * Todos los campos son opcionales excepto el ID (proporcionado en la ruta) * El campo 'status' se maneja por separado en ChangeStatusDto */ export class UpdateProjectDto extends PartialType( OmitType(CreateProjectDto, [] as const), ) { @ApiPropertyOptional({ description: 'Estado del proyecto (usar endpoint /change-status para transiciones controladas)', enum: ProjectStatus, example: ProjectStatus.EJECUCION, }) @IsEnum(ProjectStatus) @IsOptional() status?: ProjectStatus; } ``` ### 3.3 ChangeStatusDto **Archivo:** `dto/change-status.dto.ts` ```typescript import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsNotEmpty, IsDateString, IsOptional } from 'class-validator'; import { ProjectStatus } from '../entities/project.entity'; /** * DTO para cambio de estado del proyecto con validaciones de transición */ export class ChangeStatusDto { @ApiProperty({ description: 'Nuevo estado del proyecto', enum: ProjectStatus, example: ProjectStatus.EJECUCION, }) @IsEnum(ProjectStatus, { message: 'Estado inválido. Valores permitidos: licitacion, adjudicado, ejecucion, entregado, cerrado', }) @IsNotEmpty() newStatus: ProjectStatus; @ApiProperty({ description: 'Fecha efectiva del cambio de estado (ISO 8601). Si no se proporciona, se usa la fecha actual', example: '2025-06-15', required: false, }) @IsDateString() @IsOptional() effectiveDate?: string; } ``` ### 3.4 FilterProjectDto **Archivo:** `dto/filter-project.dto.ts` ```typescript import { ApiPropertyOptional } from '@nestjs/swagger'; import { IsEnum, IsOptional, IsString, IsInt, Min, Max } from 'class-validator'; import { Type } from 'class-transformer'; import { ProjectStatus, ProjectType } from '../entities/project.entity'; /** * DTO para filtrado y paginación de proyectos */ export class FilterProjectDto { @ApiPropertyOptional({ description: 'Filtrar por estado del proyecto', enum: ProjectStatus, example: ProjectStatus.EJECUCION, }) @IsEnum(ProjectStatus) @IsOptional() status?: ProjectStatus; @ApiPropertyOptional({ description: 'Filtrar por tipo de proyecto', enum: ProjectType, example: ProjectType.FRACCIONAMIENTO_HORIZONTAL, }) @IsEnum(ProjectType) @IsOptional() projectType?: ProjectType; @ApiPropertyOptional({ description: 'Buscar por nombre o código de proyecto (búsqueda parcial)', example: 'Villas', }) @IsString() @IsOptional() search?: string; @ApiPropertyOptional({ description: 'Número de página (inicia en 1)', example: 1, minimum: 1, default: 1, }) @Type(() => Number) @IsInt() @Min(1) @IsOptional() page?: number = 1; @ApiPropertyOptional({ description: 'Cantidad de registros por página', example: 20, minimum: 1, maximum: 100, default: 20, }) @Type(() => Number) @IsInt() @Min(1) @Max(100) @IsOptional() limit?: number = 20; } ``` ### 3.5 ProjectResponseDto **Archivo:** `dto/project-response.dto.ts` ```typescript import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Expose, Type } from 'class-transformer'; import { ProjectStatus, ProjectType, ClientType, ContractType } from '../entities/project.entity'; /** * DTO de respuesta para proyecto con datos serializados */ export class ProjectResponseDto { @ApiProperty({ example: 'uuid-generated' }) @Expose() id: string; @ApiProperty({ example: 'PROJ-2025-001' }) @Expose() projectCode: string; @ApiProperty({ example: 'Fraccionamiento Villas del Sol' }) @Expose() name: string; @ApiPropertyOptional({ example: 'Desarrollo de 250 viviendas de interés social' }) @Expose() description?: string; @ApiProperty({ enum: ProjectType }) @Expose() projectType: ProjectType; @ApiProperty({ enum: ProjectStatus }) @Expose() status: ProjectStatus; @ApiProperty({ enum: ClientType }) @Expose() clientType: ClientType; @ApiProperty({ example: 'INFONAVIT Jalisco' }) @Expose() clientName: string; @ApiProperty({ enum: ContractType }) @Expose() contractType: ContractType; @ApiProperty({ example: 125000000.00 }) @Expose() contractAmount: number; @ApiProperty({ example: 'Zapopan, Jalisco' }) @Expose() get location(): string { return `${this['municipality']}, ${this['state']}`; } @ApiProperty({ example: '2025-06-01' }) @Expose() contractStartDate: Date; @ApiProperty({ example: '2027-06-01' }) @Expose() scheduledEndDate: Date; @ApiProperty({ example: 78.5 }) @Expose() physicalProgress: number; @ApiProperty({ example: 250 }) @Expose() totalHousingUnits: number; @ApiProperty({ example: 187 }) @Expose() deliveredHousingUnits: number; @ApiProperty() @Expose() @Type(() => Date) createdAt: Date; @ApiProperty() @Expose() @Type(() => Date) updatedAt: Date; } ``` --- ## 4. Services ### 4.1 ProjectsService **Archivo:** `services/projects.service.ts` ```typescript import { Injectable, BadRequestException, NotFoundException, ConflictException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, FindOptionsWhere } from 'typeorm'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { Project, ProjectStatus, ProjectType } from '../entities/project.entity'; import { CreateProjectDto } from '../dto/create-project.dto'; import { UpdateProjectDto } from '../dto/update-project.dto'; import { FilterProjectDto } from '../dto/filter-project.dto'; import { ChangeStatusDto } from '../dto/change-status.dto'; /** * Servicio de gestión de proyectos * * Responsabilidades: * - CRUD de proyectos con multi-tenancy * - Generación de códigos únicos de proyecto * - Validación de transiciones de estado * - Cálculo de fechas programadas * - Emisión de eventos de dominio */ @Injectable() export class ProjectsService { constructor( @InjectRepository(Project) private readonly projectRepo: Repository, private readonly eventEmitter: EventEmitter2, ) {} /** * Crear nuevo proyecto * * @param dto - Datos del proyecto * @param constructoraId - ID de la constructora (multi-tenant) * @param userId - ID del usuario que crea el proyecto * @returns Proyecto creado con código generado * * @throws BadRequestException si buildableArea > totalArea */ async create( dto: CreateProjectDto, constructoraId: string, userId: string, ): Promise { // Validar que superficie construible no exceda superficie total if (dto.buildableArea > dto.totalArea) { throw new BadRequestException( 'La superficie construible no puede ser mayor a la superficie total', ); } // Generar código único de proyecto const projectCode = await this.generateProjectCode(constructoraId); // Calcular fecha de terminación programada const scheduledEndDate = this.calculateScheduledEndDate( new Date(dto.contractStartDate), dto.contractDuration, ); // Crear entidad const project = this.projectRepo.create({ ...dto, projectCode, constructoraId, scheduledEndDate, status: dto.biddingDate ? ProjectStatus.LICITACION : ProjectStatus.ADJUDICADO, createdBy: userId, }); // Persistir const saved = await this.projectRepo.save(project); // Emitir evento de dominio this.eventEmitter.emit('project.created', { projectId: saved.id, constructoraId: saved.constructoraId, projectCode: saved.projectCode, createdBy: userId, }); return saved; } /** * Listar proyectos con filtros y paginación * * @param constructoraId - ID de la constructora (RLS) * @param filters - Filtros de búsqueda y paginación * @returns Lista paginada de proyectos */ async findAll( constructoraId: string, filters: FilterProjectDto, ): Promise<{ items: Project[]; meta: { page: number; limit: number; totalItems: number; totalPages: number; hasNextPage: boolean; hasPreviousPage: boolean; }; }> { const page = filters.page || 1; const limit = filters.limit || 20; const skip = (page - 1) * limit; // Construir query const query = this.projectRepo .createQueryBuilder('project') .where('project.constructoraId = :constructoraId', { constructoraId }) .orderBy('project.createdAt', 'DESC'); // Aplicar filtros opcionales if (filters.status) { query.andWhere('project.status = :status', { status: filters.status }); } if (filters.projectType) { query.andWhere('project.projectType = :projectType', { projectType: filters.projectType, }); } if (filters.search) { query.andWhere( '(project.name ILIKE :search OR project.projectCode ILIKE :search)', { search: `%${filters.search}%` }, ); } // Ejecutar con paginación const [items, totalItems] = await query .skip(skip) .take(limit) .getManyAndCount(); const totalPages = Math.ceil(totalItems / limit); return { items, meta: { page, limit, totalItems, totalPages, hasNextPage: page < totalPages, hasPreviousPage: page > 1, }, }; } /** * Obtener proyecto por ID con verificación de tenant * * @param id - UUID del proyecto * @param constructoraId - ID de la constructora (RLS) * @param relations - Relaciones a cargar * @returns Proyecto encontrado * * @throws NotFoundException si el proyecto no existe o no pertenece a la constructora */ async findOne( id: string, constructoraId: string, relations: string[] = [], ): Promise { const where: FindOptionsWhere = { id, constructoraId }; const project = await this.projectRepo.findOne({ where, relations, }); if (!project) { throw new NotFoundException( `Proyecto con ID ${id} no encontrado o no pertenece a la constructora`, ); } return project; } /** * Actualizar proyecto * * @param id - UUID del proyecto * @param dto - Datos a actualizar * @param constructoraId - ID de la constructora (RLS) * @param userId - ID del usuario que actualiza * @returns Proyecto actualizado * * @throws NotFoundException si el proyecto no existe * @throws BadRequestException si la transición de estado es inválida */ async update( id: string, dto: UpdateProjectDto, constructoraId: string, userId: string, ): Promise { const project = await this.findOne(id, constructoraId); // Validar transición de estado si se está cambiando if (dto.status && dto.status !== project.status) { this.validateStatusTransition(project.status, dto.status); } // Validar superficie construible const newBuildableArea = dto.buildableArea ?? project.buildableArea; const newTotalArea = dto.totalArea ?? project.totalArea; if (newBuildableArea > newTotalArea) { throw new BadRequestException( 'La superficie construible no puede ser mayor a la superficie total', ); } // Recalcular fecha programada si cambió duración o fecha de inicio if (dto.contractStartDate || dto.contractDuration) { const startDate = dto.contractStartDate ? new Date(dto.contractStartDate) : project.contractStartDate; const duration = dto.contractDuration ?? project.contractDuration; project.scheduledEndDate = this.calculateScheduledEndDate(startDate, duration); } // Aplicar cambios const oldStatus = project.status; Object.assign(project, dto); project.updatedBy = userId; // Persistir const updated = await this.projectRepo.save(project); // Emitir evento si cambió de estado if (dto.status && dto.status !== oldStatus) { this.eventEmitter.emit('project.status.changed', { projectId: updated.id, constructoraId: updated.constructoraId, oldStatus, newStatus: dto.status, changedBy: userId, }); } return updated; } /** * Cambiar estado del proyecto con validaciones y actualización de fechas * * @param id - UUID del proyecto * @param dto - DTO con nuevo estado y fecha efectiva * @param constructoraId - ID de la constructora (RLS) * @param userId - ID del usuario que realiza el cambio * @returns Proyecto con estado actualizado * * @throws BadRequestException si la transición es inválida */ async changeStatus( id: string, dto: ChangeStatusDto, constructoraId: string, userId: string, ): Promise { const project = await this.findOne(id, constructoraId); // Validar transición this.validateStatusTransition(project.status, dto.newStatus); const oldStatus = project.status; project.status = dto.newStatus; project.updatedBy = userId; // Actualizar fechas según el nuevo estado const effectiveDate = dto.effectiveDate ? new Date(dto.effectiveDate) : new Date(); switch (dto.newStatus) { case ProjectStatus.ADJUDICADO: if (!project.awardDate) { project.awardDate = effectiveDate; } break; case ProjectStatus.EJECUCION: if (!project.actualStartDate) { project.actualStartDate = effectiveDate; } break; case ProjectStatus.ENTREGADO: if (!project.actualEndDate) { project.actualEndDate = effectiveDate; } if (!project.deliveryDate) { project.deliveryDate = effectiveDate; } break; case ProjectStatus.CERRADO: if (!project.closureDate) { project.closureDate = effectiveDate; } break; } // Persistir const updated = await this.projectRepo.save(project); // Emitir evento this.eventEmitter.emit('project.status.changed', { projectId: updated.id, constructoraId: updated.constructoraId, oldStatus, newStatus: dto.newStatus, effectiveDate, changedBy: userId, }); return updated; } /** * Eliminar proyecto (soft delete) * Solo permitido si el proyecto está en estado LICITACION * * @param id - UUID del proyecto * @param constructoraId - ID de la constructora (RLS) * @throws BadRequestException si el proyecto no está en LICITACION */ async remove(id: string, constructoraId: string): Promise { const project = await this.findOne(id, constructoraId); if (project.status !== ProjectStatus.LICITACION) { throw new BadRequestException( 'Solo se pueden eliminar proyectos en estado de licitación', ); } await this.projectRepo.remove(project); this.eventEmitter.emit('project.deleted', { projectId: id, constructoraId, }); } // ==================== MÉTODOS PRIVADOS ==================== /** * Generar código único de proyecto * Formato: PROJ-{YEAR}-{SEQUENCE} * Ejemplo: PROJ-2025-001 * * @param constructoraId - ID de la constructora * @returns Código generado */ private async generateProjectCode(constructoraId: string): Promise { const year = new Date().getFullYear(); const prefix = `PROJ-${year}-`; // Obtener último código del año para esta constructora const lastProject = await this.projectRepo .createQueryBuilder('project') .where('project.constructoraId = :constructoraId', { constructoraId }) .andWhere('project.projectCode LIKE :prefix', { prefix: `${prefix}%` }) .orderBy('project.projectCode', 'DESC') .getOne(); let sequence = 1; if (lastProject) { const lastSequence = parseInt(lastProject.projectCode.split('-').pop() || '0'); sequence = lastSequence + 1; } return `${prefix}${sequence.toString().padStart(3, '0')}`; } /** * Calcular fecha de terminación programada * * @param startDate - Fecha de inicio * @param durationMonths - Duración en meses * @returns Fecha calculada */ private calculateScheduledEndDate(startDate: Date, durationMonths: number): Date { const endDate = new Date(startDate); endDate.setMonth(endDate.getMonth() + durationMonths); return endDate; } /** * Validar transición de estado según reglas de negocio * * Flujo válido: * LICITACION → ADJUDICADO → EJECUCION → ENTREGADO → CERRADO * * @param currentStatus - Estado actual * @param newStatus - Nuevo estado * @throws BadRequestException si la transición no es válida */ private validateStatusTransition( currentStatus: ProjectStatus, newStatus: ProjectStatus, ): void { const validTransitions: Record = { [ProjectStatus.LICITACION]: [ProjectStatus.ADJUDICADO], [ProjectStatus.ADJUDICADO]: [ProjectStatus.EJECUCION], [ProjectStatus.EJECUCION]: [ProjectStatus.ENTREGADO], [ProjectStatus.ENTREGADO]: [ProjectStatus.CERRADO], [ProjectStatus.CERRADO]: [], // Estado final, no hay transiciones }; const allowedTransitions = validTransitions[currentStatus]; if (!allowedTransitions.includes(newStatus)) { throw new BadRequestException( `No se puede cambiar de estado "${currentStatus}" a "${newStatus}". ` + `Transiciones permitidas: ${allowedTransitions.join(', ') || 'ninguna (estado final)'}`, ); } } } ``` ### 4.2 ProjectMetricsService **Archivo:** `services/project-metrics.service.ts` ```typescript import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Project } from '../entities/project.entity'; /** * Servicio de cálculo de métricas del proyecto */ @Injectable() export class ProjectMetricsService { constructor( @InjectRepository(Project) private readonly projectRepo: Repository, ) {} /** * Calcular todas las métricas del proyecto * * @param project - Proyecto a analizar * @returns Objeto con métricas físicas, financieras y temporales */ async calculateMetrics(project: Project): Promise<{ physical: { progress: number; totalUnits: number; delivered: number; pending: number; deliveryRate: number; }; financial: { budget: number; exercised: number; available: number; progress: number; deviation: number; deviationStatus: 'green' | 'yellow' | 'red'; }; temporal: { contractDuration: number; elapsedMonths: number; remainingMonths: number; scheduledEnd: Date; actualEnd: Date | null; deviation: number; deviationStatus: 'green' | 'yellow' | 'red'; }; }> { // Calcular avance físico (promedio de stages) const physicalProgress = await this.calculatePhysicalProgress(project.id); // Calcular avance financiero const financialProgress = (project.exercisedCost / project.contractAmount) * 100; // Calcular desviación presupuestal const budgetDeviation = this.calculateBudgetDeviation( project.contractAmount, project.exercisedCost, ); // Calcular desviación temporal const temporalDeviation = this.calculateTemporalDeviation(project, physicalProgress); // Actualizar métricas en BD project.physicalProgress = physicalProgress; project.budgetDeviation = budgetDeviation; await this.projectRepo.save(project); return { physical: { progress: physicalProgress, totalUnits: project.totalHousingUnits, delivered: project.deliveredHousingUnits, pending: project.totalHousingUnits - project.deliveredHousingUnits, deliveryRate: project.totalHousingUnits > 0 ? (project.deliveredHousingUnits / project.totalHousingUnits) * 100 : 0, }, financial: { budget: project.contractAmount, exercised: project.exercisedCost, available: project.contractAmount - project.exercisedCost, progress: financialProgress, deviation: budgetDeviation, deviationStatus: this.getDeviationStatus(budgetDeviation), }, temporal: { contractDuration: project.contractDuration, elapsedMonths: this.calculateElapsedMonths(project), remainingMonths: this.calculateRemainingMonths(project), scheduledEnd: project.scheduledEndDate, actualEnd: project.actualEndDate, deviation: temporalDeviation, deviationStatus: this.getDeviationStatus(temporalDeviation), }, }; } /** * Calcular avance físico promedio del proyecto * Basado en el promedio de avances de todas las etapas */ private async calculatePhysicalProgress(projectId: string): Promise { const result = await this.projectRepo.query( ` SELECT COALESCE(AVG(s.physical_progress), 0) as avg_progress FROM projects.stages s WHERE s.project_id = $1 `, [projectId], ); return parseFloat(result[0]?.avg_progress || '0'); } /** * Calcular desviación presupuestal en porcentaje */ private calculateBudgetDeviation(budget: number, exercised: number): number { if (budget === 0) return 0; return ((exercised - budget) / budget) * 100; } /** * Calcular desviación temporal comparando avance real vs programado */ private calculateTemporalDeviation( project: Project, physicalProgress: number, ): number { const now = new Date(); const totalDays = (project.scheduledEndDate.getTime() - project.contractStartDate.getTime()) / (1000 * 60 * 60 * 24); const elapsedDays = (now.getTime() - project.contractStartDate.getTime()) / (1000 * 60 * 60 * 24); const expectedProgress = (elapsedDays / totalDays) * 100; return physicalProgress - expectedProgress; // Positivo = adelantado, Negativo = atrasado } /** * Calcular meses transcurridos desde el inicio */ private calculateElapsedMonths(project: Project): number { const now = new Date(); const start = project.actualStartDate || project.contractStartDate; const months = (now.getFullYear() - start.getFullYear()) * 12 + (now.getMonth() - start.getMonth()); return Math.max(0, months); } /** * Calcular meses restantes hasta la fecha programada */ private calculateRemainingMonths(project: Project): number { const now = new Date(); const months = (project.scheduledEndDate.getFullYear() - now.getFullYear()) * 12 + (project.scheduledEndDate.getMonth() - now.getMonth()); return Math.max(0, months); } /** * Determinar status de semáforo según desviación * Verde: ±5% * Amarillo: ±5% a ±15% * Rojo: > ±15% */ private getDeviationStatus(deviation: number): 'green' | 'yellow' | 'red' { const abs = Math.abs(deviation); if (abs <= 5) return 'green'; if (abs <= 15) return 'yellow'; return 'red'; } } ``` --- ## 5. Controllers ### 5.1 ProjectsController **Archivo:** `controllers/projects.controller.ts` ```typescript import { Controller, Get, Post, Patch, Delete, Param, Body, Query, UseGuards, Request, HttpCode, HttpStatus, ParseUUIDPipe, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam, ApiQuery, } from '@nestjs/swagger'; import { ProjectsService } from '../services/projects.service'; import { ProjectMetricsService } from '../services/project-metrics.service'; import { CreateProjectDto } from '../dto/create-project.dto'; import { UpdateProjectDto } from '../dto/update-project.dto'; import { FilterProjectDto } from '../dto/filter-project.dto'; import { ChangeStatusDto } from '../dto/change-status.dto'; import { ProjectResponseDto } from '../dto/project-response.dto'; import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../../auth/guards/roles.guard'; import { Roles } from '../../auth/decorators/roles.decorator'; import { Constructora } from '../decorators/constructora.decorator'; /** * Controlador de proyectos * * Endpoints: * - POST /projects - Crear proyecto * - GET /projects - Listar proyectos con filtros * - GET /projects/:id - Obtener proyecto por ID * - PATCH /projects/:id - Actualizar proyecto * - DELETE /projects/:id - Eliminar proyecto (solo LICITACION) * - POST /projects/:id/status - Cambiar estado del proyecto * - GET /projects/:id/metrics - Obtener métricas del proyecto */ @ApiTags('Projects') @ApiBearerAuth() @Controller('projects') @UseGuards(JwtAuthGuard, RolesGuard) export class ProjectsController { constructor( private readonly projectsService: ProjectsService, private readonly metricsService: ProjectMetricsService, ) {} /** * Crear nuevo proyecto * * Roles permitidos: director, engineer */ @Post() @Roles('director', 'engineer') @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: 'Crear nuevo proyecto', description: 'Crea un nuevo proyecto de construcción con código autogenerado. ' + 'El proyecto inicia en estado LICITACION si tiene biddingDate, ' + 'o ADJUDICADO si no la tiene.', }) @ApiResponse({ status: HttpStatus.CREATED, description: 'Proyecto creado exitosamente', type: ProjectResponseDto, }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Datos inválidos (validación falló)', }) @ApiResponse({ status: HttpStatus.UNAUTHORIZED, description: 'No autenticado', }) @ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'No autorizado (rol insuficiente)', }) async create( @Body() dto: CreateProjectDto, @Constructora() constructoraId: string, @Request() req: any, ): Promise { const project = await this.projectsService.create( dto, constructoraId, req.user.userId, ); return project; } /** * Listar proyectos con filtros y paginación * * Roles permitidos: todos */ @Get() @Roles('director', 'engineer', 'resident', 'purchases', 'finance', 'hr') @ApiOperation({ summary: 'Listar proyectos', description: 'Lista todos los proyectos de la constructora con filtros opcionales ' + 'y paginación. Soporta búsqueda por nombre/código.', }) @ApiResponse({ status: HttpStatus.OK, description: 'Lista de proyectos obtenida exitosamente', type: [ProjectResponseDto], }) async findAll( @Constructora() constructoraId: string, @Query() filters: FilterProjectDto, ) { return this.projectsService.findAll(constructoraId, filters); } /** * Obtener proyecto por ID * * Roles permitidos: todos */ @Get(':id') @Roles('director', 'engineer', 'resident', 'purchases', 'finance', 'hr') @ApiOperation({ summary: 'Obtener proyecto por ID', description: 'Obtiene los detalles completos de un proyecto incluyendo ' + 'relaciones (stages, teamAssignments, documents)', }) @ApiParam({ name: 'id', description: 'UUID del proyecto', example: '123e4567-e89b-12d3-a456-426614174000', }) @ApiResponse({ status: HttpStatus.OK, description: 'Proyecto encontrado', type: ProjectResponseDto, }) @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'Proyecto no encontrado o no pertenece a la constructora', }) async findOne( @Param('id', ParseUUIDPipe) id: string, @Constructora() constructoraId: string, ): Promise { return this.projectsService.findOne(id, constructoraId, [ 'stages', 'teamAssignments', 'documents', ]); } /** * Actualizar proyecto * * Roles permitidos: director, engineer */ @Patch(':id') @Roles('director', 'engineer') @ApiOperation({ summary: 'Actualizar proyecto', description: 'Actualiza los datos del proyecto. Para cambiar el estado, ' + 'usar el endpoint POST /projects/:id/status', }) @ApiParam({ name: 'id', description: 'UUID del proyecto', }) @ApiResponse({ status: HttpStatus.OK, description: 'Proyecto actualizado exitosamente', type: ProjectResponseDto, }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Datos inválidos', }) @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'Proyecto no encontrado', }) async update( @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateProjectDto, @Constructora() constructoraId: string, @Request() req: any, ): Promise { return this.projectsService.update( id, dto, constructoraId, req.user.userId, ); } /** * Eliminar proyecto * Solo permitido si está en estado LICITACION * * Roles permitidos: director */ @Delete(':id') @Roles('director') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Eliminar proyecto', description: 'Elimina un proyecto. Solo permitido si el proyecto está en estado LICITACION.', }) @ApiParam({ name: 'id', description: 'UUID del proyecto', }) @ApiResponse({ status: HttpStatus.NO_CONTENT, description: 'Proyecto eliminado exitosamente', }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'No se puede eliminar el proyecto (estado diferente a LICITACION)', }) @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'Proyecto no encontrado', }) async remove( @Param('id', ParseUUIDPipe) id: string, @Constructora() constructoraId: string, ): Promise { await this.projectsService.remove(id, constructoraId); } /** * Cambiar estado del proyecto con validaciones * * Roles permitidos: director, engineer, resident */ @Post(':id/status') @Roles('director', 'engineer', 'resident') @ApiOperation({ summary: 'Cambiar estado del proyecto', description: 'Cambia el estado del proyecto validando transiciones permitidas. ' + 'Actualiza automáticamente las fechas correspondientes según el nuevo estado.', }) @ApiParam({ name: 'id', description: 'UUID del proyecto', }) @ApiResponse({ status: HttpStatus.OK, description: 'Estado cambiado exitosamente', type: ProjectResponseDto, }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Transición de estado inválida', }) async changeStatus( @Param('id', ParseUUIDPipe) id: string, @Body() dto: ChangeStatusDto, @Constructora() constructoraId: string, @Request() req: any, ): Promise { return this.projectsService.changeStatus( id, dto, constructoraId, req.user.userId, ); } /** * Obtener métricas del proyecto * * Roles permitidos: todos */ @Get(':id/metrics') @Roles('director', 'engineer', 'resident', 'purchases', 'finance', 'hr') @ApiOperation({ summary: 'Obtener métricas del proyecto', description: 'Calcula y retorna métricas físicas, financieras y temporales ' + 'del proyecto en tiempo real.', }) @ApiParam({ name: 'id', description: 'UUID del proyecto', }) @ApiResponse({ status: HttpStatus.OK, description: 'Métricas calculadas exitosamente', schema: { example: { physical: { progress: 78.5, totalUnits: 250, delivered: 187, pending: 63, deliveryRate: 74.8, }, financial: { budget: 125000000, exercised: 97125000, available: 27875000, progress: 77.7, deviation: 2.5, deviationStatus: 'yellow', }, temporal: { contractDuration: 24, elapsedMonths: 18, remainingMonths: 6, scheduledEnd: '2026-05-15', actualEnd: null, deviation: -1.5, deviationStatus: 'green', }, }, }, }) async getMetrics( @Param('id', ParseUUIDPipe) id: string, @Constructora() constructoraId: string, ) { const project = await this.projectsService.findOne(id, constructoraId); return this.metricsService.calculateMetrics(project); } } ``` --- ## 6. Guards y Decoradores ### 6.1 Decorador Custom: Constructora **Archivo:** `decorators/constructora.decorator.ts` ```typescript import { createParamDecorator, ExecutionContext } from '@nestjs/common'; /** * Decorador custom para extraer el constructoraId del usuario autenticado * Uso: @Constructora() constructoraId: string */ export const Constructora = createParamDecorator( (data: unknown, ctx: ExecutionContext): string => { const request = ctx.switchToHttp().getRequest(); return request.user?.constructoraId; }, ); ``` ### 6.2 Guard: ProjectAccessGuard **Archivo:** `guards/project-access.guard.ts` ```typescript import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'; import { ProjectsService } from '../services/projects.service'; /** * Guard para verificar que el usuario tenga acceso al proyecto * Valida que el proyecto pertenezca a la constructora del usuario */ @Injectable() export class ProjectAccessGuard implements CanActivate { constructor(private readonly projectsService: ProjectsService) {} async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); const projectId = request.params.id; const constructoraId = request.user?.constructoraId; if (!projectId || !constructoraId) { throw new ForbiddenException('Acceso denegado'); } try { await this.projectsService.findOne(projectId, constructoraId); return true; } catch (error) { throw new ForbiddenException('No tienes acceso a este proyecto'); } } } ``` --- ## 7. Event Listeners ### 7.1 ProjectStatusListener **Archivo:** `listeners/project-status.listener.ts` ```typescript import { Injectable, Logger } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; /** * Listener de eventos de cambio de estado de proyecto */ @Injectable() export class ProjectStatusListener { private readonly logger = new Logger(ProjectStatusListener.name); @OnEvent('project.created') handleProjectCreated(payload: any) { this.logger.log( `Proyecto creado: ${payload.projectCode} (ID: ${payload.projectId}) ` + `por usuario ${payload.createdBy}`, ); // Aquí se pueden agregar acciones adicionales: // - Enviar email de notificación // - Crear registros de auditoría // - Inicializar configuraciones por defecto } @OnEvent('project.status.changed') handleStatusChanged(payload: any) { this.logger.log( `Proyecto ${payload.projectId} cambió de estado: ` + `${payload.oldStatus} → ${payload.newStatus}`, ); // Acciones según el nuevo estado: // - EJECUCION: Notificar al equipo de obra // - ENTREGADO: Generar reporte de cierre // - CERRADO: Archivar documentos } @OnEvent('project.deleted') handleProjectDeleted(payload: any) { this.logger.warn(`Proyecto eliminado: ${payload.projectId}`); // Limpiar datos relacionados si es necesario } } ``` --- ## 8. Module Configuration ### 8.1 ProjectsModule **Archivo:** `projects.module.ts` ```typescript import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ProjectsController } from './controllers/projects.controller'; import { ProjectsService } from './services/projects.service'; import { ProjectMetricsService } from './services/project-metrics.service'; import { Project } from './entities/project.entity'; import { Stage } from './entities/stage.entity'; import { ProjectTeamAssignment } from './entities/project-team-assignment.entity'; import { ProjectDocument } from './entities/project-document.entity'; import { ProjectStatusListener } from './listeners/project-status.listener'; @Module({ imports: [ TypeOrmModule.forFeature([ Project, Stage, ProjectTeamAssignment, ProjectDocument, ]), ], controllers: [ProjectsController], providers: [ ProjectsService, ProjectMetricsService, ProjectStatusListener, ], exports: [ProjectsService, ProjectMetricsService], }) export class ProjectsModule {} ``` --- ## 9. Migraciones TypeORM ### 9.1 Migración: CreateProjectsTable **Archivo:** `migrations/1701234567890-CreateProjectsTable.ts` ```typescript import { MigrationInterface, QueryRunner, Table, TableIndex, TableCheck } from 'typeorm'; export class CreateProjectsTable1701234567890 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { // Crear schema si no existe await queryRunner.query(`CREATE SCHEMA IF NOT EXISTS projects`); // Crear tabla projects await queryRunner.createTable( new Table({ name: 'projects', schema: 'projects', columns: [ { name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()', }, { name: 'project_code', type: 'varchar', length: '20', isUnique: true, }, { name: 'constructora_id', type: 'uuid', }, { name: 'name', type: 'varchar', length: '200', }, { name: 'description', type: 'text', isNullable: true, }, { name: 'project_type', type: 'enum', enum: ['fraccionamiento_horizontal', 'conjunto_habitacional', 'edificio_vertical', 'mixto'], }, { name: 'status', type: 'enum', enum: ['licitacion', 'adjudicado', 'ejecucion', 'entregado', 'cerrado'], default: "'licitacion'", }, { name: 'client_type', type: 'enum', enum: ['publico', 'privado', 'mixto'], }, { name: 'client_name', type: 'varchar', length: '200', }, { name: 'client_rfc', type: 'varchar', length: '13', }, { name: 'client_contact_name', type: 'varchar', length: '100', isNullable: true, }, { name: 'client_contact_email', type: 'varchar', length: '100', isNullable: true, }, { name: 'client_contact_phone', type: 'varchar', length: '20', isNullable: true, }, { name: 'contract_type', type: 'enum', enum: ['llave_en_mano', 'precio_alzado', 'administracion', 'mixto'], }, { name: 'contract_amount', type: 'decimal', precision: 15, scale: 2, }, { name: 'address', type: 'text', }, { name: 'state', type: 'varchar', length: '100', }, { name: 'municipality', type: 'varchar', length: '100', }, { name: 'postal_code', type: 'varchar', length: '5', }, { name: 'latitude', type: 'decimal', precision: 10, scale: 6, isNullable: true, }, { name: 'longitude', type: 'decimal', precision: 10, scale: 6, isNullable: true, }, { name: 'total_area', type: 'decimal', precision: 12, scale: 2, }, { name: 'buildable_area', type: 'decimal', precision: 12, scale: 2, }, { name: 'bidding_date', type: 'date', isNullable: true, }, { name: 'award_date', type: 'date', isNullable: true, }, { name: 'contract_start_date', type: 'date', }, { name: 'actual_start_date', type: 'date', isNullable: true, }, { name: 'contract_duration', type: 'integer', }, { name: 'scheduled_end_date', type: 'date', }, { name: 'actual_end_date', type: 'date', isNullable: true, }, { name: 'delivery_date', type: 'date', isNullable: true, }, { name: 'closure_date', type: 'date', isNullable: true, }, { name: 'construction_license_number', type: 'varchar', length: '50', isNullable: true, }, { name: 'license_issue_date', type: 'date', isNullable: true, }, { name: 'license_expiration_date', type: 'date', isNullable: true, }, { name: 'environmental_impact_number', type: 'varchar', length: '50', isNullable: true, }, { name: 'land_use_approved', type: 'varchar', length: '20', isNullable: true, }, { name: 'approved_plan_number', type: 'varchar', length: '50', isNullable: true, }, { name: 'infonavit_number', type: 'varchar', length: '50', isNullable: true, }, { name: 'fovissste_number', type: 'varchar', length: '50', isNullable: true, }, { name: 'total_housing_units', type: 'integer', default: 0, }, { name: 'delivered_housing_units', type: 'integer', default: 0, }, { name: 'physical_progress', type: 'decimal', precision: 5, scale: 2, default: 0, }, { name: 'exercised_cost', type: 'decimal', precision: 15, scale: 2, default: 0, }, { name: 'budget_deviation', type: 'decimal', precision: 5, scale: 2, default: 0, }, { name: 'created_at', type: 'timestamp', default: 'CURRENT_TIMESTAMP', }, { name: 'updated_at', type: 'timestamp', default: 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP', }, { name: 'created_by', type: 'uuid', }, { name: 'updated_by', type: 'uuid', isNullable: true, }, ], }), true, ); // Crear índices await queryRunner.createIndex( 'projects.projects', new TableIndex({ name: 'idx_projects_constructora_status', columnNames: ['constructora_id', 'status'], }), ); await queryRunner.createIndex( 'projects.projects', new TableIndex({ name: 'idx_projects_code', columnNames: ['project_code'], }), ); await queryRunner.createIndex( 'projects.projects', new TableIndex({ name: 'idx_projects_dates', columnNames: ['contract_start_date', 'scheduled_end_date'], }), ); // Crear constraints await queryRunner.createCheckConstraint( 'projects.projects', new TableCheck({ name: 'chk_contract_amount_positive', expression: 'contract_amount > 0', }), ); await queryRunner.createCheckConstraint( 'projects.projects', new TableCheck({ name: 'chk_total_area_positive', expression: 'total_area > 0', }), ); await queryRunner.createCheckConstraint( 'projects.projects', new TableCheck({ name: 'chk_buildable_area_valid', expression: 'buildable_area > 0 AND buildable_area <= total_area', }), ); // Crear policy de Row Level Security (RLS) await queryRunner.query(` ALTER TABLE projects.projects ENABLE ROW LEVEL SECURITY; `); await queryRunner.query(` CREATE POLICY project_isolation ON projects.projects USING (constructora_id = current_setting('app.current_constructora_id')::uuid); `); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropTable('projects.projects', true); await queryRunner.query(`DROP SCHEMA IF EXISTS projects CASCADE`); } } ``` --- ## 10. Tests Unitarios ### 10.1 ProjectsService Tests **Archivo:** `__tests__/projects.service.spec.ts` ```typescript import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { ProjectsService } from '../services/projects.service'; import { Project, ProjectStatus } from '../entities/project.entity'; import { BadRequestException, NotFoundException } from '@nestjs/common'; describe('ProjectsService', () => { let service: ProjectsService; let repository: Repository; let eventEmitter: EventEmitter2; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ ProjectsService, { provide: getRepositoryToken(Project), useClass: Repository, }, { provide: EventEmitter2, useValue: { emit: jest.fn(), }, }, ], }).compile(); service = module.get(ProjectsService); repository = module.get>(getRepositoryToken(Project)); eventEmitter = module.get(EventEmitter2); }); describe('generateProjectCode', () => { it('should generate unique project code with format PROJ-YYYY-XXX', async () => { jest.spyOn(repository, 'createQueryBuilder').mockReturnValue({ where: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(), getOne: jest.fn().mockResolvedValue(null), } as any); const code = await service['generateProjectCode']('constructora-uuid'); expect(code).toMatch(/^PROJ-\d{4}-\d{3}$/); expect(code).toContain(new Date().getFullYear().toString()); }); it('should increment sequence when existing project found', async () => { const lastProject = { projectCode: 'PROJ-2025-005' } as Project; jest.spyOn(repository, 'createQueryBuilder').mockReturnValue({ where: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(), getOne: jest.fn().mockResolvedValue(lastProject), } as any); const code = await service['generateProjectCode']('constructora-uuid'); expect(code).toBe('PROJ-2025-006'); }); }); describe('validateStatusTransition', () => { it('should allow valid transition: LICITACION → ADJUDICADO', () => { expect(() => service['validateStatusTransition']( ProjectStatus.LICITACION, ProjectStatus.ADJUDICADO, ), ).not.toThrow(); }); it('should reject invalid transition: LICITACION → EJECUCION', () => { expect(() => service['validateStatusTransition']( ProjectStatus.LICITACION, ProjectStatus.EJECUCION, ), ).toThrow(BadRequestException); }); it('should reject transition from CERRADO', () => { expect(() => service['validateStatusTransition']( ProjectStatus.CERRADO, ProjectStatus.EJECUCION, ), ).toThrow(BadRequestException); }); }); describe('calculateScheduledEndDate', () => { it('should calculate end date correctly', () => { const startDate = new Date('2025-06-01'); const duration = 24; // meses const endDate = service['calculateScheduledEndDate'](startDate, duration); expect(endDate).toEqual(new Date('2027-06-01')); }); }); describe('create', () => { it('should create project with auto-generated code', async () => { const createDto = { name: 'Test Project', contractStartDate: '2025-06-01', contractDuration: 24, totalArea: 100000, buildableArea: 80000, } as any; const savedProject = { id: 'uuid', projectCode: 'PROJ-2025-001', ...createDto, } as Project; jest.spyOn(service as any, 'generateProjectCode').mockResolvedValue('PROJ-2025-001'); jest.spyOn(repository, 'create').mockReturnValue(savedProject); jest.spyOn(repository, 'save').mockResolvedValue(savedProject); const result = await service.create(createDto, 'constructora-id', 'user-id'); expect(result.projectCode).toBe('PROJ-2025-001'); expect(eventEmitter.emit).toHaveBeenCalledWith('project.created', expect.any(Object)); }); it('should reject if buildableArea > totalArea', async () => { const createDto = { totalArea: 100000, buildableArea: 150000, } as any; await expect( service.create(createDto, 'constructora-id', 'user-id'), ).rejects.toThrow(BadRequestException); }); }); }); ``` --- ## 11. Configuración de Swagger ### 11.1 Swagger Tags y Metadata ```typescript // main.ts import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; const config = new DocumentBuilder() .setTitle('ERP Construcción - API') .setDescription('API REST para gestión de proyectos de construcción inmobiliaria') .setVersion('1.0') .addTag('Projects', 'Gestión de proyectos de construcción') .addBearerAuth() .build(); const document = SwaggerModule.createDocument(app, config); SwaggerModule.setup('api/docs', app, document); ``` --- ## 12. Validaciones Custom ### 12.1 Validador: IsAfter **Archivo:** `validators/is-after.validator.ts` ```typescript import { registerDecorator, ValidationOptions, ValidationArguments, } from 'class-validator'; /** * Decorador personalizado para validar que una fecha sea posterior a otra */ export function IsAfter(property: string, validationOptions?: ValidationOptions) { return function (object: Object, propertyName: string) { registerDecorator({ name: 'isAfter', target: object.constructor, propertyName: propertyName, constraints: [property], options: validationOptions, validator: { validate(value: any, args: ValidationArguments) { const [relatedPropertyName] = args.constraints; const relatedValue = (args.object as any)[relatedPropertyName]; return ( typeof value === 'string' && typeof relatedValue === 'string' && new Date(value) > new Date(relatedValue) ); }, defaultMessage(args: ValidationArguments) { const [relatedPropertyName] = args.constraints; return `${propertyName} debe ser posterior a ${relatedPropertyName}`; }, }, }); }; } ``` --- **Fecha de creación:** 2025-12-06 **Versión:** 1.0 **Autor:** Claude Opus 4.5