2881 lines
71 KiB
Markdown
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
|