89 KiB
ET-PROJ-002: Implementación de Estructura Jerárquica de Obra
Épica: MAI-002 - Proyectos y Estructura de Obra Requerimiento base: RF-PROJ-002 Prioridad: P0 (Crítica) Estimación: 8 SP Versión: 1.0 Fecha: 2025-11-17
1. Resumen Ejecutivo
Esta especificación técnica implementa la estructura jerárquica de obra de 5 niveles:
- Proyecto → Etapa → Manzana (opcional) → Lote → Vivienda
Soporta tres tipos de estructuras:
- Fraccionamiento Horizontal: Usa todos los niveles (incluye manzanas)
- Conjunto Habitacional: Sin manzanas (etapa → lotes directamente)
- Edificio Vertical: Etapas representan torres/edificios, manzanas son niveles/pisos
2. Arquitectura de Base de Datos
2.1 Schema SQL
-- Schema: projects
CREATE SCHEMA IF NOT EXISTS projects;
-- =====================================================
-- TABLA: projects.stages (Etapas)
-- =====================================================
CREATE TABLE projects.stages (
-- Identificación
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE,
-- Multi-tenant discriminator (inherited from project)
-- tenant = constructora in this system (see GLOSARIO.md)
constructora_id UUID NOT NULL,
-- Información básica
code VARCHAR(20) NOT NULL,
name VARCHAR(200) NOT NULL,
description TEXT,
stage_number INTEGER NOT NULL,
-- Métricas
total_blocks INTEGER DEFAULT 0,
total_lots INTEGER DEFAULT 0,
total_housing_units INTEGER DEFAULT 0,
total_area_sqm DECIMAL(12, 2),
-- Fechas
planned_start_date DATE,
planned_end_date DATE,
actual_start_date DATE,
actual_end_date DATE,
-- Estado
status VARCHAR(50) NOT NULL DEFAULT 'planeada',
-- Estados: planeada | en_proceso | pausada | terminada | entregada
-- Ubicación (opcional para etapas físicamente separadas)
latitude DECIMAL(10, 6),
longitude DECIMAL(10, 6),
polygon_coordinates JSONB,
-- Avance
physical_progress DECIMAL(5, 2) DEFAULT 0.00,
-- Auditoría
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by UUID,
updated_by UUID,
CONSTRAINT unique_project_stage_code UNIQUE (project_id, code),
CONSTRAINT unique_project_stage_number UNIQUE (project_id, stage_number)
);
CREATE INDEX idx_stages_project ON projects.stages(project_id);
CREATE INDEX idx_stages_constructora ON projects.stages(constructora_id);
CREATE INDEX idx_stages_status ON projects.stages(status);
-- =====================================================
-- TABLA: projects.blocks (Manzanas)
-- =====================================================
CREATE TABLE projects.blocks (
-- Identificación
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
stage_id UUID NOT NULL REFERENCES projects.stages(id) ON DELETE CASCADE,
project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE,
-- Multi-tenant discriminator (inherited from project)
-- tenant = constructora in this system (see GLOSARIO.md)
constructora_id UUID NOT NULL,
-- Información básica
code VARCHAR(20) NOT NULL,
name VARCHAR(200) NOT NULL,
description TEXT,
block_number INTEGER NOT NULL,
-- Métricas
total_lots INTEGER DEFAULT 0,
total_area_sqm DECIMAL(12, 2),
buildable_area_sqm DECIMAL(12, 2),
green_area_sqm DECIMAL(12, 2),
-- Infraestructura
has_water BOOLEAN DEFAULT false,
has_drainage BOOLEAN DEFAULT false,
has_electricity BOOLEAN DEFAULT false,
has_public_lighting BOOLEAN DEFAULT false,
has_paving BOOLEAN DEFAULT false,
has_sidewalks BOOLEAN DEFAULT false,
infrastructure_notes TEXT,
-- Estado
status VARCHAR(50) NOT NULL DEFAULT 'planeada',
-- Estados: planeada | urbanizacion | terminada | entregada
-- Ubicación
latitude DECIMAL(10, 6),
longitude DECIMAL(10, 6),
polygon_coordinates JSONB,
-- Avance
infrastructure_progress DECIMAL(5, 2) DEFAULT 0.00,
-- Auditoría
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by UUID,
updated_by UUID,
CONSTRAINT unique_stage_block_code UNIQUE (stage_id, code),
CONSTRAINT unique_stage_block_number UNIQUE (stage_id, block_number)
);
CREATE INDEX idx_blocks_stage ON projects.blocks(stage_id);
CREATE INDEX idx_blocks_project ON projects.blocks(project_id);
CREATE INDEX idx_blocks_constructora ON projects.blocks(constructora_id);
CREATE INDEX idx_blocks_status ON projects.blocks(status);
-- =====================================================
-- TABLA: projects.lots (Lotes)
-- =====================================================
CREATE TABLE projects.lots (
-- Identificación
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
block_id UUID REFERENCES projects.blocks(id) ON DELETE CASCADE,
stage_id UUID NOT NULL REFERENCES projects.stages(id) ON DELETE CASCADE,
project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE,
-- Multi-tenant discriminator (inherited from project)
-- tenant = constructora in this system (see GLOSARIO.md)
constructora_id UUID NOT NULL,
-- Información básica
code VARCHAR(20) NOT NULL,
lot_number INTEGER NOT NULL,
-- Dimensiones
area_sqm DECIMAL(10, 2) NOT NULL,
front_meters DECIMAL(6, 2),
depth_meters DECIMAL(6, 2),
-- Forma y orientación
shape VARCHAR(50),
-- Valores: rectangular | irregular | esquina | cul_de_sac
orientation VARCHAR(50),
-- Valores: norte | sur | este | oeste | noreste | noroeste | sureste | suroeste
-- Prototipo asignado
prototype_id UUID REFERENCES projects.housing_prototypes(id),
prototype_version INTEGER,
-- Topografía
is_flat BOOLEAN DEFAULT true,
slope_percentage DECIMAL(5, 2),
topography_notes TEXT,
-- Estado
status VARCHAR(50) NOT NULL DEFAULT 'disponible',
-- Estados: disponible | reservado | vendido | en_construccion | terminado | entregado
-- Venta (opcional)
sale_price DECIMAL(15, 2),
sale_date DATE,
buyer_name VARCHAR(200),
-- Ubicación
latitude DECIMAL(10, 6),
longitude DECIMAL(10, 6),
polygon_coordinates JSONB,
-- Auditoría
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by UUID,
updated_by UUID,
CONSTRAINT unique_stage_lot_code UNIQUE (stage_id, code),
CONSTRAINT check_block_or_stage CHECK (
(block_id IS NOT NULL) OR (block_id IS NULL AND stage_id IS NOT NULL)
)
);
CREATE INDEX idx_lots_block ON projects.lots(block_id);
CREATE INDEX idx_lots_stage ON projects.lots(stage_id);
CREATE INDEX idx_lots_project ON projects.lots(project_id);
CREATE INDEX idx_lots_constructora ON projects.lots(constructora_id);
CREATE INDEX idx_lots_status ON projects.lots(status);
CREATE INDEX idx_lots_prototype ON projects.lots(prototype_id);
-- =====================================================
-- TABLA: projects.housing_units (Viviendas)
-- =====================================================
CREATE TABLE projects.housing_units (
-- Identificación
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
lot_id UUID NOT NULL REFERENCES projects.lots(id) ON DELETE CASCADE,
block_id UUID REFERENCES projects.blocks(id) ON DELETE CASCADE,
stage_id UUID NOT NULL REFERENCES projects.stages(id) ON DELETE CASCADE,
project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE,
-- Multi-tenant discriminator (inherited from project)
-- tenant = constructora in this system (see GLOSARIO.md)
constructora_id UUID NOT NULL,
-- Información básica
code VARCHAR(20) NOT NULL,
unit_number INTEGER NOT NULL,
-- Prototipo (heredado del lote al momento de creación)
prototype_id UUID REFERENCES projects.housing_prototypes(id),
prototype_name VARCHAR(200),
prototype_version INTEGER,
-- Características (heredadas del prototipo pero editables)
housing_type VARCHAR(50),
-- Valores: casa_unifamiliar | departamento | duplex | triplex
levels INTEGER DEFAULT 1,
bedrooms INTEGER,
bathrooms DECIMAL(3, 1),
parking_spaces INTEGER,
-- Áreas
land_area_sqm DECIMAL(10, 2),
built_area_level_1 DECIMAL(10, 2),
built_area_level_2 DECIMAL(10, 2),
total_built_area DECIMAL(10, 2),
-- Acabados
floor_finish VARCHAR(100),
wall_finish VARCHAR(100),
kitchen_type VARCHAR(100),
bathroom_finish VARCHAR(100),
-- Estado de construcción
construction_status VARCHAR(50) NOT NULL DEFAULT 'no_iniciada',
-- Estados: no_iniciada | cimentacion | estructura | muros | instalaciones | acabados | terminada | entregada
-- Avance por etapa constructiva
foundation_progress DECIMAL(5, 2) DEFAULT 0.00,
structure_progress DECIMAL(5, 2) DEFAULT 0.00,
walls_progress DECIMAL(5, 2) DEFAULT 0.00,
installations_progress DECIMAL(5, 2) DEFAULT 0.00,
finishes_progress DECIMAL(5, 2) DEFAULT 0.00,
-- Avance global
physical_progress DECIMAL(5, 2) DEFAULT 0.00,
-- Fechas de construcción
construction_start_date DATE,
planned_completion_date DATE,
actual_completion_date DATE,
delivery_date DATE,
-- Costos
estimated_cost DECIMAL(15, 2),
actual_cost DECIMAL(15, 2),
-- Observaciones
notes TEXT,
quality_issues TEXT,
-- Auditoría
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by UUID,
updated_by UUID,
CONSTRAINT unique_project_unit_code UNIQUE (project_id, code)
);
CREATE INDEX idx_housing_units_lot ON projects.housing_units(lot_id);
CREATE INDEX idx_housing_units_block ON projects.housing_units(block_id);
CREATE INDEX idx_housing_units_stage ON projects.housing_units(stage_id);
CREATE INDEX idx_housing_units_project ON projects.housing_units(project_id);
CREATE INDEX idx_housing_units_constructora ON projects.housing_units(constructora_id);
CREATE INDEX idx_housing_units_status ON projects.housing_units(construction_status);
CREATE INDEX idx_housing_units_prototype ON projects.housing_units(prototype_id);
-- =====================================================
-- TRIGGERS para actualizar métricas automáticamente
-- =====================================================
-- Trigger: Actualizar total_lots en Stage cuando se crea/elimina un Lot
CREATE OR REPLACE FUNCTION update_stage_lot_count()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE projects.stages
SET total_lots = total_lots + 1
WHERE id = NEW.stage_id;
ELSIF TG_OP = 'DELETE' THEN
UPDATE projects.stages
SET total_lots = total_lots - 1
WHERE id = OLD.stage_id;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_stage_lot_count
AFTER INSERT OR DELETE ON projects.lots
FOR EACH ROW EXECUTE FUNCTION update_stage_lot_count();
-- Trigger: Actualizar total_blocks en Stage
CREATE OR REPLACE FUNCTION update_stage_block_count()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE projects.stages
SET total_blocks = total_blocks + 1
WHERE id = NEW.stage_id;
ELSIF TG_OP = 'DELETE' THEN
UPDATE projects.stages
SET total_blocks = total_blocks - 1
WHERE id = OLD.stage_id;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_stage_block_count
AFTER INSERT OR DELETE ON projects.blocks
FOR EACH ROW EXECUTE FUNCTION update_stage_block_count();
-- Trigger: Actualizar total_housing_units en Stage
CREATE OR REPLACE FUNCTION update_stage_housing_count()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE projects.stages
SET total_housing_units = total_housing_units + 1
WHERE id = NEW.stage_id;
ELSIF TG_OP = 'DELETE' THEN
UPDATE projects.stages
SET total_housing_units = total_housing_units - 1
WHERE id = OLD.stage_id;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_stage_housing_count
AFTER INSERT OR DELETE ON projects.housing_units
FOR EACH ROW EXECUTE FUNCTION update_stage_housing_count();
-- =====================================================
-- RLS (Row Level Security)
-- =====================================================
ALTER TABLE projects.stages ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects.blocks ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects.lots ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects.housing_units ENABLE ROW LEVEL SECURITY;
-- Políticas para stages
CREATE POLICY stages_isolation ON projects.stages
USING (constructora_id = current_setting('app.current_constructora_id')::UUID);
-- Políticas para blocks
CREATE POLICY blocks_isolation ON projects.blocks
USING (constructora_id = current_setting('app.current_constructora_id')::UUID);
-- Políticas para lots
CREATE POLICY lots_isolation ON projects.lots
USING (constructora_id = current_setting('app.current_constructora_id')::UUID);
-- Políticas para housing_units
CREATE POLICY housing_units_isolation ON projects.housing_units
USING (constructora_id = current_setting('app.current_constructora_id')::UUID);
3. Implementación Backend (NestJS)
3.1 Entities
Stage Entity
// apps/backend/src/modules/projects/entities/stage.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
OneToMany,
JoinColumn,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
import { Project } from './project.entity';
import { Block } from './block.entity';
import { Lot } from './lot.entity';
import { HousingUnit } from './housing-unit.entity';
export enum StageStatus {
PLANEADA = 'planeada',
EN_PROCESO = 'en_proceso',
PAUSADA = 'pausada',
TERMINADA = 'terminada',
ENTREGADA = 'entregada',
}
@Entity('stages', { schema: 'projects' })
@Index(['projectId', 'code'], { unique: true })
@Index(['projectId', 'stageNumber'], { unique: true })
@Index(['constructoraId'])
@Index(['status'])
export class Stage {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
projectId: string;
@Column({ type: 'uuid' })
constructoraId: string;
// Información básica
@Column({ type: 'varchar', length: 20 })
code: string;
@Column({ type: 'varchar', length: 200 })
name: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ type: 'integer' })
stageNumber: number;
// Métricas
@Column({ type: 'integer', default: 0 })
totalBlocks: number;
@Column({ type: 'integer', default: 0 })
totalLots: number;
@Column({ type: 'integer', default: 0 })
totalHousingUnits: number;
@Column({ type: 'decimal', precision: 12, scale: 2, nullable: true })
totalAreaSqm: number;
// Fechas
@Column({ type: 'date', nullable: true })
plannedStartDate: Date;
@Column({ type: 'date', nullable: true })
plannedEndDate: Date;
@Column({ type: 'date', nullable: true })
actualStartDate: Date;
@Column({ type: 'date', nullable: true })
actualEndDate: Date;
// Estado
@Column({ type: 'enum', enum: StageStatus, default: StageStatus.PLANEADA })
status: StageStatus;
// Ubicación
@Column({ type: 'decimal', precision: 10, scale: 6, nullable: true })
latitude: number;
@Column({ type: 'decimal', precision: 10, scale: 6, nullable: true })
longitude: number;
@Column({ type: 'jsonb', nullable: true })
polygonCoordinates: object;
// Avance
@Column({ type: 'decimal', precision: 5, scale: 2, default: 0.0 })
physicalProgress: number;
// Auditoría
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@Column({ type: 'uuid', nullable: true })
createdBy: string;
@Column({ type: 'uuid', nullable: true })
updatedBy: string;
// Relaciones
@ManyToOne(() => Project, (project) => project.stages)
@JoinColumn({ name: 'project_id' })
project: Project;
@OneToMany(() => Block, (block) => block.stage, { cascade: true })
blocks: Block[];
@OneToMany(() => Lot, (lot) => lot.stage, { cascade: true })
lots: Lot[];
@OneToMany(() => HousingUnit, (unit) => unit.stage, { cascade: true })
housingUnits: HousingUnit[];
}
Block Entity
// apps/backend/src/modules/projects/entities/block.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
OneToMany,
JoinColumn,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
import { Stage } from './stage.entity';
import { Project } from './project.entity';
import { Lot } from './lot.entity';
import { HousingUnit } from './housing-unit.entity';
export enum BlockStatus {
PLANEADA = 'planeada',
URBANIZACION = 'urbanizacion',
TERMINADA = 'terminada',
ENTREGADA = 'entregada',
}
@Entity('blocks', { schema: 'projects' })
@Index(['stageId', 'code'], { unique: true })
@Index(['stageId', 'blockNumber'], { unique: true })
@Index(['constructoraId'])
@Index(['status'])
export class Block {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
stageId: string;
@Column({ type: 'uuid' })
projectId: string;
@Column({ type: 'uuid' })
constructoraId: string;
// Información básica
@Column({ type: 'varchar', length: 20 })
code: string;
@Column({ type: 'varchar', length: 200 })
name: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ type: 'integer' })
blockNumber: number;
// Métricas
@Column({ type: 'integer', default: 0 })
totalLots: number;
@Column({ type: 'decimal', precision: 12, scale: 2, nullable: true })
totalAreaSqm: number;
@Column({ type: 'decimal', precision: 12, scale: 2, nullable: true })
buildableAreaSqm: number;
@Column({ type: 'decimal', precision: 12, scale: 2, nullable: true })
greenAreaSqm: number;
// Infraestructura
@Column({ type: 'boolean', default: false })
hasWater: boolean;
@Column({ type: 'boolean', default: false })
hasDrainage: boolean;
@Column({ type: 'boolean', default: false })
hasElectricity: boolean;
@Column({ type: 'boolean', default: false })
hasPublicLighting: boolean;
@Column({ type: 'boolean', default: false })
hasPaving: boolean;
@Column({ type: 'boolean', default: false })
hasSidewalks: boolean;
@Column({ type: 'text', nullable: true })
infrastructureNotes: string;
// Estado
@Column({ type: 'enum', enum: BlockStatus, default: BlockStatus.PLANEADA })
status: BlockStatus;
// Ubicación
@Column({ type: 'decimal', precision: 10, scale: 6, nullable: true })
latitude: number;
@Column({ type: 'decimal', precision: 10, scale: 6, nullable: true })
longitude: number;
@Column({ type: 'jsonb', nullable: true })
polygonCoordinates: object;
// Avance
@Column({ type: 'decimal', precision: 5, scale: 2, default: 0.0 })
infrastructureProgress: number;
// Auditoría
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@Column({ type: 'uuid', nullable: true })
createdBy: string;
@Column({ type: 'uuid', nullable: true })
updatedBy: string;
// Relaciones
@ManyToOne(() => Stage, (stage) => stage.blocks)
@JoinColumn({ name: 'stage_id' })
stage: Stage;
@ManyToOne(() => Project, (project) => project.stages)
@JoinColumn({ name: 'project_id' })
project: Project;
@OneToMany(() => Lot, (lot) => lot.block, { cascade: true })
lots: Lot[];
@OneToMany(() => HousingUnit, (unit) => unit.block)
housingUnits: HousingUnit[];
}
Lot Entity
// apps/backend/src/modules/projects/entities/lot.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
OneToMany,
JoinColumn,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
import { Stage } from './stage.entity';
import { Block } from './block.entity';
import { Project } from './project.entity';
import { HousingUnit } from './housing-unit.entity';
import { HousingPrototype } from './housing-prototype.entity';
export enum LotStatus {
DISPONIBLE = 'disponible',
RESERVADO = 'reservado',
VENDIDO = 'vendido',
EN_CONSTRUCCION = 'en_construccion',
TERMINADO = 'terminado',
ENTREGADO = 'entregado',
}
export enum LotShape {
RECTANGULAR = 'rectangular',
IRREGULAR = 'irregular',
ESQUINA = 'esquina',
CUL_DE_SAC = 'cul_de_sac',
}
export enum LotOrientation {
NORTE = 'norte',
SUR = 'sur',
ESTE = 'este',
OESTE = 'oeste',
NORESTE = 'noreste',
NOROESTE = 'noroeste',
SURESTE = 'sureste',
SUROESTE = 'suroeste',
}
@Entity('lots', { schema: 'projects' })
@Index(['stageId', 'code'], { unique: true })
@Index(['constructoraId'])
@Index(['status'])
@Index(['prototypeId'])
export class Lot {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: true })
blockId: string;
@Column({ type: 'uuid' })
stageId: string;
@Column({ type: 'uuid' })
projectId: string;
@Column({ type: 'uuid' })
constructoraId: string;
// Información básica
@Column({ type: 'varchar', length: 20 })
code: string;
@Column({ type: 'integer' })
lotNumber: number;
// Dimensiones
@Column({ type: 'decimal', precision: 10, scale: 2 })
areaSqm: number;
@Column({ type: 'decimal', precision: 6, scale: 2, nullable: true })
frontMeters: number;
@Column({ type: 'decimal', precision: 6, scale: 2, nullable: true })
depthMeters: number;
// Forma y orientación
@Column({ type: 'enum', enum: LotShape, nullable: true })
shape: LotShape;
@Column({ type: 'enum', enum: LotOrientation, nullable: true })
orientation: LotOrientation;
// Prototipo asignado
@Column({ type: 'uuid', nullable: true })
prototypeId: string;
@Column({ type: 'integer', nullable: true })
prototypeVersion: number;
// Topografía
@Column({ type: 'boolean', default: true })
isFlat: boolean;
@Column({ type: 'decimal', precision: 5, scale: 2, nullable: true })
slopePercentage: number;
@Column({ type: 'text', nullable: true })
topographyNotes: string;
// Estado
@Column({ type: 'enum', enum: LotStatus, default: LotStatus.DISPONIBLE })
status: LotStatus;
// Venta
@Column({ type: 'decimal', precision: 15, scale: 2, nullable: true })
salePrice: number;
@Column({ type: 'date', nullable: true })
saleDate: Date;
@Column({ type: 'varchar', length: 200, nullable: true })
buyerName: string;
// Ubicación
@Column({ type: 'decimal', precision: 10, scale: 6, nullable: true })
latitude: number;
@Column({ type: 'decimal', precision: 10, scale: 6, nullable: true })
longitude: number;
@Column({ type: 'jsonb', nullable: true })
polygonCoordinates: object;
// Auditoría
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@Column({ type: 'uuid', nullable: true })
createdBy: string;
@Column({ type: 'uuid', nullable: true })
updatedBy: string;
// Relaciones
@ManyToOne(() => Block, (block) => block.lots, { nullable: true })
@JoinColumn({ name: 'block_id' })
block: Block;
@ManyToOne(() => Stage, (stage) => stage.lots)
@JoinColumn({ name: 'stage_id' })
stage: Stage;
@ManyToOne(() => Project)
@JoinColumn({ name: 'project_id' })
project: Project;
@ManyToOne(() => HousingPrototype, { nullable: true })
@JoinColumn({ name: 'prototype_id' })
prototype: HousingPrototype;
@OneToMany(() => HousingUnit, (unit) => unit.lot)
housingUnits: HousingUnit[];
}
HousingUnit Entity
// apps/backend/src/modules/projects/entities/housing-unit.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
import { Lot } from './lot.entity';
import { Block } from './block.entity';
import { Stage } from './stage.entity';
import { Project } from './project.entity';
import { HousingPrototype } from './housing-prototype.entity';
export enum ConstructionStatus {
NO_INICIADA = 'no_iniciada',
CIMENTACION = 'cimentacion',
ESTRUCTURA = 'estructura',
MUROS = 'muros',
INSTALACIONES = 'instalaciones',
ACABADOS = 'acabados',
TERMINADA = 'terminada',
ENTREGADA = 'entregada',
}
export enum HousingType {
CASA_UNIFAMILIAR = 'casa_unifamiliar',
DEPARTAMENTO = 'departamento',
DUPLEX = 'duplex',
TRIPLEX = 'triplex',
}
@Entity('housing_units', { schema: 'projects' })
@Index(['projectId', 'code'], { unique: true })
@Index(['constructoraId'])
@Index(['constructionStatus'])
@Index(['prototypeId'])
export class HousingUnit {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
lotId: string;
@Column({ type: 'uuid', nullable: true })
blockId: string;
@Column({ type: 'uuid' })
stageId: string;
@Column({ type: 'uuid' })
projectId: string;
@Column({ type: 'uuid' })
constructoraId: string;
// Información básica
@Column({ type: 'varchar', length: 20 })
code: string;
@Column({ type: 'integer' })
unitNumber: number;
// Prototipo (snapshot al momento de creación)
@Column({ type: 'uuid', nullable: true })
prototypeId: string;
@Column({ type: 'varchar', length: 200, nullable: true })
prototypeName: string;
@Column({ type: 'integer', nullable: true })
prototypeVersion: number;
// Características
@Column({ type: 'enum', enum: HousingType, nullable: true })
housingType: HousingType;
@Column({ type: 'integer', default: 1 })
levels: number;
@Column({ type: 'integer', nullable: true })
bedrooms: number;
@Column({ type: 'decimal', precision: 3, scale: 1, nullable: true })
bathrooms: number;
@Column({ type: 'integer', nullable: true })
parkingSpaces: number;
// Áreas
@Column({ type: 'decimal', precision: 10, scale: 2, nullable: true })
landAreaSqm: number;
@Column({ type: 'decimal', precision: 10, scale: 2, nullable: true })
builtAreaLevel1: number;
@Column({ type: 'decimal', precision: 10, scale: 2, nullable: true })
builtAreaLevel2: number;
@Column({ type: 'decimal', precision: 10, scale: 2, nullable: true })
totalBuiltArea: number;
// Acabados
@Column({ type: 'varchar', length: 100, nullable: true })
floorFinish: string;
@Column({ type: 'varchar', length: 100, nullable: true })
wallFinish: string;
@Column({ type: 'varchar', length: 100, nullable: true })
kitchenType: string;
@Column({ type: 'varchar', length: 100, nullable: true })
bathroomFinish: string;
// Estado de construcción
@Column({ type: 'enum', enum: ConstructionStatus, default: ConstructionStatus.NO_INICIADA })
constructionStatus: ConstructionStatus;
// Avance por etapa constructiva
@Column({ type: 'decimal', precision: 5, scale: 2, default: 0.0 })
foundationProgress: number;
@Column({ type: 'decimal', precision: 5, scale: 2, default: 0.0 })
structureProgress: number;
@Column({ type: 'decimal', precision: 5, scale: 2, default: 0.0 })
wallsProgress: number;
@Column({ type: 'decimal', precision: 5, scale: 2, default: 0.0 })
installationsProgress: number;
@Column({ type: 'decimal', precision: 5, scale: 2, default: 0.0 })
finishesProgress: number;
// Avance global (calculado)
@Column({ type: 'decimal', precision: 5, scale: 2, default: 0.0 })
physicalProgress: number;
// Fechas de construcción
@Column({ type: 'date', nullable: true })
constructionStartDate: Date;
@Column({ type: 'date', nullable: true })
plannedCompletionDate: Date;
@Column({ type: 'date', nullable: true })
actualCompletionDate: Date;
@Column({ type: 'date', nullable: true })
deliveryDate: Date;
// Costos
@Column({ type: 'decimal', precision: 15, scale: 2, nullable: true })
estimatedCost: number;
@Column({ type: 'decimal', precision: 15, scale: 2, nullable: true })
actualCost: number;
// Observaciones
@Column({ type: 'text', nullable: true })
notes: string;
@Column({ type: 'text', nullable: true })
qualityIssues: string;
// Auditoría
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@Column({ type: 'uuid', nullable: true })
createdBy: string;
@Column({ type: 'uuid', nullable: true })
updatedBy: string;
// Relaciones
@ManyToOne(() => Lot, (lot) => lot.housingUnits)
@JoinColumn({ name: 'lot_id' })
lot: Lot;
@ManyToOne(() => Block, (block) => block.housingUnits, { nullable: true })
@JoinColumn({ name: 'block_id' })
block: Block;
@ManyToOne(() => Stage, (stage) => stage.housingUnits)
@JoinColumn({ name: 'stage_id' })
stage: Stage;
@ManyToOne(() => Project)
@JoinColumn({ name: 'project_id' })
project: Project;
@ManyToOne(() => HousingPrototype, { nullable: true })
@JoinColumn({ name: 'prototype_id' })
prototype: HousingPrototype;
}
3.2 DTOs
// apps/backend/src/modules/projects/dto/create-stage.dto.ts
import { IsString, IsUUID, IsInt, IsOptional, IsEnum, IsDateString, IsNumber } from 'class-validator';
import { StageStatus } from '../entities/stage.entity';
export class CreateStageDto {
@IsString()
code: string;
@IsString()
name: string;
@IsOptional()
@IsString()
description?: string;
@IsInt()
stageNumber: number;
@IsOptional()
@IsNumber()
totalAreaSqm?: number;
@IsOptional()
@IsDateString()
plannedStartDate?: string;
@IsOptional()
@IsDateString()
plannedEndDate?: string;
@IsOptional()
@IsEnum(StageStatus)
status?: StageStatus;
@IsOptional()
@IsNumber()
latitude?: number;
@IsOptional()
@IsNumber()
longitude?: number;
@IsOptional()
polygonCoordinates?: object;
}
// apps/backend/src/modules/projects/dto/create-block.dto.ts
import { IsString, IsInt, IsOptional, IsEnum, IsBoolean, IsNumber } from 'class-validator';
import { BlockStatus } from '../entities/block.entity';
export class CreateBlockDto {
@IsString()
code: string;
@IsString()
name: string;
@IsOptional()
@IsString()
description?: string;
@IsInt()
blockNumber: number;
@IsOptional()
@IsNumber()
totalAreaSqm?: number;
@IsOptional()
@IsNumber()
buildableAreaSqm?: number;
@IsOptional()
@IsNumber()
greenAreaSqm?: number;
@IsOptional()
@IsBoolean()
hasWater?: boolean;
@IsOptional()
@IsBoolean()
hasDrainage?: boolean;
@IsOptional()
@IsBoolean()
hasElectricity?: boolean;
@IsOptional()
@IsBoolean()
hasPublicLighting?: boolean;
@IsOptional()
@IsBoolean()
hasPaving?: boolean;
@IsOptional()
@IsBoolean()
hasSidewalks?: boolean;
@IsOptional()
@IsString()
infrastructureNotes?: string;
@IsOptional()
@IsEnum(BlockStatus)
status?: BlockStatus;
@IsOptional()
@IsNumber()
latitude?: number;
@IsOptional()
@IsNumber()
longitude?: number;
@IsOptional()
polygonCoordinates?: object;
}
// apps/backend/src/modules/projects/dto/create-lot.dto.ts
import { IsString, IsInt, IsUUID, IsOptional, IsEnum, IsBoolean, IsNumber, IsDateString } from 'class-validator';
import { LotStatus, LotShape, LotOrientation } from '../entities/lot.entity';
export class CreateLotDto {
@IsOptional()
@IsUUID()
blockId?: string;
@IsString()
code: string;
@IsInt()
lotNumber: number;
@IsNumber()
areaSqm: number;
@IsOptional()
@IsNumber()
frontMeters?: number;
@IsOptional()
@IsNumber()
depthMeters?: number;
@IsOptional()
@IsEnum(LotShape)
shape?: LotShape;
@IsOptional()
@IsEnum(LotOrientation)
orientation?: LotOrientation;
@IsOptional()
@IsUUID()
prototypeId?: string;
@IsOptional()
@IsInt()
prototypeVersion?: number;
@IsOptional()
@IsBoolean()
isFlat?: boolean;
@IsOptional()
@IsNumber()
slopePercentage?: number;
@IsOptional()
@IsString()
topographyNotes?: string;
@IsOptional()
@IsEnum(LotStatus)
status?: LotStatus;
@IsOptional()
@IsNumber()
salePrice?: number;
@IsOptional()
@IsDateString()
saleDate?: string;
@IsOptional()
@IsString()
buyerName?: string;
@IsOptional()
@IsNumber()
latitude?: number;
@IsOptional()
@IsNumber()
longitude?: number;
@IsOptional()
polygonCoordinates?: object;
}
// apps/backend/src/modules/projects/dto/bulk-create-lots.dto.ts
import { IsInt, IsOptional, IsNumber, IsEnum, IsUUID } from 'class-validator';
import { LotShape, LotOrientation } from '../entities/lot.entity';
export class BulkCreateLotsDto {
@IsOptional()
@IsUUID()
blockId?: string;
@IsInt()
quantity: number;
@IsString()
codePrefix: string; // ej: "LOTE-"
@IsInt()
startNumber: number; // ej: 1
@IsNumber()
areaSqm: number;
@IsOptional()
@IsNumber()
frontMeters?: number;
@IsOptional()
@IsNumber()
depthMeters?: number;
@IsOptional()
@IsEnum(LotShape)
shape?: LotShape;
@IsOptional()
@IsEnum(LotOrientation)
orientation?: LotOrientation;
@IsOptional()
@IsUUID()
prototypeId?: string;
@IsOptional()
@IsInt()
prototypeVersion?: number;
}
// apps/backend/src/modules/projects/dto/create-housing-unit.dto.ts
import { IsString, IsInt, IsUUID, IsOptional, IsEnum, IsNumber, IsDateString } from 'class-validator';
import { ConstructionStatus, HousingType } from '../entities/housing-unit.entity';
export class CreateHousingUnitDto {
@IsUUID()
lotId: string;
@IsString()
code: string;
@IsInt()
unitNumber: number;
@IsOptional()
@IsUUID()
prototypeId?: string;
@IsOptional()
@IsString()
prototypeName?: string;
@IsOptional()
@IsInt()
prototypeVersion?: number;
@IsOptional()
@IsEnum(HousingType)
housingType?: HousingType;
@IsOptional()
@IsInt()
levels?: number;
@IsOptional()
@IsInt()
bedrooms?: number;
@IsOptional()
@IsNumber()
bathrooms?: number;
@IsOptional()
@IsInt()
parkingSpaces?: number;
@IsOptional()
@IsNumber()
landAreaSqm?: number;
@IsOptional()
@IsNumber()
builtAreaLevel1?: number;
@IsOptional()
@IsNumber()
builtAreaLevel2?: number;
@IsOptional()
@IsNumber()
totalBuiltArea?: number;
@IsOptional()
@IsString()
floorFinish?: string;
@IsOptional()
@IsString()
wallFinish?: string;
@IsOptional()
@IsString()
kitchenType?: string;
@IsOptional()
@IsString()
bathroomFinish?: string;
@IsOptional()
@IsEnum(ConstructionStatus)
constructionStatus?: ConstructionStatus;
@IsOptional()
@IsDateString()
constructionStartDate?: string;
@IsOptional()
@IsDateString()
plannedCompletionDate?: string;
@IsOptional()
@IsNumber()
estimatedCost?: number;
@IsOptional()
@IsString()
notes?: string;
}
3.3 Services
StagesService
// apps/backend/src/modules/projects/services/stages.service.ts
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Stage, StageStatus } from '../entities/stage.entity';
import { CreateStageDto } from '../dto/create-stage.dto';
import { UpdateStageDto } from '../dto/update-stage.dto';
import { EventEmitter2 } from '@nestjs/event-emitter';
@Injectable()
export class StagesService {
constructor(
@InjectRepository(Stage)
private stageRepo: Repository<Stage>,
private eventEmitter: EventEmitter2,
) {}
async create(
projectId: string,
dto: CreateStageDto,
constructoraId: string,
userId: string,
): Promise<Stage> {
const stage = this.stageRepo.create({
...dto,
projectId,
constructoraId,
createdBy: userId,
});
const saved = await this.stageRepo.save(stage);
this.eventEmitter.emit('stage.created', saved);
return saved;
}
async findAll(projectId: string, constructoraId: string): Promise<Stage[]> {
return this.stageRepo.find({
where: { projectId, constructoraId },
relations: ['blocks', 'lots'],
order: { stageNumber: 'ASC' },
});
}
async findOne(id: string, constructoraId: string): Promise<Stage> {
const stage = await this.stageRepo.findOne({
where: { id, constructoraId },
relations: ['blocks', 'lots', 'housingUnits'],
});
if (!stage) {
throw new NotFoundException(`Stage con ID ${id} no encontrada`);
}
return stage;
}
async update(
id: string,
dto: UpdateStageDto,
constructoraId: string,
userId: string,
): Promise<Stage> {
const stage = await this.findOne(id, constructoraId);
Object.assign(stage, dto);
stage.updatedBy = userId;
return this.stageRepo.save(stage);
}
async changeStatus(
id: string,
newStatus: StageStatus,
constructoraId: string,
userId: string,
): Promise<Stage> {
const stage = await this.findOne(id, constructoraId);
const oldStatus = stage.status;
stage.status = newStatus;
stage.updatedBy = userId;
// Auto-update dates based on status
if (newStatus === StageStatus.EN_PROCESO && !stage.actualStartDate) {
stage.actualStartDate = new Date();
}
if (newStatus === StageStatus.TERMINADA && !stage.actualEndDate) {
stage.actualEndDate = new Date();
}
const updated = await this.stageRepo.save(stage);
this.eventEmitter.emit('stage.status_changed', { stage: updated, oldStatus, newStatus });
return updated;
}
async getTreeStructure(projectId: string, constructoraId: string): Promise<any> {
const stages = await this.stageRepo.find({
where: { projectId, constructoraId },
relations: ['blocks', 'blocks.lots', 'blocks.lots.housingUnits', 'lots', 'lots.housingUnits'],
order: { stageNumber: 'ASC' },
});
return stages.map((stage) => ({
id: stage.id,
code: stage.code,
name: stage.name,
stageNumber: stage.stageNumber,
status: stage.status,
totalBlocks: stage.totalBlocks,
totalLots: stage.totalLots,
totalHousingUnits: stage.totalHousingUnits,
physicalProgress: stage.physicalProgress,
blocks: stage.blocks.map((block) => ({
id: block.id,
code: block.code,
name: block.name,
blockNumber: block.blockNumber,
status: block.status,
totalLots: block.totalLots,
infrastructureProgress: block.infrastructureProgress,
lots: block.lots.map((lot) => ({
id: lot.id,
code: lot.code,
lotNumber: lot.lotNumber,
areaSqm: lot.areaSqm,
status: lot.status,
prototypeId: lot.prototypeId,
housingUnits: lot.housingUnits.map((unit) => ({
id: unit.id,
code: unit.code,
constructionStatus: unit.constructionStatus,
physicalProgress: unit.physicalProgress,
})),
})),
})),
// Lots directly under stage (for projects without blocks)
lots: stage.lots
.filter((lot) => !lot.blockId)
.map((lot) => ({
id: lot.id,
code: lot.code,
lotNumber: lot.lotNumber,
areaSqm: lot.areaSqm,
status: lot.status,
prototypeId: lot.prototypeId,
housingUnits: lot.housingUnits.map((unit) => ({
id: unit.id,
code: unit.code,
constructionStatus: unit.constructionStatus,
physicalProgress: unit.physicalProgress,
})),
})),
}));
}
async remove(id: string, constructoraId: string): Promise<void> {
const stage = await this.findOne(id, constructoraId);
await this.stageRepo.remove(stage);
this.eventEmitter.emit('stage.deleted', { id });
}
}
LotsService (with bulk creation)
// apps/backend/src/modules/projects/services/lots.service.ts
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Lot, LotStatus } from '../entities/lot.entity';
import { CreateLotDto } from '../dto/create-lot.dto';
import { BulkCreateLotsDto } from '../dto/bulk-create-lots.dto';
import { UpdateLotDto } from '../dto/update-lot.dto';
import { EventEmitter2 } from '@nestjs/event-emitter';
@Injectable()
export class LotsService {
constructor(
@InjectRepository(Lot)
private lotRepo: Repository<Lot>,
private eventEmitter: EventEmitter2,
) {}
async create(
stageId: string,
dto: CreateLotDto,
projectId: string,
constructoraId: string,
userId: string,
): Promise<Lot> {
const lot = this.lotRepo.create({
...dto,
stageId,
projectId,
constructoraId,
createdBy: userId,
});
const saved = await this.lotRepo.save(lot);
this.eventEmitter.emit('lot.created', saved);
return saved;
}
async bulkCreate(
stageId: string,
dto: BulkCreateLotsDto,
projectId: string,
constructoraId: string,
userId: string,
): Promise<Lot[]> {
const lots: Lot[] = [];
for (let i = 0; i < dto.quantity; i++) {
const lotNumber = dto.startNumber + i;
const code = `${dto.codePrefix}${lotNumber.toString().padStart(3, '0')}`;
const lot = this.lotRepo.create({
stageId,
projectId,
constructoraId,
blockId: dto.blockId || null,
code,
lotNumber,
areaSqm: dto.areaSqm,
frontMeters: dto.frontMeters,
depthMeters: dto.depthMeters,
shape: dto.shape,
orientation: dto.orientation,
prototypeId: dto.prototypeId,
prototypeVersion: dto.prototypeVersion,
status: LotStatus.DISPONIBLE,
createdBy: userId,
});
lots.push(lot);
}
const saved = await this.lotRepo.save(lots);
this.eventEmitter.emit('lots.bulk_created', { stageId, quantity: saved.length });
return saved;
}
async findAll(
stageId: string,
constructoraId: string,
filters?: { status?: LotStatus; blockId?: string },
): Promise<Lot[]> {
const query = this.lotRepo
.createQueryBuilder('lot')
.where('lot.stageId = :stageId', { stageId })
.andWhere('lot.constructoraId = :constructoraId', { constructoraId })
.leftJoinAndSelect('lot.prototype', 'prototype')
.leftJoinAndSelect('lot.housingUnits', 'housingUnits')
.orderBy('lot.lotNumber', 'ASC');
if (filters?.status) {
query.andWhere('lot.status = :status', { status: filters.status });
}
if (filters?.blockId) {
query.andWhere('lot.blockId = :blockId', { blockId: filters.blockId });
}
return query.getMany();
}
async findOne(id: string, constructoraId: string): Promise<Lot> {
const lot = await this.lotRepo.findOne({
where: { id, constructoraId },
relations: ['prototype', 'housingUnits', 'stage', 'block'],
});
if (!lot) {
throw new NotFoundException(`Lote con ID ${id} no encontrado`);
}
return lot;
}
async update(
id: string,
dto: UpdateLotDto,
constructoraId: string,
userId: string,
): Promise<Lot> {
const lot = await this.findOne(id, constructoraId);
Object.assign(lot, dto);
lot.updatedBy = userId;
return this.lotRepo.save(lot);
}
async assignPrototype(
id: string,
prototypeId: string,
prototypeVersion: number,
constructoraId: string,
userId: string,
): Promise<Lot> {
const lot = await this.findOne(id, constructoraId);
lot.prototypeId = prototypeId;
lot.prototypeVersion = prototypeVersion;
lot.updatedBy = userId;
const updated = await this.lotRepo.save(lot);
this.eventEmitter.emit('lot.prototype_assigned', { lot: updated, prototypeId });
return updated;
}
async bulkAssignPrototype(
lotIds: string[],
prototypeId: string,
prototypeVersion: number,
constructoraId: string,
userId: string,
): Promise<Lot[]> {
const lots = await this.lotRepo.find({
where: { constructoraId },
});
const lotsToUpdate = lots.filter((lot) => lotIds.includes(lot.id));
if (lotsToUpdate.length === 0) {
throw new BadRequestException('No se encontraron lotes para actualizar');
}
lotsToUpdate.forEach((lot) => {
lot.prototypeId = prototypeId;
lot.prototypeVersion = prototypeVersion;
lot.updatedBy = userId;
});
const updated = await this.lotRepo.save(lotsToUpdate);
this.eventEmitter.emit('lots.prototype_bulk_assigned', {
count: updated.length,
prototypeId,
});
return updated;
}
async changeStatus(
id: string,
newStatus: LotStatus,
constructoraId: string,
userId: string,
): Promise<Lot> {
const lot = await this.findOne(id, constructoraId);
const oldStatus = lot.status;
lot.status = newStatus;
lot.updatedBy = userId;
// Auto-update sale date if transitioning to sold
if (newStatus === LotStatus.VENDIDO && !lot.saleDate) {
lot.saleDate = new Date();
}
const updated = await this.lotRepo.save(lot);
this.eventEmitter.emit('lot.status_changed', { lot: updated, oldStatus, newStatus });
return updated;
}
async remove(id: string, constructoraId: string): Promise<void> {
const lot = await this.findOne(id, constructoraId);
if (lot.housingUnits && lot.housingUnits.length > 0) {
throw new BadRequestException('No se puede eliminar un lote con viviendas asignadas');
}
await this.lotRepo.remove(lot);
this.eventEmitter.emit('lot.deleted', { id });
}
}
HousingUnitsService
// apps/backend/src/modules/projects/services/housing-units.service.ts
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { HousingUnit, ConstructionStatus } from '../entities/housing-unit.entity';
import { CreateHousingUnitDto } from '../dto/create-housing-unit.dto';
import { UpdateHousingUnitProgressDto } from '../dto/update-housing-unit-progress.dto';
import { Lot } from '../entities/lot.entity';
import { HousingPrototype } from '../entities/housing-prototype.entity';
import { EventEmitter2 } from '@nestjs/event-emitter';
@Injectable()
export class HousingUnitsService {
constructor(
@InjectRepository(HousingUnit)
private housingUnitRepo: Repository<HousingUnit>,
@InjectRepository(Lot)
private lotRepo: Repository<Lot>,
@InjectRepository(HousingPrototype)
private prototypeRepo: Repository<HousingPrototype>,
private eventEmitter: EventEmitter2,
) {}
async create(
dto: CreateHousingUnitDto,
constructoraId: string,
userId: string,
): Promise<HousingUnit> {
// Get lot to inherit project, stage, block info
const lot = await this.lotRepo.findOne({
where: { id: dto.lotId, constructoraId },
relations: ['prototype'],
});
if (!lot) {
throw new NotFoundException(`Lote con ID ${dto.lotId} no encontrado`);
}
// If no prototype specified in DTO, use lot's prototype
let prototypeData = {};
if (dto.prototypeId || lot.prototypeId) {
const prototypeId = dto.prototypeId || lot.prototypeId;
const prototype = await this.prototypeRepo.findOne({
where: { id: prototypeId, constructoraId },
});
if (prototype) {
prototypeData = {
prototypeId: prototype.id,
prototypeName: prototype.name,
prototypeVersion: prototype.version,
housingType: prototype.category,
levels: prototype.levels,
bedrooms: prototype.bedrooms,
bathrooms: prototype.bathrooms,
parkingSpaces: prototype.parkingSpaces,
landAreaSqm: prototype.landAreaRequired,
builtAreaLevel1: prototype.builtAreaLevel1,
builtAreaLevel2: prototype.builtAreaLevel2,
totalBuiltArea: prototype.totalBuiltArea,
floorFinish: prototype.floorFinish,
wallFinish: prototype.wallFinish,
kitchenType: prototype.kitchenType,
bathroomFinish: prototype.bathroomFinish,
estimatedCost: prototype.totalConstructionCost,
};
}
}
const housingUnit = this.housingUnitRepo.create({
...prototypeData,
...dto, // DTO overrides prototype defaults
lotId: lot.id,
blockId: lot.blockId,
stageId: lot.stageId,
projectId: lot.projectId,
constructoraId,
createdBy: userId,
});
const saved = await this.housingUnitRepo.save(housingUnit);
this.eventEmitter.emit('housing_unit.created', saved);
return saved;
}
async findAll(
projectId: string,
constructoraId: string,
filters?: { stageId?: string; constructionStatus?: ConstructionStatus },
): Promise<HousingUnit[]> {
const query = this.housingUnitRepo
.createQueryBuilder('unit')
.where('unit.projectId = :projectId', { projectId })
.andWhere('unit.constructoraId = :constructoraId', { constructoraId })
.leftJoinAndSelect('unit.lot', 'lot')
.leftJoinAndSelect('unit.stage', 'stage')
.orderBy('unit.code', 'ASC');
if (filters?.stageId) {
query.andWhere('unit.stageId = :stageId', { stageId: filters.stageId });
}
if (filters?.constructionStatus) {
query.andWhere('unit.constructionStatus = :constructionStatus', {
constructionStatus: filters.constructionStatus,
});
}
return query.getMany();
}
async findOne(id: string, constructoraId: string): Promise<HousingUnit> {
const unit = await this.housingUnitRepo.findOne({
where: { id, constructoraId },
relations: ['lot', 'stage', 'block', 'project', 'prototype'],
});
if (!unit) {
throw new NotFoundException(`Vivienda con ID ${id} no encontrada`);
}
return unit;
}
async updateProgress(
id: string,
dto: UpdateHousingUnitProgressDto,
constructoraId: string,
userId: string,
): Promise<HousingUnit> {
const unit = await this.findOne(id, constructoraId);
// Update individual progress values
if (dto.foundationProgress !== undefined) unit.foundationProgress = dto.foundationProgress;
if (dto.structureProgress !== undefined) unit.structureProgress = dto.structureProgress;
if (dto.wallsProgress !== undefined) unit.wallsProgress = dto.wallsProgress;
if (dto.installationsProgress !== undefined)
unit.installationsProgress = dto.installationsProgress;
if (dto.finishesProgress !== undefined) unit.finishesProgress = dto.finishesProgress;
// Calculate overall physical progress (weighted average)
unit.physicalProgress =
(unit.foundationProgress * 0.2 +
unit.structureProgress * 0.25 +
unit.wallsProgress * 0.2 +
unit.installationsProgress * 0.2 +
unit.finishesProgress * 0.15) /
1.0;
// Auto-update construction status based on progress
if (unit.physicalProgress === 0) {
unit.constructionStatus = ConstructionStatus.NO_INICIADA;
} else if (unit.foundationProgress < 100) {
unit.constructionStatus = ConstructionStatus.CIMENTACION;
} else if (unit.structureProgress < 100) {
unit.constructionStatus = ConstructionStatus.ESTRUCTURA;
} else if (unit.wallsProgress < 100) {
unit.constructionStatus = ConstructionStatus.MUROS;
} else if (unit.installationsProgress < 100) {
unit.constructionStatus = ConstructionStatus.INSTALACIONES;
} else if (unit.finishesProgress < 100) {
unit.constructionStatus = ConstructionStatus.ACABADOS;
} else {
unit.constructionStatus = ConstructionStatus.TERMINADA;
if (!unit.actualCompletionDate) {
unit.actualCompletionDate = new Date();
}
}
// Auto-set construction start date
if (unit.physicalProgress > 0 && !unit.constructionStartDate) {
unit.constructionStartDate = new Date();
}
unit.updatedBy = userId;
const updated = await this.housingUnitRepo.save(unit);
this.eventEmitter.emit('housing_unit.progress_updated', updated);
return updated;
}
async changeStatus(
id: string,
newStatus: ConstructionStatus,
constructoraId: string,
userId: string,
): Promise<HousingUnit> {
const unit = await this.findOne(id, constructoraId);
const oldStatus = unit.constructionStatus;
unit.constructionStatus = newStatus;
unit.updatedBy = userId;
// Auto-update dates
if (newStatus === ConstructionStatus.TERMINADA && !unit.actualCompletionDate) {
unit.actualCompletionDate = new Date();
}
if (newStatus === ConstructionStatus.ENTREGADA && !unit.deliveryDate) {
unit.deliveryDate = new Date();
}
const updated = await this.housingUnitRepo.save(unit);
this.eventEmitter.emit('housing_unit.status_changed', {
unit: updated,
oldStatus,
newStatus,
});
return updated;
}
async remove(id: string, constructoraId: string): Promise<void> {
const unit = await this.findOne(id, constructoraId);
await this.housingUnitRepo.remove(unit);
this.eventEmitter.emit('housing_unit.deleted', { id });
}
}
3.4 Controllers
// apps/backend/src/modules/projects/controllers/stages.controller.ts
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
Request,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../../auth/guards/roles.guard';
import { Roles } from '../../auth/decorators/roles.decorator';
import { StagesService } from '../services/stages.service';
import { CreateStageDto } from '../dto/create-stage.dto';
import { UpdateStageDto } from '../dto/update-stage.dto';
import { StageStatus } from '../entities/stage.entity';
@Controller('projects/:projectId/stages')
@UseGuards(JwtAuthGuard, RolesGuard)
export class StagesController {
constructor(private stagesService: StagesService) {}
@Post()
@Roles('director', 'admin')
async create(
@Param('projectId') projectId: string,
@Body() dto: CreateStageDto,
@Request() req,
) {
return this.stagesService.create(projectId, dto, req.user.constructoraId, req.user.sub);
}
@Get()
@Roles('director', 'resident', 'engineer', 'supervisor', 'admin')
async findAll(@Param('projectId') projectId: string, @Request() req) {
return this.stagesService.findAll(projectId, req.user.constructoraId);
}
@Get('tree')
@Roles('director', 'resident', 'engineer', 'supervisor', 'admin')
async getTree(@Param('projectId') projectId: string, @Request() req) {
return this.stagesService.getTreeStructure(projectId, req.user.constructoraId);
}
@Get(':id')
@Roles('director', 'resident', 'engineer', 'supervisor', 'admin')
async findOne(@Param('id') id: string, @Request() req) {
return this.stagesService.findOne(id, req.user.constructoraId);
}
@Put(':id')
@Roles('director', 'resident', 'admin')
async update(
@Param('id') id: string,
@Body() dto: UpdateStageDto,
@Request() req,
) {
return this.stagesService.update(id, dto, req.user.constructoraId, req.user.sub);
}
@Put(':id/status')
@Roles('director', 'resident', 'admin')
async changeStatus(
@Param('id') id: string,
@Body('status') status: StageStatus,
@Request() req,
) {
return this.stagesService.changeStatus(id, status, req.user.constructoraId, req.user.sub);
}
@Delete(':id')
@Roles('director', 'admin')
async remove(@Param('id') id: string, @Request() req) {
await this.stagesService.remove(id, req.user.constructoraId);
return { message: 'Etapa eliminada exitosamente' };
}
}
// apps/backend/src/modules/projects/controllers/lots.controller.ts
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
Request,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../../auth/guards/roles.guard';
import { Roles } from '../../auth/decorators/roles.decorator';
import { LotsService } from '../services/lots.service';
import { CreateLotDto } from '../dto/create-lot.dto';
import { BulkCreateLotsDto } from '../dto/bulk-create-lots.dto';
import { UpdateLotDto } from '../dto/update-lot.dto';
import { LotStatus } from '../entities/lot.entity';
@Controller('stages/:stageId/lots')
@UseGuards(JwtAuthGuard, RolesGuard)
export class LotsController {
constructor(private lotsService: LotsService) {}
@Post()
@Roles('director', 'admin')
async create(
@Param('stageId') stageId: string,
@Query('projectId') projectId: string,
@Body() dto: CreateLotDto,
@Request() req,
) {
return this.lotsService.create(
stageId,
dto,
projectId,
req.user.constructoraId,
req.user.sub,
);
}
@Post('bulk')
@Roles('director', 'admin')
async bulkCreate(
@Param('stageId') stageId: string,
@Query('projectId') projectId: string,
@Body() dto: BulkCreateLotsDto,
@Request() req,
) {
return this.lotsService.bulkCreate(
stageId,
dto,
projectId,
req.user.constructoraId,
req.user.sub,
);
}
@Get()
@Roles('director', 'resident', 'engineer', 'supervisor', 'admin')
async findAll(
@Param('stageId') stageId: string,
@Query('status') status: LotStatus,
@Query('blockId') blockId: string,
@Request() req,
) {
return this.lotsService.findAll(stageId, req.user.constructoraId, { status, blockId });
}
@Get(':id')
@Roles('director', 'resident', 'engineer', 'supervisor', 'admin')
async findOne(@Param('id') id: string, @Request() req) {
return this.lotsService.findOne(id, req.user.constructoraId);
}
@Put(':id')
@Roles('director', 'resident', 'admin')
async update(
@Param('id') id: string,
@Body() dto: UpdateLotDto,
@Request() req,
) {
return this.lotsService.update(id, dto, req.user.constructoraId, req.user.sub);
}
@Put(':id/assign-prototype')
@Roles('director', 'admin')
async assignPrototype(
@Param('id') id: string,
@Body('prototypeId') prototypeId: string,
@Body('prototypeVersion') prototypeVersion: number,
@Request() req,
) {
return this.lotsService.assignPrototype(
id,
prototypeId,
prototypeVersion,
req.user.constructoraId,
req.user.sub,
);
}
@Put('bulk-assign-prototype')
@Roles('director', 'admin')
async bulkAssignPrototype(
@Body('lotIds') lotIds: string[],
@Body('prototypeId') prototypeId: string,
@Body('prototypeVersion') prototypeVersion: number,
@Request() req,
) {
return this.lotsService.bulkAssignPrototype(
lotIds,
prototypeId,
prototypeVersion,
req.user.constructoraId,
req.user.sub,
);
}
@Put(':id/status')
@Roles('director', 'resident', 'admin')
async changeStatus(
@Param('id') id: string,
@Body('status') status: LotStatus,
@Request() req,
) {
return this.lotsService.changeStatus(id, status, req.user.constructoraId, req.user.sub);
}
@Delete(':id')
@Roles('director', 'admin')
async remove(@Param('id') id: string, @Request() req) {
await this.lotsService.remove(id, req.user.constructoraId);
return { message: 'Lote eliminado exitosamente' };
}
}
4. Implementación Frontend (React + TypeScript)
4.1 API Service
// apps/frontend/src/services/projects.api.ts
import { apiClient } from './api-client';
import type {
Stage,
Block,
Lot,
HousingUnit,
CreateStageDto,
CreateLotDto,
BulkCreateLotsDto,
CreateHousingUnitDto,
UpdateHousingUnitProgressDto,
} from '../types/projects.types';
export const projectsApi = {
// Stages
async getStages(projectId: string): Promise<Stage[]> {
const { data } = await apiClient.get(`/projects/${projectId}/stages`);
return data;
},
async getStageTree(projectId: string): Promise<any> {
const { data } = await apiClient.get(`/projects/${projectId}/stages/tree`);
return data;
},
async createStage(projectId: string, dto: CreateStageDto): Promise<Stage> {
const { data } = await apiClient.post(`/projects/${projectId}/stages`, dto);
return data;
},
async updateStageStatus(stageId: string, projectId: string, status: string): Promise<Stage> {
const { data } = await apiClient.put(`/projects/${projectId}/stages/${stageId}/status`, {
status,
});
return data;
},
// Lots
async getLots(stageId: string, projectId: string): Promise<Lot[]> {
const { data } = await apiClient.get(`/stages/${stageId}/lots?projectId=${projectId}`);
return data;
},
async createLot(stageId: string, projectId: string, dto: CreateLotDto): Promise<Lot> {
const { data } = await apiClient.post(`/stages/${stageId}/lots?projectId=${projectId}`, dto);
return data;
},
async bulkCreateLots(
stageId: string,
projectId: string,
dto: BulkCreateLotsDto,
): Promise<Lot[]> {
const { data } = await apiClient.post(
`/stages/${stageId}/lots/bulk?projectId=${projectId}`,
dto,
);
return data;
},
async assignPrototypeToLot(
lotId: string,
prototypeId: string,
prototypeVersion: number,
): Promise<Lot> {
const { data } = await apiClient.put(`/stages/x/lots/${lotId}/assign-prototype`, {
prototypeId,
prototypeVersion,
});
return data;
},
async bulkAssignPrototype(
lotIds: string[],
prototypeId: string,
prototypeVersion: number,
): Promise<Lot[]> {
const { data } = await apiClient.put(`/stages/x/lots/bulk-assign-prototype`, {
lotIds,
prototypeId,
prototypeVersion,
});
return data;
},
// Housing Units
async getHousingUnits(projectId: string): Promise<HousingUnit[]> {
const { data} = await apiClient.get(`/housing-units?projectId=${projectId}`);
return data;
},
async createHousingUnit(dto: CreateHousingUnitDto): Promise<HousingUnit> {
const { data } = await apiClient.post(`/housing-units`, dto);
return data;
},
async updateHousingUnitProgress(
unitId: string,
dto: UpdateHousingUnitProgressDto,
): Promise<HousingUnit> {
const { data } = await apiClient.put(`/housing-units/${unitId}/progress`, dto);
return data;
},
};
4.2 Components
StructureTreeView Component
// apps/frontend/src/features/projects/components/StructureTreeView.tsx
import React, { useState } from 'react';
import { ChevronRight, ChevronDown, Home, Building, Grid, Square } from 'lucide-react';
import type { Stage, Block, Lot, HousingUnit } from '../../../types/projects.types';
interface TreeNode {
id: string;
code: string;
name: string;
status: string;
type: 'stage' | 'block' | 'lot' | 'housing_unit';
progress?: number;
children?: TreeNode[];
}
interface StructureTreeViewProps {
data: Stage[];
onNodeClick?: (node: TreeNode) => void;
}
export function StructureTreeView({ data, onNodeClick }: StructureTreeViewProps) {
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
const toggleNode = (nodeId: string) => {
setExpandedNodes((prev) => {
const newSet = new Set(prev);
if (newSet.has(nodeId)) {
newSet.delete(nodeId);
} else {
newSet.add(nodeId);
}
return newSet;
});
};
const renderIcon = (type: string) => {
switch (type) {
case 'stage':
return <Building className="h-4 w-4" />;
case 'block':
return <Grid className="h-4 w-4" />;
case 'lot':
return <Square className="h-4 w-4" />;
case 'housing_unit':
return <Home className="h-4 w-4" />;
default:
return null;
}
};
const getStatusColor = (status: string): string => {
const statusColors: Record<string, string> = {
planeada: 'bg-gray-100 text-gray-700',
en_proceso: 'bg-blue-100 text-blue-700',
pausada: 'bg-yellow-100 text-yellow-700',
terminada: 'bg-green-100 text-green-700',
entregada: 'bg-purple-100 text-purple-700',
disponible: 'bg-green-100 text-green-700',
vendido: 'bg-blue-100 text-blue-700',
en_construccion: 'bg-orange-100 text-orange-700',
};
return statusColors[status] || 'bg-gray-100 text-gray-700';
};
const renderTreeNode = (node: any, level: number = 0, type: string) => {
const hasChildren =
(type === 'stage' && (node.blocks?.length > 0 || node.lots?.length > 0)) ||
(type === 'block' && node.lots?.length > 0) ||
(type === 'lot' && node.housingUnits?.length > 0);
const isExpanded = expandedNodes.has(node.id);
return (
<div key={node.id}>
<div
className={`flex items-center gap-2 p-2 hover:bg-gray-50 cursor-pointer rounded-md transition-colors`}
style={{ paddingLeft: `${level * 1.5 + 0.5}rem` }}
onClick={() => {
if (hasChildren) toggleNode(node.id);
onNodeClick?.({ ...node, type });
}}
>
{hasChildren && (
<button className="p-0.5" onClick={(e) => {
e.stopPropagation();
toggleNode(node.id);
}}>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-gray-500" />
) : (
<ChevronRight className="h-4 w-4 text-gray-500" />
)}
</button>
)}
{!hasChildren && <div className="w-5" />}
<div className="flex items-center gap-2 flex-1">
{renderIcon(type)}
<span className="font-medium text-sm">{node.code}</span>
<span className="text-gray-600 text-sm">{node.name}</span>
<span
className={`ml-auto px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(
node.status,
)}`}
>
{node.status}
</span>
{node.physicalProgress !== undefined && (
<span className="text-xs text-gray-500">{node.physicalProgress.toFixed(1)}%</span>
)}
</div>
</div>
{isExpanded && hasChildren && (
<div>
{type === 'stage' &&
node.blocks?.map((block: any) => renderTreeNode(block, level + 1, 'block'))}
{type === 'stage' &&
node.lots
?.filter((lot: any) => !lot.blockId)
.map((lot: any) => renderTreeNode(lot, level + 1, 'lot'))}
{type === 'block' &&
node.lots?.map((lot: any) => renderTreeNode(lot, level + 1, 'lot'))}
{type === 'lot' &&
node.housingUnits?.map((unit: any) =>
renderTreeNode(unit, level + 1, 'housing_unit'),
)}
</div>
)}
</div>
);
};
return (
<div className="bg-white rounded-lg shadow p-4">
<h3 className="text-lg font-semibold mb-4">Estructura del Proyecto</h3>
<div className="space-y-1">
{data.map((stage) => renderTreeNode(stage, 0, 'stage'))}
</div>
</div>
);
}
BulkLotCreationForm Component
// apps/frontend/src/features/projects/components/BulkLotCreationForm.tsx
import React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { projectsApi } from '../../../services/projects.api';
const bulkLotSchema = z.object({
quantity: z.number().int().min(1).max(500),
codePrefix: z.string().min(1),
startNumber: z.number().int().min(1),
areaSqm: z.number().positive(),
frontMeters: z.number().positive().optional(),
depthMeters: z.number().positive().optional(),
shape: z.enum(['rectangular', 'irregular', 'esquina', 'cul_de_sac']).optional(),
orientation: z
.enum(['norte', 'sur', 'este', 'oeste', 'noreste', 'noroeste', 'sureste', 'suroeste'])
.optional(),
prototypeId: z.string().uuid().optional(),
});
type BulkLotFormData = z.infer<typeof bulkLotSchema>;
interface BulkLotCreationFormProps {
stageId: string;
projectId: string;
blockId?: string;
onSuccess: () => void;
}
export function BulkLotCreationForm({
stageId,
projectId,
blockId,
onSuccess,
}: BulkLotCreationFormProps) {
const queryClient = useQueryClient();
const form = useForm<BulkLotFormData>({
resolver: zodResolver(bulkLotSchema),
defaultValues: {
quantity: 10,
codePrefix: 'LOTE-',
startNumber: 1,
areaSqm: 120,
frontMeters: 6,
depthMeters: 20,
shape: 'rectangular',
},
});
const createMutation = useMutation({
mutationFn: (data: BulkLotFormData) => {
return projectsApi.bulkCreateLots(stageId, projectId, {
...data,
blockId,
});
},
onSuccess: (data) => {
toast.success(`${data.length} lotes creados exitosamente`);
queryClient.invalidateQueries({ queryKey: ['lots', stageId] });
onSuccess();
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Error al crear lotes');
},
});
const onSubmit = (data: BulkLotFormData) => {
createMutation.mutate(data);
};
return (
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Cantidad de Lotes
</label>
<input
type="number"
{...form.register('quantity', { valueAsNumber: true })}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
{form.formState.errors.quantity && (
<p className="text-red-600 text-sm mt-1">
{form.formState.errors.quantity.message}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Prefijo de Código</label>
<input
type="text"
{...form.register('codePrefix')}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
placeholder="LOTE-"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Número Inicial</label>
<input
type="number"
{...form.register('startNumber', { valueAsNumber: true })}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Área (m²)</label>
<input
type="number"
step="0.01"
{...form.register('areaSqm', { valueAsNumber: true })}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Frente (m)</label>
<input
type="number"
step="0.01"
{...form.register('frontMeters', { valueAsNumber: true })}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Fondo (m)</label>
<input
type="number"
step="0.01"
{...form.register('depthMeters', { valueAsNumber: true })}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Forma</label>
<select {...form.register('shape')} className="w-full px-3 py-2 border border-gray-300 rounded-md">
<option value="rectangular">Rectangular</option>
<option value="irregular">Irregular</option>
<option value="esquina">Esquina</option>
<option value="cul_de_sac">Cul de Sac</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Orientación</label>
<select
{...form.register('orientation')}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
>
<option value="">Sin especificar</option>
<option value="norte">Norte</option>
<option value="sur">Sur</option>
<option value="este">Este</option>
<option value="oeste">Oeste</option>
<option value="noreste">Noreste</option>
<option value="noroeste">Noroeste</option>
<option value="sureste">Sureste</option>
<option value="suroeste">Suroeste</option>
</select>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<button
type="button"
onClick={onSuccess}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200"
>
Cancelar
</button>
<button
type="submit"
disabled={createMutation.isPending}
className="px-4 py-2 text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{createMutation.isPending ? 'Creando...' : 'Crear Lotes'}
</button>
</div>
</form>
);
}
HousingUnitProgressCard Component
// apps/frontend/src/features/projects/components/HousingUnitProgressCard.tsx
import React, { useState } from 'react';
import { Home, TrendingUp } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { projectsApi } from '../../../services/projects.api';
import type { HousingUnit } from '../../../types/projects.types';
interface HousingUnitProgressCardProps {
unit: HousingUnit;
}
export function HousingUnitProgressCard({ unit }: HousingUnitProgressCardProps) {
const [editMode, setEditMode] = useState(false);
const queryClient = useQueryClient();
const [progress, setProgress] = useState({
foundationProgress: unit.foundationProgress || 0,
structureProgress: unit.structureProgress || 0,
wallsProgress: unit.wallsProgress || 0,
installationsProgress: unit.installationsProgress || 0,
finishesProgress: unit.finishesProgress || 0,
});
const updateMutation = useMutation({
mutationFn: (data: typeof progress) => {
return projectsApi.updateHousingUnitProgress(unit.id, data);
},
onSuccess: () => {
toast.success('Avance actualizado');
queryClient.invalidateQueries({ queryKey: ['housing-units'] });
setEditMode(false);
},
onError: () => {
toast.error('Error al actualizar avance');
},
});
const handleSave = () => {
updateMutation.mutate(progress);
};
const getStatusColor = (status: string): string => {
const colors: Record<string, string> = {
no_iniciada: 'bg-gray-100 text-gray-700',
cimentacion: 'bg-yellow-100 text-yellow-700',
estructura: 'bg-orange-100 text-orange-700',
muros: 'bg-blue-100 text-blue-700',
instalaciones: 'bg-indigo-100 text-indigo-700',
acabados: 'bg-purple-100 text-purple-700',
terminada: 'bg-green-100 text-green-700',
entregada: 'bg-emerald-100 text-emerald-700',
};
return colors[status] || 'bg-gray-100 text-gray-700';
};
return (
<div className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<Home className="h-5 w-5 text-gray-600" />
<div>
<h4 className="font-semibold">{unit.code}</h4>
<p className="text-sm text-gray-600">{unit.prototypeName}</p>
</div>
</div>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(unit.constructionStatus)}`}>
{unit.constructionStatus}
</span>
</div>
<div className="mb-3">
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600">Avance Global</span>
<span className="font-semibold">{unit.physicalProgress?.toFixed(1)}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
style={{ width: `${unit.physicalProgress}%` }}
/>
</div>
</div>
{editMode ? (
<div className="space-y-2">
{[
{ key: 'foundationProgress', label: 'Cimentación' },
{ key: 'structureProgress', label: 'Estructura' },
{ key: 'wallsProgress', label: 'Muros' },
{ key: 'installationsProgress', label: 'Instalaciones' },
{ key: 'finishesProgress', label: 'Acabados' },
].map((item) => (
<div key={item.key}>
<div className="flex justify-between text-xs mb-1">
<span>{item.label}</span>
<span>{progress[item.key as keyof typeof progress]}%</span>
</div>
<input
type="range"
min="0"
max="100"
value={progress[item.key as keyof typeof progress]}
onChange={(e) =>
setProgress({ ...progress, [item.key]: Number(e.target.value) })
}
className="w-full h-1"
/>
</div>
))}
<div className="flex gap-2 pt-2">
<button
onClick={handleSave}
disabled={updateMutation.isPending}
className="flex-1 px-3 py-1.5 text-sm text-white bg-blue-600 rounded hover:bg-blue-700"
>
Guardar
</button>
<button
onClick={() => setEditMode(false)}
className="flex-1 px-3 py-1.5 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200"
>
Cancelar
</button>
</div>
</div>
) : (
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<span className="text-gray-600">Cimentación:</span>
<span className="ml-1 font-medium">{unit.foundationProgress}%</span>
</div>
<div>
<span className="text-gray-600">Estructura:</span>
<span className="ml-1 font-medium">{unit.structureProgress}%</span>
</div>
<div>
<span className="text-gray-600">Muros:</span>
<span className="ml-1 font-medium">{unit.wallsProgress}%</span>
</div>
<div>
<span className="text-gray-600">Instalaciones:</span>
<span className="ml-1 font-medium">{unit.installationsProgress}%</span>
</div>
<div>
<span className="text-gray-600">Acabados:</span>
<span className="ml-1 font-medium">{unit.finishesProgress}%</span>
</div>
<button
onClick={() => setEditMode(true)}
className="col-span-2 mt-2 px-3 py-1.5 text-sm text-blue-600 bg-blue-50 rounded hover:bg-blue-100 flex items-center justify-center gap-1"
>
<TrendingUp className="h-4 w-4" />
Actualizar Avance
</button>
</div>
)}
</div>
);
}
5. Casos de Uso
CU-STR-001: Crear Estructura de Fraccionamiento Horizontal
Actor: Director de Proyecto Precondición: Proyecto creado con tipo "fraccionamiento_horizontal"
Flujo Principal:
- Director accede a "Crear Estructura" desde el proyecto
- Sistema muestra wizard de 4 pasos
- Paso 1: Crear Etapas
- Director define 3 etapas: "Etapa 1", "Etapa 2", "Etapa 3"
- Cada etapa con código, fechas, área total
- Paso 2: Crear Manzanas
- Para Etapa 1: 4 manzanas (MZA-A, MZA-B, MZA-C, MZA-D)
- Define área, infraestructura por manzana
- Paso 3: Crear Lotes en Masa
- Selecciona MZA-A
- Usa "Creación masiva": 20 lotes, prefijo "LOTE-", 120 m² cada uno
- Repite para cada manzana
- Paso 4: Asignar Prototipos
- Selecciona lotes 1-10 de MZA-A
- Asigna prototipo "Casa Tipo A v1"
- Selecciona lotes 11-20
- Asigna prototipo "Casa Tipo B v1"
- Sistema crea toda la estructura en transacción
- Sistema muestra vista de árbol jerárquico
- Sistema actualiza métricas de proyecto (total de lotes, viviendas proyectadas)
Resultado: Estructura completa de fraccionamiento creada con 3 etapas, 12 manzanas, 240 lotes
CU-STR-002: Crear Estructura de Torre Vertical
Actor: Director de Proyecto Precondición: Proyecto creado con tipo "edificio_vertical"
Flujo Principal:
- Director accede a "Crear Estructura de Torre"
- Sistema muestra formulario adaptado:
- Etapa = Torre/Edificio
- Manzana = Nivel/Piso
- Lote = Departamento
- Torre 1:
- Código: "TORRE-1"
- 8 niveles (pisos)
- Nivel 1 (Planta Baja):
- Código: "NIVEL-PB"
- 4 departamentos (ej: DEPTO-101, DEPTO-102, DEPTO-103, DEPTO-104)
- Niveles 2-8:
- Cada uno con 4 departamentos
- Código: NIVEL-2, NIVEL-3, etc.
- Total: 8 niveles × 4 deptos = 32 departamentos
- Sistema adapta terminología en UI: "Nivel" en vez de "Manzana"
- Sistema crea viviendas automáticamente para cada departamento
- Sistema muestra vista de árbol jerárquico adaptada
Resultado: Torre de 8 niveles con 32 departamentos creada
CU-STR-003: Actualizar Avance de Vivienda
Actor: Residente de Obra Precondición: Vivienda creada y en construcción
Flujo Principal:
- Residente accede a "Viviendas en Construcción"
- Sistema muestra tarjetas de viviendas activas
- Residente selecciona "VIV-A-012"
- Residente hace clic en "Actualizar Avance"
- Sistema muestra sliders por etapa constructiva:
- Cimentación: 100% (ya completa)
- Estructura: 100% (ya completa)
- Muros: 60% → Residente ajusta a 80%
- Instalaciones: 40% → Residente ajusta a 60%
- Acabados: 10% → Residente ajusta a 20%
- Residente hace clic en "Guardar"
- Sistema calcula avance global ponderado:
(100*0.2 + 100*0.25 + 80*0.2 + 60*0.2 + 20*0.15) = 72%
- Sistema actualiza
constructionStatusa "instalaciones" (la etapa más baja < 100%) - Sistema emite evento
housing_unit.progress_updated - Sistema actualiza métricas de lote, manzana, etapa, proyecto (cascada)
- Sistema muestra notificación "Avance actualizado a 72%"
Resultado: Avance de vivienda actualizado y reflejado en todas las jerarquías superiores
6. Tests
// apps/backend/src/modules/projects/services/stages.service.spec.ts
describe('StagesService', () => {
let service: StagesService;
let repo: Repository<Stage>;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
StagesService,
{
provide: getRepositoryToken(Stage),
useClass: Repository,
},
{
provide: EventEmitter2,
useValue: { emit: jest.fn() },
},
],
}).compile();
service = module.get<StagesService>(StagesService);
repo = module.get<Repository<Stage>>(getRepositoryToken(Stage));
});
it('should create a stage with sequential number', async () => {
const projectId = 'uuid-project-1';
const constructoraId = 'uuid-constructora-1';
jest.spyOn(repo, 'create').mockReturnValue({
id: 'uuid-stage-1',
code: 'ETAPA-1',
stageNumber: 1,
} as Stage);
jest.spyOn(repo, 'save').mockResolvedValue({} as Stage);
const result = await service.create(
projectId,
{
code: 'ETAPA-1',
name: 'Etapa 1',
stageNumber: 1,
},
constructoraId,
'user-id',
);
expect(repo.create).toHaveBeenCalled();
expect(repo.save).toHaveBeenCalled();
});
it('should build tree structure with nested relations', async () => {
const mockStages = [
{
id: 'stage-1',
code: 'ETAPA-1',
blocks: [
{
id: 'block-1',
code: 'MZA-A',
lots: [
{
id: 'lot-1',
code: 'LOTE-001',
housingUnits: [{ id: 'unit-1', code: 'VIV-A-001' }],
},
],
},
],
lots: [],
},
];
jest.spyOn(repo, 'find').mockResolvedValue(mockStages as Stage[]);
const tree = await service.getTreeStructure('project-id', 'constructora-id');
expect(tree).toHaveLength(1);
expect(tree[0].blocks).toHaveLength(1);
expect(tree[0].blocks[0].lots).toHaveLength(1);
expect(tree[0].blocks[0].lots[0].housingUnits).toHaveLength(1);
});
});
7. Métricas y KPIs
Métricas Calculadas Automáticamente
-
Por Etapa:
totalBlocks: Contador actualizado por triggertotalLots: Contador actualizado por triggertotalHousingUnits: Contador actualizado por triggerphysicalProgress: Promedio ponderado de avance de viviendas
-
Por Manzana:
totalLots: Contador actualizado por triggerinfrastructureProgress: Promedio de % de infraestructura completada
-
Por Lote:
- Avance heredado de vivienda asignada
-
Por Vivienda:
physicalProgress: Calculado con fórmula ponderada:(foundation * 0.20 + structure * 0.25 + walls * 0.20 + installations * 0.20 + finishes * 0.15)
8. Permisos por Rol
| Acción | Director | Residente | Ingeniero | Supervisor |
|---|---|---|---|---|
| Crear Etapa | ✅ | ❌ | ❌ | ❌ |
| Ver Etapas | ✅ | ✅ | ✅ | ✅ |
| Crear Manzanas | ✅ | ✅ | ❌ | ❌ |
| Crear Lotes (masa) | ✅ | ✅ | ❌ | ❌ |
| Asignar Prototipos | ✅ | ✅ | ❌ | ❌ |
| Crear Viviendas | ✅ | ✅ | ❌ | ❌ |
| Actualizar Avance de Vivienda | ✅ | ✅ | ✅ | ✅ |
| Ver Árbol Jerárquico | ✅ | ✅ | ✅ | ✅ |
| Eliminar Etapa | ✅ | ❌ | ❌ | ❌ |
| Exportar Estructura | ✅ | ✅ | ✅ | ❌ |
9. Validaciones de Negocio
-
Etapa:
- Código único dentro del proyecto
stageNumbersecuencial y único dentro del proyecto
-
Manzana:
- Código único dentro de la etapa
- No puede eliminarse si tiene lotes asignados
-
Lote:
- Código único dentro de la etapa
areaSqmdebe ser > 0- No puede cambiar a estado "vendido" sin
salePriceybuyerName - No puede eliminarse si tiene viviendas asignadas
-
Vivienda:
- Cada lote puede tener 1 o más viviendas (ej: dúplex = 2 viviendas en 1 lote)
- Avances parciales deben estar entre 0 y 100
- Al llegar a 100% en todas las etapas,
constructionStatus= 'terminada' automáticamente actualCompletionDatese auto-asigna al marcar como terminada
-
Asignación de Prototipos:
- Prototipo debe pertenecer a la misma constructora
- Prototipo debe estar activo (no deprecated)
- Se puede asignar en masa a múltiples lotes
10. Eventos Emitidos
// Eventos del sistema
'stage.created': { stage: Stage }
'stage.status_changed': { stage: Stage, oldStatus, newStatus }
'stage.deleted': { id: string }
'block.created': { block: Block }
'block.infrastructure_updated': { block: Block }
'block.deleted': { id: string }
'lot.created': { lot: Lot }
'lots.bulk_created': { stageId: string, quantity: number }
'lot.prototype_assigned': { lot: Lot, prototypeId: string }
'lots.prototype_bulk_assigned': { count: number, prototypeId: string }
'lot.status_changed': { lot: Lot, oldStatus, newStatus }
'lot.deleted': { id: string }
'housing_unit.created': { housingUnit: HousingUnit }
'housing_unit.progress_updated': { housingUnit: HousingUnit }
'housing_unit.status_changed': { housingUnit: HousingUnit, oldStatus, newStatus }
'housing_unit.deleted': { id: string }
11. Optimizaciones de Performance
-
Índices en Base de Datos:
- Índice compuesto en
(project_id, stage_number)para búsquedas rápidas - Índice en
constructora_idpara RLS - Índice en
statuspara filtros
- Índice compuesto en
-
Carga de Árbol Jerárquico:
- Query única con
leftJoinAndSelectpara cargar todas las relaciones - Alternativa: Query recursiva SQL para proyectos muy grandes (>10,000 lotes)
- Query única con
-
Creación Masiva de Lotes:
- Inserción en batch (hasta 500 lotes por operación)
- Transacción única para garantizar atomicidad
-
Actualización de Métricas:
- Triggers de base de datos para contadores (
totalLots,totalBlocks) - Cálculo diferido de
physicalProgress(via job nocturno o on-demand)
- Triggers de base de datos para contadores (
12. Migraciones
// apps/backend/src/migrations/1234567890-CreateProjectStructure.ts
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateProjectStructure1234567890 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE projects.stages (
-- Schema definido en sección 2.1
);
`);
await queryRunner.query(`
CREATE TABLE projects.blocks (
-- Schema definido en sección 2.1
);
`);
await queryRunner.query(`
CREATE TABLE projects.lots (
-- Schema definido en sección 2.1
);
`);
await queryRunner.query(`
CREATE TABLE projects.housing_units (
-- Schema definido en sección 2.1
);
`);
// Triggers
await queryRunner.query(`
-- Triggers definidos en sección 2.1
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS projects.housing_units CASCADE;`);
await queryRunner.query(`DROP TABLE IF EXISTS projects.lots CASCADE;`);
await queryRunner.query(`DROP TABLE IF EXISTS projects.blocks CASCADE;`);
await queryRunner.query(`DROP TABLE IF EXISTS projects.stages CASCADE;`);
}
}
13. Notas de Implementación
-
Flexibilidad de Estructura:
blockIdes nullable enlotspara soportar proyectos sin manzanas- UI adapta terminología según
projectType(Torre = Niveles, Fraccionamiento = Manzanas)
-
Herencia de Datos:
- Viviendas heredan características del prototipo al momento de creación
- Cambios posteriores en el prototipo NO afectan viviendas ya creadas
- Se guarda
prototypeVersionpara trazabilidad
-
Cascada de Eliminación:
- Eliminar etapa → elimina manzanas, lotes, viviendas (CASCADE)
- Eliminar manzana → elimina lotes y viviendas
- Validaciones previenen eliminación accidental
-
Transacciones:
- Creación masiva de lotes en transacción única
- Rollback completo si falla alguna inserción
-
Auditoría:
- Todos los cambios registran
createdByyupdatedBy - Timestamps automáticos con
@CreateDateColumny@UpdateDateColumn
- Todos los cambios registran
Fecha de generación: 2025-11-17 Autor: Sistema de Documentación Técnica Versión: 1.0 Estado: ✅ Completo