erp-construccion/docs/02-definicion-modulos/MAI-002-proyectos-estructura/especificaciones/ET-PROJ-001-backend.md

2881 lines
71 KiB
Markdown

# 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<Project>,
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<Project> {
// 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<Project> {
const where: FindOptionsWhere<Project> = { 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<Project> {
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<Project> {
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<void> {
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<string> {
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, ProjectStatus[]> = {
[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<Project>,
) {}
/**
* 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<number> {
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<ProjectResponseDto> {
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<ProjectResponseDto> {
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<ProjectResponseDto> {
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<void> {
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<ProjectResponseDto> {
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<boolean> {
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<void> {
// 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<void> {
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<Project>;
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>(ProjectsService);
repository = module.get<Repository<Project>>(getRepositoryToken(Project));
eventEmitter = module.get<EventEmitter2>(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