workspace-v1/projects/erp-construccion/docs/02-definicion-modulos/MAI-002-proyectos-estructura/especificaciones/ET-PROJ-001-backend.md
rckrdmrd 66161b1566 feat: Workspace-v1 complete migration with NEXUS v3.4
Sistema NEXUS v3.4 migrado con:

Estructura principal:
- core/orchestration: Sistema SIMCO + CAPVED (27 directivas, 28 perfiles)
- core/catalog: Catalogo de funcionalidades reutilizables
- shared/knowledge-base: Base de conocimiento compartida
- devtools/scripts: Herramientas de desarrollo
- control-plane/registries: Control de servicios y CI/CD
- orchestration/: Configuracion de orquestacion de agentes

Proyectos incluidos (11):
- gamilit (submodule -> GitHub)
- trading-platform (OrbiquanTIA)
- erp-suite con 5 verticales:
  - erp-core, construccion, vidrio-templado
  - mecanicas-diesel, retail, clinicas
- betting-analytics
- inmobiliaria-analytics
- platform_marketing_content
- pos-micro, erp-basico

Configuracion:
- .gitignore completo para Node.js/Python/Docker
- gamilit como submodule (git@github.com:rckrdmrd/gamilit-workspace.git)
- Sistema de puertos estandarizado (3005-3199)

Generated with NEXUS v3.4 Migration System
EPIC-010: Configuracion Git y Repositorios
2026-01-04 03:37:42 -06:00

71 KiB

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// 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

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