erp-construccion/docs/05-backend-specs/modules/SPEC-construction.md

18 KiB

Backend Specification: Construction Module

Version: 1.0.0 Fecha: 2025-12-05 Modulos: MAI-002 (Proyectos), MAI-003 (Presupuestos), MAI-005 (Control de Obra)


Resumen

Metrica Valor
Controllers 5
Services 6
Entities 15
Endpoints 45+
Tests Requeridos 60+

Estructura del Modulo

modules/construction/
+-- construction.module.ts
+-- controllers/
|   +-- project.controller.ts
|   +-- development.controller.ts
|   +-- budget.controller.ts
|   +-- progress.controller.ts
|   +-- estimation.controller.ts
+-- services/
|   +-- project.service.ts
|   +-- development.service.ts
|   +-- budget.service.ts
|   +-- apu.service.ts
|   +-- progress.service.ts
|   +-- estimation.service.ts
+-- entities/
|   +-- project.entity.ts
|   +-- development.entity.ts
|   +-- section.entity.ts
|   +-- housing-unit.entity.ts
|   +-- prototype.entity.ts
|   +-- budget.entity.ts
|   +-- budget-partida.entity.ts
|   +-- budget-concept.entity.ts
|   +-- apu-item.entity.ts
|   +-- schedule.entity.ts
|   +-- schedule-item.entity.ts
|   +-- progress-record.entity.ts
|   +-- logbook-entry.entity.ts
|   +-- estimation.entity.ts
|   +-- estimation-line.entity.ts
+-- dto/
|   +-- project/
|   +-- budget/
|   +-- progress/
+-- repositories/
|   +-- project.repository.ts
|   +-- budget.repository.ts
+-- events/
    +-- project-created.event.ts
    +-- progress-recorded.event.ts

Controllers

1. ProjectController

@Controller('api/v1/projects')
@ApiTags('projects')
export class ProjectController {

  // GET /api/v1/projects
  @Get()
  async findAll(@Query() query: ProjectQueryDto): Promise<PaginatedResponse<ProjectDto>>;

  // GET /api/v1/projects/:id
  @Get(':id')
  async findOne(@Param('id') id: UUID): Promise<ProjectDto>;

  // POST /api/v1/projects
  @Post()
  async create(@Body() dto: CreateProjectDto): Promise<ProjectDto>;

  // PUT /api/v1/projects/:id
  @Put(':id')
  async update(@Param('id') id: UUID, @Body() dto: UpdateProjectDto): Promise<ProjectDto>;

  // DELETE /api/v1/projects/:id
  @Delete(':id')
  async remove(@Param('id') id: UUID): Promise<void>;

  // GET /api/v1/projects/:id/summary
  @Get(':id/summary')
  async getSummary(@Param('id') id: UUID): Promise<ProjectSummaryDto>;

  // GET /api/v1/projects/:id/developments
  @Get(':id/developments')
  async getDevelopments(@Param('id') id: UUID): Promise<DevelopmentDto[]>;

  // GET /api/v1/projects/:id/budgets
  @Get(':id/budgets')
  async getBudgets(@Param('id') id: UUID): Promise<BudgetDto[]>;
}

2. BudgetController

@Controller('api/v1/budgets')
@ApiTags('budgets')
export class BudgetController {

  // GET /api/v1/budgets
  @Get()
  async findAll(@Query() query: BudgetQueryDto): Promise<PaginatedResponse<BudgetDto>>;

  // GET /api/v1/budgets/:id
  @Get(':id')
  async findOne(@Param('id') id: UUID): Promise<BudgetDetailDto>;

  // POST /api/v1/budgets
  @Post()
  async create(@Body() dto: CreateBudgetDto): Promise<BudgetDto>;

  // PUT /api/v1/budgets/:id
  @Put(':id')
  async update(@Param('id') id: UUID, @Body() dto: UpdateBudgetDto): Promise<BudgetDto>;

  // POST /api/v1/budgets/:id/approve
  @Post(':id/approve')
  async approve(@Param('id') id: UUID): Promise<BudgetDto>;

  // GET /api/v1/budgets/:id/partidas
  @Get(':id/partidas')
  async getPartidas(@Param('id') id: UUID): Promise<PartidaTreeDto[]>;

  // POST /api/v1/budgets/:id/partidas
  @Post(':id/partidas')
  async addPartida(@Param('id') id: UUID, @Body() dto: CreatePartidaDto): Promise<PartidaDto>;

  // GET /api/v1/budgets/:id/concepts
  @Get(':id/concepts')
  async getConcepts(@Param('id') id: UUID): Promise<ConceptDto[]>;

  // POST /api/v1/budgets/:id/concepts
  @Post(':id/concepts')
  async addConcept(@Body() dto: CreateConceptDto): Promise<ConceptDto>;

  // GET /api/v1/budgets/:id/explosion
  @Get(':id/explosion')
  async getExplosion(@Param('id') id: UUID): Promise<MaterialExplosionDto[]>;

  // POST /api/v1/budgets/:id/explode
  @Post(':id/explode')
  async explodeMaterials(@Param('id') id: UUID): Promise<{ count: number }>;

  // GET /api/v1/budgets/:id/comparison
  @Get(':id/comparison')
  async getComparison(@Param('id') id: UUID): Promise<BudgetComparisonDto>;

  // POST /api/v1/budgets/:id/import
  @Post(':id/import')
  @UseInterceptors(FileInterceptor('file'))
  async importFromExcel(@UploadedFile() file: Express.Multer.File): Promise<ImportResultDto>;
}

3. ProgressController

@Controller('api/v1/progress')
@ApiTags('progress')
export class ProgressController {

  // GET /api/v1/progress
  @Get()
  async findAll(@Query() query: ProgressQueryDto): Promise<PaginatedResponse<ProgressRecordDto>>;

  // POST /api/v1/progress
  @Post()
  async create(@Body() dto: CreateProgressDto): Promise<ProgressRecordDto>;

  // POST /api/v1/progress/:id/approve
  @Post(':id/approve')
  async approve(@Param('id') id: UUID): Promise<ProgressRecordDto>;

  // GET /api/v1/progress/by-project/:projectId
  @Get('by-project/:projectId')
  async getByProject(@Param('projectId') projectId: UUID): Promise<ProgressRecordDto[]>;

  // GET /api/v1/progress/by-unit/:unitId
  @Get('by-unit/:unitId')
  async getByUnit(@Param('unitId') unitId: UUID): Promise<ProgressRecordDto[]>;

  // GET /api/v1/progress/curve-s/:projectId
  @Get('curve-s/:projectId')
  async getCurveS(@Param('projectId') projectId: UUID): Promise<CurveSDto>;

  // POST /api/v1/progress/:id/photos
  @Post(':id/photos')
  @UseInterceptors(FilesInterceptor('photos'))
  async uploadPhotos(
    @Param('id') id: UUID,
    @UploadedFiles() files: Express.Multer.File[]
  ): Promise<PhotoDto[]>;
}

4. EstimationController

@Controller('api/v1/estimations')
@ApiTags('estimations')
export class EstimationController {

  // GET /api/v1/estimations
  @Get()
  async findAll(@Query() query: EstimationQueryDto): Promise<PaginatedResponse<EstimationDto>>;

  // GET /api/v1/estimations/:id
  @Get(':id')
  async findOne(@Param('id') id: UUID): Promise<EstimationDetailDto>;

  // POST /api/v1/estimations
  @Post()
  async create(@Body() dto: CreateEstimationDto): Promise<EstimationDto>;

  // PUT /api/v1/estimations/:id
  @Put(':id')
  async update(@Param('id') id: UUID, @Body() dto: UpdateEstimationDto): Promise<EstimationDto>;

  // POST /api/v1/estimations/:id/submit
  @Post(':id/submit')
  async submit(@Param('id') id: UUID): Promise<EstimationDto>;

  // POST /api/v1/estimations/:id/approve
  @Post(':id/approve')
  async approve(@Param('id') id: UUID): Promise<EstimationDto>;

  // POST /api/v1/estimations/:id/reject
  @Post(':id/reject')
  async reject(@Param('id') id: UUID, @Body() dto: RejectEstimationDto): Promise<EstimationDto>;

  // GET /api/v1/estimations/:id/lines
  @Get(':id/lines')
  async getLines(@Param('id') id: UUID): Promise<EstimationLineDto[]>;

  // POST /api/v1/estimations/:id/lines
  @Post(':id/lines')
  async addLine(@Param('id') id: UUID, @Body() dto: CreateEstimationLineDto): Promise<EstimationLineDto>;

  // GET /api/v1/estimations/:id/export/pdf
  @Get(':id/export/pdf')
  async exportPdf(@Param('id') id: UUID): Promise<StreamableFile>;
}

Services

ProjectService

@Injectable()
export class ProjectService {
  constructor(
    @InjectRepository(Project)
    private readonly projectRepo: Repository<Project>,
    private readonly eventEmitter: EventEmitter2
  ) {}

  async findAll(tenantId: UUID, query: ProjectQueryDto): Promise<PaginatedResponse<ProjectDto>>;
  async findOne(tenantId: UUID, id: UUID): Promise<ProjectDto>;
  async create(tenantId: UUID, userId: UUID, dto: CreateProjectDto): Promise<ProjectDto>;
  async update(tenantId: UUID, id: UUID, dto: UpdateProjectDto): Promise<ProjectDto>;
  async remove(tenantId: UUID, id: UUID): Promise<void>;
  async getSummary(tenantId: UUID, id: UUID): Promise<ProjectSummaryDto>;
  async calculateProgress(projectId: UUID): Promise<number>;
}

BudgetService

@Injectable()
export class BudgetService {
  constructor(
    @InjectRepository(Budget)
    private readonly budgetRepo: Repository<Budget>,
    @InjectRepository(BudgetPartida)
    private readonly partidaRepo: Repository<BudgetPartida>,
    @InjectRepository(BudgetConcept)
    private readonly conceptRepo: Repository<BudgetConcept>,
    private readonly apuService: ApuService
  ) {}

  async findAll(tenantId: UUID, query: BudgetQueryDto): Promise<PaginatedResponse<BudgetDto>>;
  async findOne(tenantId: UUID, id: UUID): Promise<BudgetDetailDto>;
  async create(tenantId: UUID, userId: UUID, dto: CreateBudgetDto): Promise<BudgetDto>;
  async approve(tenantId: UUID, id: UUID, userId: UUID): Promise<BudgetDto>;
  async calculateTotals(budgetId: UUID): Promise<void>;
  async explodeMaterials(budgetId: UUID): Promise<number>;
  async importFromExcel(file: Express.Multer.File, budgetId: UUID): Promise<ImportResultDto>;
  async getComparison(tenantId: UUID, budgetId: UUID): Promise<BudgetComparisonDto>;
}

ApuService

@Injectable()
export class ApuService {
  constructor(
    @InjectRepository(APUItem)
    private readonly apuRepo: Repository<APUItem>
  ) {}

  async getApuItems(conceptId: UUID): Promise<APUItemDto[]>;
  async addApuItem(conceptId: UUID, dto: CreateApuItemDto): Promise<APUItemDto>;
  async updateApuItem(id: UUID, dto: UpdateApuItemDto): Promise<APUItemDto>;
  async removeApuItem(id: UUID): Promise<void>;
  async calculateConceptPrice(conceptId: UUID): Promise<number>;
}

ProgressService

@Injectable()
export class ProgressService {
  constructor(
    @InjectRepository(ProgressRecord)
    private readonly progressRepo: Repository<ProgressRecord>,
    private readonly housingUnitService: HousingUnitService,
    private readonly eventEmitter: EventEmitter2
  ) {}

  async findAll(tenantId: UUID, query: ProgressQueryDto): Promise<PaginatedResponse<ProgressRecordDto>>;
  async create(tenantId: UUID, userId: UUID, dto: CreateProgressDto): Promise<ProgressRecordDto>;
  async approve(tenantId: UUID, id: UUID, userId: UUID): Promise<ProgressRecordDto>;
  async getByProject(projectId: UUID): Promise<ProgressRecordDto[]>;
  async getByUnit(unitId: UUID): Promise<ProgressRecordDto[]>;
  async getCurveS(projectId: UUID): Promise<CurveSDto>;
  async uploadPhotos(progressId: UUID, files: Express.Multer.File[]): Promise<PhotoDto[]>;
}

EstimationService

@Injectable()
export class EstimationService {
  constructor(
    @InjectRepository(Estimation)
    private readonly estimationRepo: Repository<Estimation>,
    @InjectRepository(EstimationLine)
    private readonly lineRepo: Repository<EstimationLine>,
    private readonly financeIntegration: FinanceIntegrationService,
    private readonly eventEmitter: EventEmitter2
  ) {}

  async findAll(tenantId: UUID, query: EstimationQueryDto): Promise<PaginatedResponse<EstimationDto>>;
  async findOne(tenantId: UUID, id: UUID): Promise<EstimationDetailDto>;
  async create(tenantId: UUID, userId: UUID, dto: CreateEstimationDto): Promise<EstimationDto>;
  async submit(tenantId: UUID, id: UUID, userId: UUID): Promise<EstimationDto>;
  async approve(tenantId: UUID, id: UUID, userId: UUID): Promise<EstimationDto>;
  async reject(tenantId: UUID, id: UUID, userId: UUID, reason: string): Promise<EstimationDto>;
  async calculateTotals(estimationId: UUID): Promise<void>;
  async generateAR(estimationId: UUID): Promise<void>;  // Create Account Receivable
  async exportPdf(tenantId: UUID, id: UUID): Promise<Buffer>;
}

DTOs

Project DTOs

export class CreateProjectDto {
  @IsString() @MaxLength(20)
  code: string;

  @IsString() @MaxLength(200)
  name: string;

  @IsOptional() @IsString()
  description?: string;

  @IsOptional() @IsString()
  address?: string;

  @IsOptional() @IsString()
  city?: string;

  @IsOptional() @IsString()
  state?: string;

  @IsOptional() @IsDateString()
  startDate?: string;

  @IsOptional() @IsDateString()
  endDate?: string;

  @IsOptional() @IsUUID()
  projectManagerId?: string;

  @IsOptional() @IsNumber()
  totalBudget?: number;
}

export class ProjectDto {
  id: string;
  code: string;
  name: string;
  description?: string;
  address?: string;
  city?: string;
  state?: string;
  status: ProjectStatus;
  progressPercentage: number;
  startDate?: string;
  endDate?: string;
  totalBudget?: number;
  totalSpent: number;
  createdAt: string;
}

export class ProjectSummaryDto {
  id: string;
  code: string;
  name: string;
  status: ProjectStatus;
  progressPercentage: number;
  totalDevelopments: number;
  totalUnits: number;
  completedUnits: number;
  totalBudget: number;
  totalSpent: number;
  budgetVariance: number;
  budgetVariancePercentage: number;
  daysRemaining: number;
  isOnSchedule: boolean;
}

Budget DTOs

export class CreateBudgetDto {
  @IsString() @MaxLength(20)
  code: string;

  @IsString() @MaxLength(200)
  name: string;

  @IsOptional() @IsUUID()
  projectId?: string;

  @IsOptional() @IsUUID()
  prototypeId?: string;

  @IsOptional() @IsBoolean()
  isBase?: boolean;

  @IsOptional() @IsNumber()
  indirectPercentage?: number;

  @IsOptional() @IsNumber()
  profitPercentage?: number;
}

export class CreatePartidaDto {
  @IsString() @MaxLength(20)
  code: string;

  @IsString() @MaxLength(200)
  name: string;

  @IsOptional() @IsUUID()
  parentId?: string;

  @IsOptional() @IsNumber()
  sortOrder?: number;
}

export class CreateConceptDto {
  @IsUUID()
  partidaId: string;

  @IsString() @MaxLength(30)
  code: string;

  @IsString() @MaxLength(300)
  name: string;

  @IsString() @MaxLength(10)
  unitCode: string;

  @IsNumber()
  quantity: number;

  @IsNumber()
  unitPrice: number;

  @IsOptional() @IsEnum(ConceptType)
  conceptType?: ConceptType;
}

Progress DTOs

export class CreateProgressDto {
  @IsUUID()
  projectId: string;

  @IsOptional() @IsUUID()
  housingUnitId?: string;

  @IsOptional() @IsUUID()
  conceptId?: string;

  @IsDateString()
  recordDate: string;

  @IsEnum(ProgressType)
  progressType: ProgressType;

  @IsNumber() @Min(0) @Max(100)
  currentProgress: number;

  @IsOptional() @IsNumber()
  quantityExecuted?: number;

  @IsOptional() @IsString()
  notes?: string;
}

export class CurveSDto {
  projectId: string;
  periods: CurveSPeriodDto[];
  summary: {
    plannedProgress: number;
    actualProgress: number;
    variance: number;
    status: 'ahead' | 'on_track' | 'behind';
  };
}

export class CurveSPeriodDto {
  period: string;
  plannedAccumulated: number;
  actualAccumulated: number;
  variance: number;
}

Events

ProjectCreatedEvent

export class ProjectCreatedEvent {
  constructor(
    public readonly projectId: string,
    public readonly code: string,
    public readonly name: string,
    public readonly tenantId: string,
    public readonly createdBy: string,
    public readonly timestamp: Date = new Date()
  ) {}
}

ProgressRecordedEvent

export class ProgressRecordedEvent {
  constructor(
    public readonly progressRecordId: string,
    public readonly projectId: string,
    public readonly housingUnitId: string | null,
    public readonly progressIncrement: number,
    public readonly recordedBy: string,
    public readonly timestamp: Date = new Date()
  ) {}
}

EstimationApprovedEvent

export class EstimationApprovedEvent {
  constructor(
    public readonly estimationId: string,
    public readonly projectId: string,
    public readonly netAmount: number,
    public readonly approvedBy: string,
    public readonly timestamp: Date = new Date()
  ) {}
}

Event Handlers

@Injectable()
export class ConstructionEventHandlers {
  constructor(
    private readonly progressService: ProgressService,
    private readonly financeService: FinanceIntegrationService
  ) {}

  @OnEvent('progress.recorded')
  async handleProgressRecorded(event: ProgressRecordedEvent) {
    // Update housing unit progress
    if (event.housingUnitId) {
      await this.progressService.updateUnitProgress(event.housingUnitId);
    }

    // Update project progress
    await this.progressService.updateProjectProgress(event.projectId);
  }

  @OnEvent('estimation.approved')
  async handleEstimationApproved(event: EstimationApprovedEvent) {
    // Create Account Receivable in Finance module
    await this.financeService.createARFromEstimation(event.estimationId);
  }
}

Tests

Unit Tests Required

  • ProjectService: 10 tests
  • BudgetService: 12 tests
  • ApuService: 6 tests
  • ProgressService: 8 tests
  • EstimationService: 10 tests

Integration Tests Required

  • Project CRUD: 5 tests
  • Budget with partidas/concepts: 8 tests
  • Progress recording workflow: 5 tests
  • Estimation approval workflow: 6 tests

Referencias


Ultima actualizacion: 2025-12-05