Migración desde erp-construccion/backend - Estándar multi-repo v2

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-16 08:11:14 -06:00
parent aeb201d7f6
commit 7c1480a819
322 changed files with 64119 additions and 2 deletions

71
.env.example Normal file
View File

@ -0,0 +1,71 @@
# ============================================================================
# BACKEND ENVIRONMENT VARIABLES - ERP Construccion
# ============================================================================
# Proyecto: construccion
# Rango de puertos: 3100 (ver DEVENV-PORTS.md)
# Fecha: 2025-12-06
# ============================================================================
# Application
NODE_ENV=development
APP_PORT=3021
APP_HOST=0.0.0.0
API_VERSION=v1
API_PREFIX=/api/v1
# Database (Puerto 5433 - diferenciado de erp-core:5432)
DATABASE_URL=postgresql://erp_user:erp_dev_password@localhost:5433/erp_construccion
DB_HOST=localhost
DB_PORT=5433
DB_NAME=erp_construccion
DB_USER=erp_user
DB_PASSWORD=erp_dev_password
DB_SYNCHRONIZE=false
DB_LOGGING=true
# Redis (Puerto 6380 - diferenciado de erp-core:6379)
REDIS_HOST=localhost
REDIS_PORT=6380
REDIS_URL=redis://localhost:6380
# MinIO S3 (Puerto 9100 - diferenciado de erp-core:9000)
S3_ENDPOINT=http://localhost:9100
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
S3_BUCKET=erp-construccion
# JWT
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
JWT_EXPIRATION=24h
JWT_REFRESH_EXPIRATION=7d
# CORS (Frontend en puerto 5174)
CORS_ORIGIN=http://localhost:3020,http://localhost:5174
CORS_CREDENTIALS=true
# Rate Limiting
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
# Logging
LOG_LEVEL=debug
LOG_FORMAT=dev
# File Upload
MAX_FILE_SIZE=10485760
UPLOAD_DIR=./uploads
# Email (opcional)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@example.com
SMTP_PASSWORD=your-email-password
SMTP_FROM=noreply@example.com
# Security
BCRYPT_ROUNDS=10
SESSION_SECRET=your-session-secret-change-this
# External APIs (futuro)
INFONAVIT_API_URL=https://api.infonavit.gob.mx
INFONAVIT_API_KEY=your-api-key

84
Dockerfile Normal file
View File

@ -0,0 +1,84 @@
# =============================================================================
# Dockerfile - Backend API
# ERP Construccion - Node.js + Express + TypeScript
# =============================================================================
# -----------------------------------------------------------------------------
# Stage 1: Base
# -----------------------------------------------------------------------------
FROM node:20-alpine AS base
# Install dependencies for native modules
RUN apk add --no-cache \
python3 \
make \
g++ \
curl
WORKDIR /app
# Copy package files
COPY package*.json ./
# -----------------------------------------------------------------------------
# Stage 2: Development
# -----------------------------------------------------------------------------
FROM base AS development
# Install all dependencies (including devDependencies)
RUN npm ci
# Copy source code
COPY . .
# Expose port (standard: 3021 for construccion backend)
EXPOSE 3021
# Development command with hot reload
CMD ["npm", "run", "dev"]
# -----------------------------------------------------------------------------
# Stage 3: Builder
# -----------------------------------------------------------------------------
FROM base AS builder
# Install all dependencies
RUN npm ci
# Copy source code
COPY . .
# Build TypeScript
RUN npm run build
# Prune devDependencies
RUN npm prune --production
# -----------------------------------------------------------------------------
# Stage 4: Production
# -----------------------------------------------------------------------------
FROM node:20-alpine AS production
# Security: Run as non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
WORKDIR /app
# Copy built application
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./
# Set user
USER nodejs
# Expose port (standard: 3021 for construccion backend)
EXPOSE 3021
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:3021/health || exit 1
# Production command
CMD ["node", "dist/server.js"]

462
README.md
View File

@ -1,3 +1,461 @@
# erp-construccion-backend-v2
# Backend - ERP Construccion
Backend de erp-construccion - Workspace V2
API REST para sistema de administracion de obra e INFONAVIT.
| Campo | Valor |
|-------|-------|
| **Stack** | Node.js 20 + Express 4 + TypeScript 5 + TypeORM 0.3 |
| **Version** | 1.0.0 |
| **Entidades** | 30 |
| **Services** | 8 |
| **Arquitectura** | Multi-tenant con RLS |
---
## Quick Start
```bash
# Instalar dependencias
npm install
# Configurar variables de entorno
cp ../.env.example .env
# Desarrollo con hot-reload
npm run dev
# El servidor estara en http://localhost:3000
```
---
## Estructura del Proyecto
```
src/
├── modules/
│ ├── auth/ # Autenticacion JWT
│ │ ├── dto/
│ │ │ └── auth.dto.ts # DTOs tipados
│ │ ├── middleware/
│ │ │ └── auth.middleware.ts
│ │ ├── services/
│ │ │ └── auth.service.ts
│ │ └── index.ts
│ │
│ ├── budgets/ # MAI-003 Presupuestos
│ │ ├── entities/
│ │ │ ├── concepto.entity.ts
│ │ │ ├── presupuesto.entity.ts
│ │ │ └── presupuesto-partida.entity.ts
│ │ ├── services/
│ │ │ ├── concepto.service.ts
│ │ │ └── presupuesto.service.ts
│ │ └── index.ts
│ │
│ ├── progress/ # MAI-005 Control de Obra
│ │ ├── entities/
│ │ │ ├── avance-obra.entity.ts
│ │ │ ├── foto-avance.entity.ts
│ │ │ ├── bitacora-obra.entity.ts
│ │ │ ├── programa-obra.entity.ts
│ │ │ └── programa-actividad.entity.ts
│ │ ├── services/
│ │ │ ├── avance-obra.service.ts
│ │ │ └── bitacora-obra.service.ts
│ │ └── index.ts
│ │
│ ├── estimates/ # MAI-008 Estimaciones
│ │ ├── entities/
│ │ │ ├── estimacion.entity.ts
│ │ │ ├── estimacion-concepto.entity.ts
│ │ │ ├── generador.entity.ts
│ │ │ ├── anticipo.entity.ts
│ │ │ ├── amortizacion.entity.ts
│ │ │ ├── retencion.entity.ts
│ │ │ ├── fondo-garantia.entity.ts
│ │ │ └── estimacion-workflow.entity.ts
│ │ ├── services/
│ │ │ └── estimacion.service.ts
│ │ └── index.ts
│ │
│ ├── construction/ # MAI-002 Proyectos
│ │ └── entities/
│ │ ├── proyecto.entity.ts
│ │ └── fraccionamiento.entity.ts
│ │
│ ├── hr/ # MAI-007 RRHH
│ │ └── entities/
│ │ ├── employee.entity.ts
│ │ ├── puesto.entity.ts
│ │ └── employee-fraccionamiento.entity.ts
│ │
│ ├── hse/ # MAA-017 Seguridad HSE
│ │ └── entities/
│ │ ├── incidente.entity.ts
│ │ ├── incidente-involucrado.entity.ts
│ │ ├── incidente-accion.entity.ts
│ │ └── capacitacion.entity.ts
│ │
│ └── core/ # Entidades base
│ └── entities/
│ ├── user.entity.ts
│ └── tenant.entity.ts
└── shared/
├── constants/ # SSOT
│ ├── database.constants.ts
│ ├── api.constants.ts
│ ├── enums.constants.ts
│ └── index.ts
├── services/
│ └── base.service.ts # CRUD multi-tenant
└── database/
└── typeorm.config.ts
```
---
## Modulos Implementados
### Auth Module
Autenticacion JWT con refresh tokens y multi-tenancy.
```typescript
// Services
AuthService
├── login(dto) // Login con email/password
├── register(dto) // Registro de usuarios
├── refresh(dto) // Renovar tokens
├── logout(token) // Revocar refresh token
└── changePassword(dto) // Cambiar password
// Middleware
AuthMiddleware
├── authenticate // Validar JWT (requerido)
├── optionalAuthenticate // Validar JWT (opcional)
├── authorize(...roles) // Autorizar por roles
├── requireAdmin // Solo admin/super_admin
└── requireSupervisor // Solo supervisores+
```
### Budgets Module (MAI-003)
Catalogo de conceptos y presupuestos de obra.
```typescript
// Entities
Concepto // Catalogo jerarquico (arbol)
Presupuesto // Presupuestos versionados
PresupuestoPartida // Lineas con calculo automatico
// Services
ConceptoService
├── createConcepto(ctx, dto) // Crear con nivel/path automatico
├── findRootConceptos(ctx) // Conceptos raiz
├── findChildren(ctx, parentId) // Hijos de un concepto
├── getConceptoTree(ctx, rootId) // Arbol completo
└── search(ctx, term) // Busqueda por codigo/nombre
PresupuestoService
├── createPresupuesto(ctx, dto)
├── findByFraccionamiento(ctx, id)
├── findWithPartidas(ctx, id)
├── addPartida(ctx, id, dto)
├── updatePartida(ctx, id, dto)
├── removePartida(ctx, id)
├── recalculateTotal(ctx, id)
├── createNewVersion(ctx, id) // Versionamiento
└── approve(ctx, id)
```
### Progress Module (MAI-005)
Control de avances fisicos y bitacora de obra.
```typescript
// Entities
AvanceObra // Avances con workflow
FotoAvance // Evidencias fotograficas con GPS
BitacoraObra // Bitacora diaria
ProgramaObra // Programa maestro
ProgramaActividad // Actividades WBS
// Services
AvanceObraService
├── createAvance(ctx, dto)
├── findByLote(ctx, loteId)
├── findByDepartamento(ctx, deptoId)
├── findWithFilters(ctx, filters)
├── findWithFotos(ctx, id)
├── addFoto(ctx, id, dto)
├── review(ctx, id) // Workflow: revisar
├── approve(ctx, id) // Workflow: aprobar
├── reject(ctx, id, reason) // Workflow: rechazar
└── getAccumulatedProgress(ctx) // Acumulado por concepto
BitacoraObraService
├── createEntry(ctx, dto) // Numero automatico
├── findByFraccionamiento(ctx, id)
├── findWithFilters(ctx, id, filters)
├── findByDate(ctx, id, date)
├── findLatest(ctx, id)
└── getStats(ctx, id) // Estadisticas
```
### Estimates Module (MAI-008)
Estimaciones periodicas con workflow de aprobacion.
```typescript
// Entities
Estimacion // Estimaciones con workflow
EstimacionConcepto // Lineas con acumulados
Generador // Numeros generadores
Anticipo // Anticipos
Amortizacion // Amortizaciones
Retencion // Retenciones
FondoGarantia // Fondo de garantia
EstimacionWorkflow // Historial de estados
// Services
EstimacionService
├── createEstimacion(ctx, dto) // Numero automatico
├── findByContrato(ctx, contratoId)
├── findWithFilters(ctx, filters)
├── findWithDetails(ctx, id) // Con relaciones
├── addConcepto(ctx, id, dto)
├── addGenerador(ctx, conceptoId, dto)
├── recalculateTotals(ctx, id) // Llama funcion PG
├── submit(ctx, id) // Workflow
├── review(ctx, id) // Workflow
├── approve(ctx, id) // Workflow
├── reject(ctx, id, reason) // Workflow
└── getContractSummary(ctx, id) // Resumen financiero
```
---
## Base Service
Servicio base con CRUD multi-tenant.
```typescript
// Uso
class MiService extends BaseService<MiEntity> {
constructor(repository: Repository<MiEntity>) {
super(repository);
}
}
// Metodos disponibles
BaseService<T>
├── findAll(ctx, options?) // Paginado
├── findById(ctx, id)
├── findOne(ctx, where)
├── find(ctx, options)
├── create(ctx, data)
├── update(ctx, id, data)
├── softDelete(ctx, id)
├── hardDelete(ctx, id)
├── count(ctx, where?)
└── exists(ctx, where)
// ServiceContext
interface ServiceContext {
tenantId: string;
userId: string;
}
```
---
## SSOT Constants
Sistema de constantes centralizadas.
```typescript
// database.constants.ts
import { DB_SCHEMAS, DB_TABLES, TABLE_REFS } from '@shared/constants';
DB_SCHEMAS.CONSTRUCTION // 'construction'
DB_TABLES.construction.CONCEPTOS // 'conceptos'
TABLE_REFS.FRACCIONAMIENTOS // 'construction.fraccionamientos'
// api.constants.ts
import { API_ROUTES } from '@shared/constants';
API_ROUTES.PRESUPUESTOS.BASE // '/api/v1/presupuestos'
API_ROUTES.ESTIMACIONES.BY_ID(id) // '/api/v1/estimaciones/:id'
// enums.constants.ts
import { ROLES, PROJECT_STATUS } from '@shared/constants';
ROLES.ADMIN // 'admin'
PROJECT_STATUS.IN_PROGRESS // 'in_progress'
```
---
## Scripts NPM
```bash
# Desarrollo
npm run dev # Hot-reload con ts-node-dev
npm run build # Compilar TypeScript
npm run start # Produccion (dist/)
# Calidad
npm run lint # ESLint
npm run lint:fix # ESLint con autofix
npm run test # Jest
npm run test:watch # Jest watch mode
npm run test:coverage # Jest con cobertura
# Base de datos
npm run migration:generate # Generar migracion
npm run migration:run # Ejecutar migraciones
npm run migration:revert # Revertir ultima
# SSOT
npm run validate:constants # Validar no hardcoding
npm run sync:enums # Sincronizar a frontend
npm run precommit # lint + validate
```
---
## Convenciones
### Nomenclatura
| Tipo | Convencion | Ejemplo |
|------|------------|---------|
| Archivos | kebab-case.tipo.ts | `concepto.entity.ts` |
| Clases | PascalCase + sufijo | `ConceptoService` |
| Variables | camelCase | `totalAmount` |
| Constantes | UPPER_SNAKE_CASE | `DB_SCHEMAS` |
| Metodos | camelCase + verbo | `findByContrato` |
### Entity Pattern
```typescript
@Entity({ schema: 'construction', name: 'conceptos' })
@Index(['tenantId', 'code'], { unique: true })
export class Concepto {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
// ... columnas con name: 'snake_case'
// Soft delete
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date | null;
// Relations
@ManyToOne(() => Tenant)
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
}
```
### Service Pattern
```typescript
export class MiService extends BaseService<MiEntity> {
constructor(
repository: Repository<MiEntity>,
private readonly otroRepo: Repository<OtroEntity>
) {
super(repository);
}
async miMetodo(ctx: ServiceContext, data: MiDto): Promise<MiEntity> {
// ctx tiene tenantId y userId
return this.create(ctx, data);
}
}
```
---
## Seguridad
- Helmet para HTTP security headers
- CORS configurado por dominio
- Rate limiting por IP
- JWT con refresh tokens
- Bcrypt (12 rounds) para passwords
- class-validator para inputs
- RLS para aislamiento de tenants
---
## Testing
```bash
# Ejecutar tests
npm test
# Con cobertura
npm run test:coverage
# Watch mode
npm run test:watch
```
```typescript
// Ejemplo de test
describe('ConceptoService', () => {
let service: ConceptoService;
let mockRepo: jest.Mocked<Repository<Concepto>>;
beforeEach(() => {
mockRepo = createMockRepository();
service = new ConceptoService(mockRepo);
});
it('should create concepto with level', async () => {
const ctx = { tenantId: 'uuid', userId: 'uuid' };
const dto = { code: '001', name: 'Test' };
mockRepo.save.mockResolvedValue({ ...dto, level: 0 });
const result = await service.createConcepto(ctx, dto);
expect(result.level).toBe(0);
});
});
```
---
## Debugging
### VS Code
```json
{
"type": "node",
"request": "launch",
"name": "Debug Backend",
"runtimeArgs": ["-r", "ts-node/register"],
"args": ["${workspaceFolder}/src/server.ts"],
"env": { "NODE_ENV": "development" }
}
```
### Logs
```typescript
// Configurar en .env
LOG_LEVEL=debug
LOG_FORMAT=dev
```
---
**Ultima actualizacion:** 2025-12-12

7817
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

73
package.json Normal file
View File

@ -0,0 +1,73 @@
{
"name": "@construccion-mvp/backend",
"version": "1.0.0",
"description": "Backend API - MVP Sistema Administración de Obra e INFONAVIT",
"main": "dist/server.js",
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"lint": "eslint src/**/*.ts",
"lint:fix": "eslint src/**/*.ts --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"typeorm": "typeorm-ts-node-commonjs",
"migration:generate": "npm run typeorm -- migration:generate",
"migration:run": "npm run typeorm -- migration:run",
"migration:revert": "npm run typeorm -- migration:revert",
"validate:constants": "ts-node scripts/validate-constants-usage.ts",
"sync:enums": "ts-node scripts/sync-enums.ts",
"precommit": "npm run lint && npm run validate:constants"
},
"keywords": [
"construccion",
"erp",
"infonavit",
"nodejs",
"typescript",
"express",
"typeorm"
],
"author": "Tu Empresa",
"license": "UNLICENSED",
"dependencies": {
"express": "^4.18.2",
"typeorm": "^0.3.17",
"pg": "^8.11.3",
"reflect-metadata": "^0.1.13",
"class-validator": "^0.14.0",
"class-transformer": "^0.5.1",
"dotenv": "^16.3.1",
"cors": "^2.8.5",
"helmet": "^7.1.0",
"morgan": "^1.10.0",
"express-rate-limit": "^7.1.5",
"bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.2",
"swagger-ui-express": "^5.0.0",
"yamljs": "^0.3.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.10.5",
"@types/cors": "^2.8.17",
"@types/morgan": "^1.9.9",
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.5",
"@types/swagger-ui-express": "^4.1.6",
"@types/jest": "^29.5.11",
"@typescript-eslint/eslint-plugin": "^6.15.0",
"@typescript-eslint/parser": "^6.15.0",
"eslint": "^8.56.0",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"typescript": "^5.3.3"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=9.0.0"
}
}

120
scripts/sync-enums.ts Normal file
View File

@ -0,0 +1,120 @@
#!/usr/bin/env ts-node
/**
* Sync Enums - Backend to Frontend
*
* Este script sincroniza automaticamente las constantes y enums del backend
* al frontend, manteniendo el principio SSOT (Single Source of Truth).
*
* Ejecutar: npm run sync:enums
*
* @author Architecture-Analyst
* @date 2025-12-12
*/
import * as fs from 'fs';
import * as path from 'path';
// =============================================================================
// CONFIGURACION
// =============================================================================
const BACKEND_CONSTANTS_DIR = path.resolve(__dirname, '../src/shared/constants');
const FRONTEND_CONSTANTS_DIR = path.resolve(__dirname, '../../frontend/web/src/shared/constants');
// Archivos a sincronizar
const FILES_TO_SYNC = [
'enums.constants.ts',
'api.constants.ts',
];
// Header para archivos generados
const GENERATED_HEADER = `/**
* AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY
*
* Este archivo es generado automaticamente desde el backend.
* Cualquier cambio sera sobreescrito en la proxima sincronizacion.
*
* Fuente: backend/src/shared/constants/
* Generado: ${new Date().toISOString()}
*
* Para modificar, edita el archivo fuente en el backend
* y ejecuta: npm run sync:enums
*/
`;
// =============================================================================
// FUNCIONES
// =============================================================================
function ensureDirectoryExists(dir: string): void {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
console.log(`📁 Created directory: ${dir}`);
}
}
function processContent(content: string): string {
// Remover imports que no aplican al frontend
let processed = content
// Remover imports de Node.js
.replace(/import\s+\*\s+as\s+\w+\s+from\s+['"]fs['"];?\n?/g, '')
.replace(/import\s+\*\s+as\s+\w+\s+from\s+['"]path['"];?\n?/g, '')
// Remover comentarios de @module backend
.replace(/@module\s+@shared\/constants\//g, '@module shared/constants/')
// Mantener 'as const' para inferencia de tipos
;
return GENERATED_HEADER + processed;
}
function syncFile(filename: string): void {
const sourcePath = path.join(BACKEND_CONSTANTS_DIR, filename);
const destPath = path.join(FRONTEND_CONSTANTS_DIR, filename);
if (!fs.existsSync(sourcePath)) {
console.log(`⚠️ Source file not found: ${sourcePath}`);
return;
}
const content = fs.readFileSync(sourcePath, 'utf-8');
const processedContent = processContent(content);
fs.writeFileSync(destPath, processedContent);
console.log(`✅ Synced: ${filename}`);
}
function generateIndexFile(): void {
const indexContent = `${GENERATED_HEADER}
// Re-export all constants
export * from './enums.constants';
export * from './api.constants';
`;
const indexPath = path.join(FRONTEND_CONSTANTS_DIR, 'index.ts');
fs.writeFileSync(indexPath, indexContent);
console.log(`✅ Generated: index.ts`);
}
function main(): void {
console.log('🔄 Syncing constants from Backend to Frontend...\n');
console.log(`Source: ${BACKEND_CONSTANTS_DIR}`);
console.log(`Target: ${FRONTEND_CONSTANTS_DIR}\n`);
// Asegurar que el directorio destino existe
ensureDirectoryExists(FRONTEND_CONSTANTS_DIR);
// Sincronizar cada archivo
for (const file of FILES_TO_SYNC) {
syncFile(file);
}
// Generar archivo index
generateIndexFile();
console.log('\n✅ Sync completed successfully!');
console.log('\nRecuerda importar las constantes desde:');
console.log(' import { ROLES, PROJECT_STATUS, API_ROUTES } from "@/shared/constants";');
}
main();

View File

@ -0,0 +1,385 @@
#!/usr/bin/env ts-node
/**
* Validate Constants Usage - SSOT Enforcement
*
* Este script detecta hardcoding de schemas, tablas, rutas API y enums
* que deberian estar usando las constantes centralizadas del SSOT.
*
* Ejecutar: npm run validate:constants
*
* @author Architecture-Analyst
* @date 2025-12-12
*/
import * as fs from 'fs';
import * as path from 'path';
// =============================================================================
// CONFIGURACION
// =============================================================================
interface ValidationPattern {
pattern: RegExp;
message: string;
severity: 'P0' | 'P1' | 'P2';
suggestion: string;
exclude?: RegExp[];
}
const PATTERNS: ValidationPattern[] = [
// Database Schemas
{
pattern: /['"`]auth['"`](?!\s*:)/g,
message: 'Hardcoded schema "auth"',
severity: 'P0',
suggestion: 'Usa DB_SCHEMAS.AUTH',
exclude: [/from\s+['"`]\.\/database\.constants['"`]/],
},
{
pattern: /['"`]construction['"`](?!\s*:)/g,
message: 'Hardcoded schema "construction"',
severity: 'P0',
suggestion: 'Usa DB_SCHEMAS.CONSTRUCTION',
},
{
pattern: /['"`]hr['"`](?!\s*:)(?!\.entity)/g,
message: 'Hardcoded schema "hr"',
severity: 'P0',
suggestion: 'Usa DB_SCHEMAS.HR',
},
{
pattern: /['"`]hse['"`](?!\s*:)(?!\/)/g,
message: 'Hardcoded schema "hse"',
severity: 'P0',
suggestion: 'Usa DB_SCHEMAS.HSE',
},
{
pattern: /['"`]estimates['"`](?!\s*:)/g,
message: 'Hardcoded schema "estimates"',
severity: 'P0',
suggestion: 'Usa DB_SCHEMAS.ESTIMATES',
},
{
pattern: /['"`]infonavit['"`](?!\s*:)/g,
message: 'Hardcoded schema "infonavit"',
severity: 'P0',
suggestion: 'Usa DB_SCHEMAS.INFONAVIT',
},
{
pattern: /['"`]inventory['"`](?!\s*:)/g,
message: 'Hardcoded schema "inventory"',
severity: 'P0',
suggestion: 'Usa DB_SCHEMAS.INVENTORY',
},
{
pattern: /['"`]purchase['"`](?!\s*:)/g,
message: 'Hardcoded schema "purchase"',
severity: 'P0',
suggestion: 'Usa DB_SCHEMAS.PURCHASE',
},
// API Routes
{
pattern: /['"`]\/api\/v1\/proyectos['"`]/g,
message: 'Hardcoded API route "/api/v1/proyectos"',
severity: 'P0',
suggestion: 'Usa API_ROUTES.PROYECTOS.BASE',
},
{
pattern: /['"`]\/api\/v1\/fraccionamientos['"`]/g,
message: 'Hardcoded API route "/api/v1/fraccionamientos"',
severity: 'P0',
suggestion: 'Usa API_ROUTES.FRACCIONAMIENTOS.BASE',
},
{
pattern: /['"`]\/api\/v1\/employees['"`]/g,
message: 'Hardcoded API route "/api/v1/employees"',
severity: 'P0',
suggestion: 'Usa API_ROUTES.EMPLOYEES.BASE',
},
{
pattern: /['"`]\/api\/v1\/incidentes['"`]/g,
message: 'Hardcoded API route "/api/v1/incidentes"',
severity: 'P0',
suggestion: 'Usa API_ROUTES.INCIDENTES.BASE',
},
// Common Table Names
{
pattern: /FROM\s+proyectos(?!\s+AS|\s+WHERE)/gi,
message: 'Hardcoded table name "proyectos"',
severity: 'P1',
suggestion: 'Usa DB_TABLES.CONSTRUCTION.PROYECTOS',
},
{
pattern: /FROM\s+fraccionamientos(?!\s+AS|\s+WHERE)/gi,
message: 'Hardcoded table name "fraccionamientos"',
severity: 'P1',
suggestion: 'Usa DB_TABLES.CONSTRUCTION.FRACCIONAMIENTOS',
},
{
pattern: /FROM\s+employees(?!\s+AS|\s+WHERE)/gi,
message: 'Hardcoded table name "employees"',
severity: 'P1',
suggestion: 'Usa DB_TABLES.HR.EMPLOYEES',
},
{
pattern: /FROM\s+incidentes(?!\s+AS|\s+WHERE)/gi,
message: 'Hardcoded table name "incidentes"',
severity: 'P1',
suggestion: 'Usa DB_TABLES.HSE.INCIDENTES',
},
// Status Values
{
pattern: /status\s*===?\s*['"`]active['"`]/gi,
message: 'Hardcoded status "active"',
severity: 'P1',
suggestion: 'Usa PROJECT_STATUS.ACTIVE o USER_STATUS.ACTIVE',
},
{
pattern: /status\s*===?\s*['"`]borrador['"`]/gi,
message: 'Hardcoded status "borrador"',
severity: 'P1',
suggestion: 'Usa BUDGET_STATUS.DRAFT o ESTIMATION_STATUS.DRAFT',
},
{
pattern: /status\s*===?\s*['"`]aprobado['"`]/gi,
message: 'Hardcoded status "aprobado"',
severity: 'P1',
suggestion: 'Usa BUDGET_STATUS.APPROVED o ESTIMATION_STATUS.APPROVED',
},
// Role Names
{
pattern: /role\s*===?\s*['"`]admin['"`]/gi,
message: 'Hardcoded role "admin"',
severity: 'P0',
suggestion: 'Usa ROLES.ADMIN',
},
{
pattern: /role\s*===?\s*['"`]supervisor['"`]/gi,
message: 'Hardcoded role "supervisor"',
severity: 'P1',
suggestion: 'Usa ROLES.SUPERVISOR_OBRA o ROLES.SUPERVISOR_HSE',
},
];
// Archivos a excluir
const EXCLUDED_PATHS = [
'node_modules',
'dist',
'.git',
'coverage',
'database.constants.ts',
'api.constants.ts',
'enums.constants.ts',
'index.ts',
'.sql',
'.md',
'.json',
'.yml',
'.yaml',
];
// Extensiones a validar
const VALID_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx'];
// =============================================================================
// TIPOS
// =============================================================================
interface Violation {
file: string;
line: number;
column: number;
pattern: string;
message: string;
severity: 'P0' | 'P1' | 'P2';
suggestion: string;
context: string;
}
// =============================================================================
// FUNCIONES
// =============================================================================
function shouldExclude(filePath: string): boolean {
return EXCLUDED_PATHS.some(excluded => filePath.includes(excluded));
}
function hasValidExtension(filePath: string): boolean {
return VALID_EXTENSIONS.some(ext => filePath.endsWith(ext));
}
function getFiles(dir: string): string[] {
const files: string[] = [];
if (!fs.existsSync(dir)) {
return files;
}
const items = fs.readdirSync(dir);
for (const item of items) {
const fullPath = path.join(dir, item);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
if (!shouldExclude(fullPath)) {
files.push(...getFiles(fullPath));
}
} else if (stat.isFile() && hasValidExtension(fullPath) && !shouldExclude(fullPath)) {
files.push(fullPath);
}
}
return files;
}
function findViolations(filePath: string, content: string, patterns: ValidationPattern[]): Violation[] {
const violations: Violation[] = [];
const lines = content.split('\n');
for (const patternConfig of patterns) {
let match: RegExpExecArray | null;
const regex = new RegExp(patternConfig.pattern.source, patternConfig.pattern.flags);
while ((match = regex.exec(content)) !== null) {
// Check exclusions
if (patternConfig.exclude) {
const shouldSkip = patternConfig.exclude.some(excludePattern =>
excludePattern.test(content)
);
if (shouldSkip) continue;
}
// Find line number
const beforeMatch = content.substring(0, match.index);
const lineNumber = beforeMatch.split('\n').length;
const lineStart = beforeMatch.lastIndexOf('\n') + 1;
const column = match.index - lineStart + 1;
violations.push({
file: filePath,
line: lineNumber,
column,
pattern: match[0],
message: patternConfig.message,
severity: patternConfig.severity,
suggestion: patternConfig.suggestion,
context: lines[lineNumber - 1]?.trim() || '',
});
}
}
return violations;
}
function formatViolation(v: Violation): string {
const severityColor = {
P0: '\x1b[31m', // Red
P1: '\x1b[33m', // Yellow
P2: '\x1b[36m', // Cyan
};
const reset = '\x1b[0m';
return `
${severityColor[v.severity]}[${v.severity}]${reset} ${v.message}
File: ${v.file}:${v.line}:${v.column}
Found: "${v.pattern}"
Context: ${v.context}
Suggestion: ${v.suggestion}
`;
}
function generateReport(violations: Violation[]): void {
const p0 = violations.filter(v => v.severity === 'P0');
const p1 = violations.filter(v => v.severity === 'P1');
const p2 = violations.filter(v => v.severity === 'P2');
console.log('\n========================================');
console.log('SSOT VALIDATION REPORT');
console.log('========================================\n');
console.log(`Total Violations: ${violations.length}`);
console.log(` P0 (Critical): ${p0.length}`);
console.log(` P1 (High): ${p1.length}`);
console.log(` P2 (Medium): ${p2.length}`);
if (violations.length > 0) {
console.log('\n----------------------------------------');
console.log('VIOLATIONS FOUND:');
console.log('----------------------------------------');
// Group by file
const byFile = violations.reduce((acc, v) => {
if (!acc[v.file]) acc[v.file] = [];
acc[v.file].push(v);
return acc;
}, {} as Record<string, Violation[]>);
for (const [file, fileViolations] of Object.entries(byFile)) {
console.log(`\n📁 ${file}`);
for (const v of fileViolations) {
console.log(formatViolation(v));
}
}
}
console.log('\n========================================');
if (p0.length > 0) {
console.log('\n❌ FAILED: P0 violations found. Fix before merging.\n');
process.exit(1);
} else if (violations.length > 0) {
console.log('\n⚠ WARNING: Non-critical violations found. Consider fixing.\n');
process.exit(0);
} else {
console.log('\n✅ PASSED: No SSOT violations found!\n');
process.exit(0);
}
}
// =============================================================================
// MAIN
// =============================================================================
function main(): void {
const backendDir = path.resolve(__dirname, '../src');
const frontendDir = path.resolve(__dirname, '../../frontend/web/src');
console.log('🔍 Validating SSOT constants usage...\n');
console.log(`Backend: ${backendDir}`);
console.log(`Frontend: ${frontendDir}`);
const allViolations: Violation[] = [];
// Scan backend
if (fs.existsSync(backendDir)) {
const backendFiles = getFiles(backendDir);
console.log(`\nScanning ${backendFiles.length} backend files...`);
for (const file of backendFiles) {
const content = fs.readFileSync(file, 'utf-8');
const violations = findViolations(file, content, PATTERNS);
allViolations.push(...violations);
}
}
// Scan frontend
if (fs.existsSync(frontendDir)) {
const frontendFiles = getFiles(frontendDir);
console.log(`Scanning ${frontendFiles.length} frontend files...`);
for (const file of frontendFiles) {
const content = fs.readFileSync(file, 'utf-8');
const violations = findViolations(file, content, PATTERNS);
allViolations.push(...violations);
}
}
generateReport(allViolations);
}
main();

169
service.descriptor.yml Normal file
View File

@ -0,0 +1,169 @@
# ==============================================================================
# SERVICE DESCRIPTOR - ERP CONSTRUCCION API
# ==============================================================================
# API especializada para construccion residencial
# Mantenido por: Backend-Agent
# Actualizado: 2025-12-18
# ==============================================================================
version: "1.0.0"
# ------------------------------------------------------------------------------
# IDENTIFICACION DEL SERVICIO
# ------------------------------------------------------------------------------
service:
name: "erp-construccion-api"
display_name: "ERP Construccion API"
description: "API especializada para gestion de construccion residencial"
type: "backend"
runtime: "node"
framework: "express"
owner_agent: "NEXUS-BACKEND"
# ------------------------------------------------------------------------------
# CONFIGURACION DE PUERTOS
# ------------------------------------------------------------------------------
ports:
internal: 3020
registry_ref: "projects.erp_suite.verticales.construccion.api"
protocol: "http"
# ------------------------------------------------------------------------------
# CONFIGURACION DE BASE DE DATOS
# ------------------------------------------------------------------------------
database:
registry_ref: "erp_construccion"
schemas:
- "construction"
- "budgets"
- "estimates"
- "progress"
- "hr"
- "hse"
- "inventory"
- "purchase"
role: "runtime"
# ------------------------------------------------------------------------------
# DEPENDENCIAS
# ------------------------------------------------------------------------------
dependencies:
services:
- name: "erp-core-api"
type: "backend"
required: true
description: "Core ERP para auth y catalogos"
- name: "postgres"
type: "database"
required: true
# ------------------------------------------------------------------------------
# MODULOS ESPECIALIZADOS
# ------------------------------------------------------------------------------
modules:
construction:
description: "Gestion de construccion"
entities:
- fraccionamiento
- etapa
- manzana
- lote
- prototipo
endpoints:
- { path: "/fraccionamientos", methods: ["GET", "POST"] }
- { path: "/etapas", methods: ["GET", "POST"] }
- { path: "/manzanas", methods: ["GET", "POST"] }
- { path: "/lotes", methods: ["GET", "POST"] }
budgets:
description: "Presupuestos de obra"
entities:
- presupuesto
- partida
- concepto
endpoints:
- { path: "/presupuestos", methods: ["GET", "POST"] }
estimates:
description: "Estimaciones"
entities:
- estimacion
- detalle_estimacion
endpoints:
- { path: "/estimaciones", methods: ["GET", "POST"] }
progress:
description: "Avance de obra"
entities:
- avance_obra
- bitacora
endpoints:
- { path: "/avance-obra", methods: ["GET", "POST"] }
hr:
description: "Recursos humanos"
entities:
- empleado
- nomina
- asistencia
hse:
description: "Seguridad e higiene"
entities:
- incidente
- capacitacion
inventory:
description: "Inventario de materiales"
entities:
- material
- movimiento_inventario
purchase:
description: "Compras"
entities:
- orden_compra
- proveedor
# ------------------------------------------------------------------------------
# DOCKER
# ------------------------------------------------------------------------------
docker:
image: "erp-construccion-api"
dockerfile: "Dockerfile"
networks:
- "erp_construccion_${ENV:-local}"
- "erp_core_${ENV:-local}"
- "infra_shared"
labels:
traefik:
enable: true
router: "erp-construccion-api"
rule: "Host(`api.construccion.erp.localhost`)"
# ------------------------------------------------------------------------------
# HEALTH CHECK
# ------------------------------------------------------------------------------
healthcheck:
endpoint: "/health"
interval: "30s"
timeout: "5s"
retries: 3
# ------------------------------------------------------------------------------
# ESTADO
# ------------------------------------------------------------------------------
status:
phase: "development"
version: "0.1.0"
completeness: 40
# ------------------------------------------------------------------------------
# METADATA
# ------------------------------------------------------------------------------
metadata:
created_at: "2025-12-18"
created_by: "Backend-Agent"
project: "erp-suite"
vertical: "construccion"
team: "construccion-team"

View File

@ -0,0 +1,229 @@
/**
* AuditLogController - Controller de Logs de Auditoría
*
* Endpoints REST para consulta de logs de auditoría.
*
* @module Admin
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { AuditLogService, AuditLogFilters } from '../services/audit-log.service';
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
import { AuthService } from '../../auth/services/auth.service';
import { AuditLog } from '../entities/audit-log.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
export function createAuditLogController(dataSource: DataSource): Router {
const router = Router();
// Repositorios
const auditLogRepository = dataSource.getRepository(AuditLog);
const userRepository = dataSource.getRepository(User);
const tenantRepository = dataSource.getRepository(Tenant);
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
// Servicios
const auditLogService = new AuditLogService(auditLogRepository);
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
const authMiddleware = new AuthMiddleware(authService, dataSource);
// Helper para crear contexto
const getContext = (req: Request): ServiceContext => {
if (!req.tenantId) {
throw new Error('Tenant ID is required');
}
return {
tenantId: req.tenantId,
userId: req.user?.sub,
};
};
/**
* GET /audit-logs
*/
router.get('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'auditor'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 50, 200);
const filters: AuditLogFilters = {};
if (req.query.userId) filters.userId = req.query.userId as string;
if (req.query.category) filters.category = req.query.category as any;
if (req.query.action) filters.action = req.query.action as any;
if (req.query.severity) filters.severity = req.query.severity as any;
if (req.query.entityType) filters.entityType = req.query.entityType as string;
if (req.query.entityId) filters.entityId = req.query.entityId as string;
if (req.query.module) filters.module = req.query.module as string;
if (req.query.isSuccess !== undefined) filters.isSuccess = req.query.isSuccess === 'true';
if (req.query.dateFrom) filters.dateFrom = new Date(req.query.dateFrom as string);
if (req.query.dateTo) filters.dateTo = new Date(req.query.dateTo as string);
if (req.query.ipAddress) filters.ipAddress = req.query.ipAddress as string;
if (req.query.search) filters.search = req.query.search as string;
const result = await auditLogService.findWithFilters(getContext(req), filters, page, limit);
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
});
} catch (error) {
next(error);
}
});
/**
* GET /audit-logs/stats
*/
router.get('/stats', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'auditor'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const days = parseInt(req.query.days as string) || 30;
const stats = await auditLogService.getStats(getContext(req), days);
res.status(200).json({ success: true, data: stats });
} catch (error) {
next(error);
}
});
/**
* GET /audit-logs/critical
*/
router.get('/critical', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const days = parseInt(req.query.days as string) || 7;
const limit = Math.min(parseInt(req.query.limit as string) || 100, 500);
const logs = await auditLogService.getCriticalLogs(getContext(req), days, limit);
res.status(200).json({ success: true, data: logs });
} catch (error) {
next(error);
}
});
/**
* GET /audit-logs/failed
*/
router.get('/failed', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const hours = parseInt(req.query.hours as string) || 24;
const limit = Math.min(parseInt(req.query.limit as string) || 100, 500);
const logs = await auditLogService.getFailedLogs(getContext(req), hours, limit);
res.status(200).json({ success: true, data: logs });
} catch (error) {
next(error);
}
});
/**
* GET /audit-logs/entity/:type/:id
*/
router.get('/entity/:type/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'auditor'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const result = await auditLogService.findByEntity(
getContext(req),
req.params.type,
req.params.id,
page,
limit
);
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
});
} catch (error) {
next(error);
}
});
/**
* GET /audit-logs/user/:userId
*/
router.get('/user/:userId', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'auditor'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 50, 200);
const result = await auditLogService.findByUser(
getContext(req),
req.params.userId,
page,
limit
);
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
});
} catch (error) {
next(error);
}
});
/**
* POST /audit-logs/cleanup
* Cleanup expired logs
*/
router.post('/cleanup', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const deleted = await auditLogService.cleanupExpiredLogs(getContext(req));
res.status(200).json({
success: true,
message: `Cleaned up ${deleted} expired audit logs`,
data: { deleted },
});
} catch (error) {
next(error);
}
});
return router;
}
export default createAuditLogController;

View File

@ -0,0 +1,283 @@
/**
* BackupController - Controller de Backups
*
* Endpoints REST para gestión de backups del sistema.
*
* @module Admin
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { BackupService, CreateBackupDto, BackupFilters } from '../services/backup.service';
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
import { AuthService } from '../../auth/services/auth.service';
import { Backup } from '../entities/backup.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
export function createBackupController(dataSource: DataSource): Router {
const router = Router();
// Repositorios
const backupRepository = dataSource.getRepository(Backup);
const userRepository = dataSource.getRepository(User);
const tenantRepository = dataSource.getRepository(Tenant);
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
// Servicios
const backupService = new BackupService(backupRepository);
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
const authMiddleware = new AuthMiddleware(authService, dataSource);
// Helper para crear contexto
const getContext = (req: Request): ServiceContext => {
if (!req.tenantId) {
throw new Error('Tenant ID is required');
}
return {
tenantId: req.tenantId,
userId: req.user?.sub,
};
};
/**
* GET /backups
*/
router.get('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const filters: BackupFilters = {};
if (req.query.backupType) filters.backupType = req.query.backupType as any;
if (req.query.status) filters.status = req.query.status as any;
if (req.query.storageLocation) filters.storageLocation = req.query.storageLocation as any;
if (req.query.isScheduled !== undefined) filters.isScheduled = req.query.isScheduled === 'true';
if (req.query.isVerified !== undefined) filters.isVerified = req.query.isVerified === 'true';
if (req.query.dateFrom) filters.dateFrom = new Date(req.query.dateFrom as string);
if (req.query.dateTo) filters.dateTo = new Date(req.query.dateTo as string);
const result = await backupService.findWithFilters(getContext(req), filters, page, limit);
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
});
} catch (error) {
next(error);
}
});
/**
* GET /backups/stats
*/
router.get('/stats', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const stats = await backupService.getStats(getContext(req));
res.status(200).json({ success: true, data: stats });
} catch (error) {
next(error);
}
});
/**
* GET /backups/last
*/
router.get('/last', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const backupType = req.query.backupType as any;
const backup = await backupService.getLastSuccessful(getContext(req), backupType);
if (!backup) {
res.status(404).json({ error: 'Not Found', message: 'No successful backup found' });
return;
}
res.status(200).json({ success: true, data: backup });
} catch (error) {
next(error);
}
});
/**
* GET /backups/pending-verification
*/
router.get('/pending-verification', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const backups = await backupService.getPendingVerification(getContext(req));
res.status(200).json({ success: true, data: backups });
} catch (error) {
next(error);
}
});
/**
* GET /backups/:id
*/
router.get('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const backup = await backupService.findById(getContext(req), req.params.id);
if (!backup) {
res.status(404).json({ error: 'Not Found', message: 'Backup not found' });
return;
}
res.status(200).json({ success: true, data: backup });
} catch (error) {
next(error);
}
});
/**
* POST /backups
*/
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const dto: CreateBackupDto = req.body;
if (!dto.backupType || !dto.name) {
res.status(400).json({
error: 'Bad Request',
message: 'backupType and name are required',
});
return;
}
const backup = await backupService.initiateBackup(getContext(req), dto);
res.status(201).json({ success: true, data: backup });
} catch (error) {
next(error);
}
});
/**
* POST /backups/:id/verify
*/
router.post('/:id/verify', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const backup = await backupService.markVerified(getContext(req), req.params.id);
if (!backup) {
res.status(404).json({ error: 'Not Found', message: 'Backup not found' });
return;
}
res.status(200).json({ success: true, data: backup });
} catch (error) {
if (error instanceof Error && error.message.includes('Only completed')) {
res.status(400).json({ error: 'Bad Request', message: error.message });
return;
}
next(error);
}
});
/**
* POST /backups/:id/cancel
*/
router.post('/:id/cancel', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const backup = await backupService.cancelBackup(getContext(req), req.params.id);
if (!backup) {
res.status(404).json({ error: 'Not Found', message: 'Backup not found' });
return;
}
res.status(200).json({ success: true, data: backup });
} catch (error) {
if (error instanceof Error && error.message.includes('Only pending')) {
res.status(400).json({ error: 'Bad Request', message: error.message });
return;
}
next(error);
}
});
/**
* POST /backups/cleanup
*/
router.post('/cleanup', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const expired = await backupService.cleanupExpired(getContext(req));
res.status(200).json({
success: true,
message: `Marked ${expired} backups as expired`,
data: { expired },
});
} catch (error) {
next(error);
}
});
/**
* DELETE /backups/:id
*/
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const deleted = await backupService.softDelete(getContext(req), req.params.id);
if (!deleted) {
res.status(404).json({ error: 'Not Found', message: 'Backup not found' });
return;
}
res.status(200).json({ success: true, message: 'Backup deleted' });
} catch (error) {
next(error);
}
});
return router;
}
export default createBackupController;

View File

@ -0,0 +1,280 @@
/**
* CostCenterController - Controller de Centros de Costo
*
* Endpoints REST para gestión de centros de costo jerárquicos.
*
* @module Admin
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { CostCenterService, CreateCostCenterDto, UpdateCostCenterDto, CostCenterFilters } from '../services/cost-center.service';
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
import { AuthService } from '../../auth/services/auth.service';
import { CostCenter } from '../entities/cost-center.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
export function createCostCenterController(dataSource: DataSource): Router {
const router = Router();
// Repositorios
const costCenterRepository = dataSource.getRepository(CostCenter);
const userRepository = dataSource.getRepository(User);
const tenantRepository = dataSource.getRepository(Tenant);
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
// Servicios
const costCenterService = new CostCenterService(costCenterRepository);
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
const authMiddleware = new AuthMiddleware(authService, dataSource);
// Helper para crear contexto
const getContext = (req: Request): ServiceContext => {
if (!req.tenantId) {
throw new Error('Tenant ID is required');
}
return {
tenantId: req.tenantId,
userId: req.user?.sub,
};
};
/**
* GET /cost-centers
*/
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const filters: CostCenterFilters = {};
if (req.query.costCenterType) filters.costCenterType = req.query.costCenterType as any;
if (req.query.level) filters.level = req.query.level as any;
if (req.query.fraccionamientoId) filters.fraccionamientoId = req.query.fraccionamientoId as string;
if (req.query.parentId) filters.parentId = req.query.parentId as string;
if (req.query.responsibleId) filters.responsibleId = req.query.responsibleId as string;
if (req.query.isActive !== undefined) filters.isActive = req.query.isActive === 'true';
if (req.query.search) filters.search = req.query.search as string;
const result = await costCenterService.findWithFilters(getContext(req), filters, page, limit);
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
});
} catch (error) {
next(error);
}
});
/**
* GET /cost-centers/tree
*/
router.get('/tree', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const fraccionamientoId = req.query.fraccionamientoId as string | undefined;
const tree = await costCenterService.getTree(getContext(req), fraccionamientoId);
res.status(200).json({ success: true, data: tree });
} catch (error) {
next(error);
}
});
/**
* GET /cost-centers/stats
*/
router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const stats = await costCenterService.getStats(getContext(req));
res.status(200).json({ success: true, data: stats });
} catch (error) {
next(error);
}
});
/**
* GET /cost-centers/budget-summary
*/
router.get('/budget-summary', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const fraccionamientoId = req.query.fraccionamientoId as string | undefined;
const summary = await costCenterService.getBudgetSummary(getContext(req), fraccionamientoId);
res.status(200).json({ success: true, data: summary });
} catch (error) {
next(error);
}
});
/**
* GET /cost-centers/code/:code
*/
router.get('/code/:code', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const cc = await costCenterService.findByCode(getContext(req), req.params.code);
if (!cc) {
res.status(404).json({ error: 'Not Found', message: 'Cost center not found' });
return;
}
res.status(200).json({ success: true, data: cc });
} catch (error) {
next(error);
}
});
/**
* GET /cost-centers/:id
*/
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const cc = await costCenterService.findById(getContext(req), req.params.id);
if (!cc) {
res.status(404).json({ error: 'Not Found', message: 'Cost center not found' });
return;
}
res.status(200).json({ success: true, data: cc });
} catch (error) {
next(error);
}
});
/**
* GET /cost-centers/:id/children
*/
router.get('/:id/children', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const children = await costCenterService.getChildren(getContext(req), req.params.id);
res.status(200).json({ success: true, data: children });
} catch (error) {
next(error);
}
});
/**
* POST /cost-centers
*/
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'finance'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const dto: CreateCostCenterDto = req.body;
if (!dto.code || !dto.name) {
res.status(400).json({
error: 'Bad Request',
message: 'code and name are required',
});
return;
}
const cc = await costCenterService.createCostCenter(getContext(req), dto);
res.status(201).json({ success: true, data: cc });
} catch (error) {
if (error instanceof Error) {
if (error.message.includes('already exists')) {
res.status(409).json({ error: 'Conflict', message: error.message });
return;
}
if (error.message.includes('not found')) {
res.status(400).json({ error: 'Bad Request', message: error.message });
return;
}
}
next(error);
}
});
/**
* PUT /cost-centers/:id
*/
router.put('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'finance'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const dto: UpdateCostCenterDto = req.body;
const cc = await costCenterService.update(getContext(req), req.params.id, dto);
if (!cc) {
res.status(404).json({ error: 'Not Found', message: 'Cost center not found' });
return;
}
res.status(200).json({ success: true, data: cc });
} catch (error) {
next(error);
}
});
/**
* DELETE /cost-centers/:id
*/
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const deleted = await costCenterService.softDelete(getContext(req), req.params.id);
if (!deleted) {
res.status(404).json({ error: 'Not Found', message: 'Cost center not found' });
return;
}
res.status(200).json({ success: true, message: 'Cost center deleted' });
} catch (error) {
next(error);
}
});
return router;
}
export default createCostCenterController;

View File

@ -0,0 +1,9 @@
/**
* Admin Controllers Index
* @module Admin
*/
export { createCostCenterController } from './cost-center.controller';
export { createAuditLogController } from './audit-log.controller';
export { createSystemSettingController } from './system-setting.controller';
export { createBackupController } from './backup.controller';

View File

@ -0,0 +1,370 @@
/**
* SystemSettingController - Controller de Configuración del Sistema
*
* Endpoints REST para gestión de configuraciones.
*
* @module Admin
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { SystemSettingService, CreateSettingDto, UpdateSettingDto, SettingFilters } from '../services/system-setting.service';
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
import { AuthService } from '../../auth/services/auth.service';
import { SystemSetting } from '../entities/system-setting.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
export function createSystemSettingController(dataSource: DataSource): Router {
const router = Router();
// Repositorios
const settingRepository = dataSource.getRepository(SystemSetting);
const userRepository = dataSource.getRepository(User);
const tenantRepository = dataSource.getRepository(Tenant);
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
// Servicios
const settingService = new SystemSettingService(settingRepository);
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
const authMiddleware = new AuthMiddleware(authService, dataSource);
// Helper para crear contexto
const getContext = (req: Request): ServiceContext => {
if (!req.tenantId) {
throw new Error('Tenant ID is required');
}
return {
tenantId: req.tenantId,
userId: req.user?.sub,
};
};
/**
* GET /settings
*/
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
const filters: SettingFilters = {};
if (req.query.category) filters.category = req.query.category as any;
if (req.query.isPublic !== undefined) filters.isPublic = req.query.isPublic === 'true';
if (req.query.search) filters.search = req.query.search as string;
const result = await settingService.findWithFilters(getContext(req), filters, page, limit);
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
});
} catch (error) {
next(error);
}
});
/**
* GET /settings/public
*/
router.get('/public', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const settings = await settingService.getPublicSettings(getContext(req));
res.status(200).json({ success: true, data: settings });
} catch (error) {
next(error);
}
});
/**
* GET /settings/stats
*/
router.get('/stats', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const stats = await settingService.getStats(getContext(req));
res.status(200).json({ success: true, data: stats });
} catch (error) {
next(error);
}
});
/**
* GET /settings/category/:category
*/
router.get('/category/:category', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const settings = await settingService.findByCategory(getContext(req), req.params.category as any);
res.status(200).json({ success: true, data: settings });
} catch (error) {
next(error);
}
});
/**
* GET /settings/key/:key
*/
router.get('/key/:key', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const setting = await settingService.findByKey(getContext(req), req.params.key);
if (!setting) {
res.status(404).json({ error: 'Not Found', message: 'Setting not found' });
return;
}
res.status(200).json({ success: true, data: setting });
} catch (error) {
next(error);
}
});
/**
* GET /settings/key/:key/value
*/
router.get('/key/:key/value', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const value = await settingService.getValue(getContext(req), req.params.key);
if (value === null) {
res.status(404).json({ error: 'Not Found', message: 'Setting not found' });
return;
}
res.status(200).json({ success: true, data: { key: req.params.key, value } });
} catch (error) {
next(error);
}
});
/**
* GET /settings/:id
*/
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const setting = await settingService.findById(getContext(req), req.params.id);
if (!setting) {
res.status(404).json({ error: 'Not Found', message: 'Setting not found' });
return;
}
res.status(200).json({ success: true, data: setting });
} catch (error) {
next(error);
}
});
/**
* POST /settings
*/
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const dto: CreateSettingDto = req.body;
if (!dto.key || !dto.name || dto.value === undefined) {
res.status(400).json({
error: 'Bad Request',
message: 'key, name, and value are required',
});
return;
}
const setting = await settingService.createSetting(getContext(req), dto);
res.status(201).json({ success: true, data: setting });
} catch (error) {
if (error instanceof Error) {
if (error.message.includes('already exists')) {
res.status(409).json({ error: 'Conflict', message: error.message });
return;
}
if (error.message.includes('must be') || error.message.includes('pattern')) {
res.status(400).json({ error: 'Bad Request', message: error.message });
return;
}
}
next(error);
}
});
/**
* PUT /settings/:id
*/
router.put('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const dto: UpdateSettingDto = req.body;
const setting = await settingService.update(getContext(req), req.params.id, dto);
if (!setting) {
res.status(404).json({ error: 'Not Found', message: 'Setting not found' });
return;
}
res.status(200).json({ success: true, data: setting });
} catch (error) {
next(error);
}
});
/**
* PUT /settings/key/:key/value
*/
router.put('/key/:key/value', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const { value } = req.body;
if (value === undefined) {
res.status(400).json({ error: 'Bad Request', message: 'value is required' });
return;
}
const setting = await settingService.updateValue(getContext(req), req.params.key, value);
if (!setting) {
res.status(404).json({ error: 'Not Found', message: 'Setting not found' });
return;
}
res.status(200).json({ success: true, data: setting });
} catch (error) {
if (error instanceof Error && (error.message.includes('must be') || error.message.includes('pattern'))) {
res.status(400).json({ error: 'Bad Request', message: error.message });
return;
}
next(error);
}
});
/**
* POST /settings/key/:key/reset
*/
router.post('/key/:key/reset', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const setting = await settingService.resetToDefault(getContext(req), req.params.key);
if (!setting) {
res.status(404).json({ error: 'Not Found', message: 'Setting not found or no default value' });
return;
}
res.status(200).json({ success: true, data: setting });
} catch (error) {
next(error);
}
});
/**
* PUT /settings/bulk
*/
router.put('/bulk', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const { settings } = req.body;
if (!Array.isArray(settings)) {
res.status(400).json({ error: 'Bad Request', message: 'settings array is required' });
return;
}
const result = await settingService.updateMultiple(getContext(req), settings);
res.status(200).json({
success: true,
data: result,
message: `Updated ${result.updated} settings`,
});
} catch (error) {
next(error);
}
});
/**
* DELETE /settings/:id
*/
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
// Check if system setting
const setting = await settingService.findById(getContext(req), req.params.id);
if (!setting) {
res.status(404).json({ error: 'Not Found', message: 'Setting not found' });
return;
}
if (setting.isSystem) {
res.status(403).json({ error: 'Forbidden', message: 'Cannot delete system settings' });
return;
}
const deleted = await settingService.hardDelete(getContext(req), req.params.id);
if (!deleted) {
res.status(404).json({ error: 'Not Found', message: 'Setting not found' });
return;
}
res.status(200).json({ success: true, message: 'Setting deleted' });
} catch (error) {
next(error);
}
});
return router;
}
export default createSystemSettingController;

View File

@ -0,0 +1,256 @@
/**
* AuditLog Entity
* Registro de auditoría para trazabilidad de operaciones
*
* @module Admin
* @table admin.audit_logs
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Tenant } from '../../core/entities/tenant.entity';
import { User } from '../../core/entities/user.entity';
export type AuditCategory =
| 'authentication'
| 'user_management'
| 'critical_operation'
| 'administration'
| 'data_access'
| 'system';
export type AuditAction =
| 'login'
| 'logout'
| 'login_failed'
| 'password_change'
| 'password_reset'
| 'create'
| 'read'
| 'update'
| 'delete'
| 'approve'
| 'reject'
| 'export'
| 'import'
| 'configure'
| 'backup'
| 'restore';
export type AuditSeverity = 'low' | 'medium' | 'high' | 'critical';
@Entity({ schema: 'admin', name: 'audit_logs' })
@Index(['tenantId'])
@Index(['userId'])
@Index(['category'])
@Index(['action'])
@Index(['entityType', 'entityId'])
@Index(['createdAt'])
@Index(['severity'])
@Index(['ipAddress'])
export class AuditLog {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'user_id', type: 'uuid', nullable: true })
userId: string | null;
@Column({
type: 'varchar',
length: 30,
})
category: AuditCategory;
@Column({
type: 'varchar',
length: 30,
})
action: AuditAction;
@Column({
type: 'varchar',
length: 20,
default: 'medium',
})
severity: AuditSeverity;
@Column({
name: 'entity_type',
type: 'varchar',
length: 100,
nullable: true,
comment: 'Type of entity affected (e.g., User, Estimacion)',
})
entityType: string | null;
@Column({
name: 'entity_id',
type: 'uuid',
nullable: true,
comment: 'ID of the affected entity',
})
entityId: string | null;
@Column({
name: 'entity_name',
type: 'varchar',
length: 200,
nullable: true,
comment: 'Human-readable name of the entity',
})
entityName: string | null;
@Column({
type: 'text',
comment: 'Human-readable description of the action',
})
description: string;
@Column({
name: 'old_values',
type: 'jsonb',
nullable: true,
comment: 'Previous values before the change',
})
oldValues: Record<string, any> | null;
@Column({
name: 'new_values',
type: 'jsonb',
nullable: true,
comment: 'New values after the change',
})
newValues: Record<string, any> | null;
@Column({
name: 'changed_fields',
type: 'varchar',
array: true,
nullable: true,
comment: 'List of fields that were changed',
})
changedFields: string[] | null;
@Column({
name: 'ip_address',
type: 'varchar',
length: 45,
nullable: true,
})
ipAddress: string | null;
@Column({
name: 'user_agent',
type: 'varchar',
length: 500,
nullable: true,
})
userAgent: string | null;
@Column({
name: 'request_id',
type: 'varchar',
length: 100,
nullable: true,
comment: 'Request/correlation ID for tracing',
})
requestId: string | null;
@Column({
name: 'session_id',
type: 'varchar',
length: 100,
nullable: true,
})
sessionId: string | null;
@Column({
type: 'varchar',
length: 100,
nullable: true,
comment: 'Module where action occurred',
})
module: string | null;
@Column({
type: 'varchar',
length: 200,
nullable: true,
comment: 'API endpoint or route',
})
endpoint: string | null;
@Column({
name: 'http_method',
type: 'varchar',
length: 10,
nullable: true,
})
httpMethod: string | null;
@Column({
name: 'response_status',
type: 'integer',
nullable: true,
})
responseStatus: number | null;
@Column({
name: 'duration_ms',
type: 'integer',
nullable: true,
comment: 'Request duration in milliseconds',
})
durationMs: number | null;
@Column({
name: 'is_success',
type: 'boolean',
default: true,
})
isSuccess: boolean;
@Column({
name: 'error_message',
type: 'text',
nullable: true,
})
errorMessage: string | null;
@Column({
type: 'jsonb',
nullable: true,
comment: 'Additional context data',
})
metadata: Record<string, any> | null;
@Column({
name: 'retention_days',
type: 'integer',
default: 90,
comment: 'Days to retain this log (90 for operational, 1825 for critical)',
})
retentionDays: number;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
// Relations
@ManyToOne(() => Tenant)
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user: User | null;
}

View File

@ -0,0 +1,301 @@
/**
* Backup Entity
* Registro de backups del sistema
*
* @module Admin
* @table admin.backups
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Tenant } from '../../core/entities/tenant.entity';
import { User } from '../../core/entities/user.entity';
export type BackupType = 'full' | 'incremental' | 'differential' | 'files' | 'snapshot';
export type BackupStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'expired';
export type BackupStorage = 'local' | 's3' | 'gcs' | 'azure' | 'offsite';
@Entity({ schema: 'admin', name: 'backups' })
@Index(['tenantId'])
@Index(['backupType'])
@Index(['status'])
@Index(['createdAt'])
@Index(['expiresAt'])
export class Backup {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({
name: 'backup_type',
type: 'varchar',
length: 20,
})
backupType: BackupType;
@Column({
type: 'varchar',
length: 20,
default: 'pending',
})
status: BackupStatus;
@Column({
type: 'varchar',
length: 200,
comment: 'Descriptive name for the backup',
})
name: string;
@Column({
type: 'text',
nullable: true,
})
description: string | null;
@Column({
name: 'file_path',
type: 'varchar',
length: 500,
nullable: true,
})
filePath: string | null;
@Column({
name: 'file_name',
type: 'varchar',
length: 200,
nullable: true,
})
fileName: string | null;
@Column({
name: 'file_size',
type: 'bigint',
nullable: true,
comment: 'Size in bytes',
})
fileSize: number | null;
@Column({
name: 'storage_location',
type: 'varchar',
length: 20,
default: 'local',
})
storageLocation: BackupStorage;
@Column({
name: 'storage_url',
type: 'varchar',
length: 1000,
nullable: true,
comment: 'Full URL or path to backup file',
})
storageUrl: string | null;
@Column({
type: 'varchar',
length: 64,
nullable: true,
comment: 'SHA-256 checksum for integrity verification',
})
checksum: string | null;
@Column({
name: 'is_encrypted',
type: 'boolean',
default: true,
})
isEncrypted: boolean;
@Column({
name: 'encryption_key_id',
type: 'varchar',
length: 100,
nullable: true,
comment: 'Reference to encryption key used',
})
encryptionKeyId: string | null;
@Column({
name: 'is_compressed',
type: 'boolean',
default: true,
})
isCompressed: boolean;
@Column({
name: 'compression_type',
type: 'varchar',
length: 20,
nullable: true,
comment: 'gzip, lz4, zstd, etc.',
})
compressionType: string | null;
@Column({
name: 'database_version',
type: 'varchar',
length: 50,
nullable: true,
comment: 'PostgreSQL version at backup time',
})
databaseVersion: string | null;
@Column({
name: 'app_version',
type: 'varchar',
length: 50,
nullable: true,
comment: 'Application version at backup time',
})
appVersion: string | null;
@Column({
name: 'tables_included',
type: 'varchar',
array: true,
nullable: true,
comment: 'List of tables included in backup',
})
tablesIncluded: string[] | null;
@Column({
name: 'tables_excluded',
type: 'varchar',
array: true,
nullable: true,
comment: 'List of tables excluded from backup',
})
tablesExcluded: string[] | null;
@Column({
name: 'row_count',
type: 'integer',
nullable: true,
comment: 'Total rows backed up',
})
rowCount: number | null;
@Column({
name: 'started_at',
type: 'timestamptz',
nullable: true,
})
startedAt: Date | null;
@Column({
name: 'completed_at',
type: 'timestamptz',
nullable: true,
})
completedAt: Date | null;
@Column({
name: 'duration_seconds',
type: 'integer',
nullable: true,
})
durationSeconds: number | null;
@Column({
name: 'expires_at',
type: 'timestamptz',
nullable: true,
comment: 'When this backup will be automatically deleted',
})
expiresAt: Date | null;
@Column({
name: 'retention_policy',
type: 'varchar',
length: 50,
nullable: true,
comment: 'daily, weekly, monthly, yearly, permanent',
})
retentionPolicy: string | null;
@Column({
name: 'is_scheduled',
type: 'boolean',
default: false,
comment: 'Was this a scheduled backup?',
})
isScheduled: boolean;
@Column({
name: 'schedule_id',
type: 'varchar',
length: 100,
nullable: true,
comment: 'Reference to schedule that triggered this backup',
})
scheduleId: string | null;
@Column({
name: 'is_verified',
type: 'boolean',
default: false,
comment: 'Has restore been tested?',
})
isVerified: boolean;
@Column({
name: 'verified_at',
type: 'timestamptz',
nullable: true,
})
verifiedAt: Date | null;
@Column({
name: 'verified_by',
type: 'uuid',
nullable: true,
})
verifiedById: string | null;
@Column({
name: 'error_message',
type: 'text',
nullable: true,
})
errorMessage: string | null;
@Column({
type: 'jsonb',
nullable: true,
comment: 'Additional backup metadata',
})
metadata: Record<string, any> | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdById: string | null;
// Relations
@ManyToOne(() => Tenant)
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by' })
createdBy: User | null;
@ManyToOne(() => User)
@JoinColumn({ name: 'verified_by' })
verifiedBy: User | null;
}

View File

@ -0,0 +1,208 @@
/**
* CostCenter Entity
* Centros de costo jerárquicos para imputación de gastos
*
* @module Admin
* @table admin.cost_centers
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
Index,
Tree,
TreeChildren,
TreeParent,
} from 'typeorm';
import { Tenant } from '../../core/entities/tenant.entity';
import { User } from '../../core/entities/user.entity';
export type CostCenterType = 'direct' | 'indirect' | 'shared_services' | 'overhead';
export type CostCenterLevel = 'company' | 'project' | 'phase' | 'front' | 'activity';
@Entity({ schema: 'admin', name: 'cost_centers' })
@Tree('closure-table')
@Index(['tenantId', 'code'], { unique: true })
@Index(['tenantId'])
@Index(['parentId'])
@Index(['fraccionamientoId'])
@Index(['costCenterType'])
@Index(['level'])
@Index(['isActive'])
export class CostCenter {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ type: 'varchar', length: 50 })
code: string;
@Column({ type: 'varchar', length: 200 })
name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({
name: 'cost_center_type',
type: 'varchar',
length: 30,
default: 'direct',
})
costCenterType: CostCenterType;
@Column({
type: 'varchar',
length: 20,
default: 'activity',
})
level: CostCenterLevel;
@Column({
name: 'parent_id',
type: 'uuid',
nullable: true,
})
parentId: string | null;
@Column({
name: 'fraccionamiento_id',
type: 'uuid',
nullable: true,
comment: 'Project this cost center belongs to (null for company level)',
})
fraccionamientoId: string | null;
@Column({
name: 'responsible_id',
type: 'uuid',
nullable: true,
comment: 'User responsible for this cost center',
})
responsibleId: string | null;
@Column({
type: 'decimal',
precision: 16,
scale: 2,
default: 0,
comment: 'Annual budget for this cost center',
})
budget: number;
@Column({
name: 'budget_consumed',
type: 'decimal',
precision: 16,
scale: 2,
default: 0,
comment: 'Amount consumed from budget',
})
budgetConsumed: number;
@Column({
name: 'distribution_percentage',
type: 'decimal',
precision: 5,
scale: 2,
nullable: true,
comment: 'Percentage for indirect cost distribution',
})
distributionPercentage: number | null;
@Column({
name: 'distribution_base',
type: 'varchar',
length: 50,
nullable: true,
comment: 'Base for distribution: direct_cost, headcount, area, etc.',
})
distributionBase: string | null;
@Column({
name: 'accounting_code',
type: 'varchar',
length: 50,
nullable: true,
comment: 'Code for accounting system integration',
})
accountingCode: string | null;
@Column({
name: 'is_billable',
type: 'boolean',
default: false,
comment: 'Can be billed to client',
})
isBillable: boolean;
@Column({
name: 'is_active',
type: 'boolean',
default: true,
})
isActive: boolean;
@Column({
name: 'sort_order',
type: 'integer',
default: 0,
})
sortOrder: number;
@Column({
type: 'jsonb',
nullable: true,
comment: 'Additional metadata',
})
metadata: Record<string, any> | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdById: string | null;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true })
updatedAt: Date | null;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedById: string | null;
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date | null;
@Column({ name: 'deleted_by', type: 'uuid', nullable: true })
deletedById: string | null;
// Relations
@ManyToOne(() => Tenant)
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@TreeParent()
@ManyToOne(() => CostCenter, (cc) => cc.children)
@JoinColumn({ name: 'parent_id' })
parent: CostCenter | null;
@TreeChildren()
@OneToMany(() => CostCenter, (cc) => cc.parent)
children: CostCenter[];
@ManyToOne(() => User)
@JoinColumn({ name: 'responsible_id' })
responsible: User | null;
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by' })
createdBy: User | null;
}

View File

@ -0,0 +1,161 @@
/**
* CustomPermission Entity
* Permisos personalizados y temporales para usuarios
*
* @module Admin
* @table admin.custom_permissions
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Tenant } from '../../core/entities/tenant.entity';
import { User } from '../../core/entities/user.entity';
export type PermissionAction = 'create' | 'read' | 'update' | 'delete' | 'approve' | 'export' | 'import';
@Entity({ schema: 'admin', name: 'custom_permissions' })
@Index(['tenantId'])
@Index(['userId'])
@Index(['module'])
@Index(['isActive'])
@Index(['validUntil'])
@Index(['tenantId', 'userId', 'module'])
export class CustomPermission {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@Column({
type: 'varchar',
length: 100,
comment: 'Module this permission applies to',
})
module: string;
@Column({
type: 'varchar',
array: true,
comment: 'Actions allowed',
})
actions: PermissionAction[];
@Column({
name: 'resource_type',
type: 'varchar',
length: 100,
nullable: true,
comment: 'Specific resource type (e.g., Estimacion, Proyecto)',
})
resourceType: string | null;
@Column({
name: 'resource_id',
type: 'uuid',
nullable: true,
comment: 'Specific resource ID (null = all resources)',
})
resourceId: string | null;
@Column({
name: 'fraccionamiento_id',
type: 'uuid',
nullable: true,
comment: 'Project scope (null = all projects)',
})
fraccionamientoId: string | null;
@Column({
type: 'jsonb',
nullable: true,
comment: 'Additional conditions for permission',
})
conditions: Record<string, any> | null;
@Column({
type: 'text',
nullable: true,
comment: 'Reason for granting this permission',
})
reason: string | null;
@Column({
name: 'valid_from',
type: 'timestamptz',
default: () => 'CURRENT_TIMESTAMP',
})
validFrom: Date;
@Column({
name: 'valid_until',
type: 'timestamptz',
nullable: true,
comment: 'Expiration date (null = permanent)',
})
validUntil: Date | null;
@Column({
name: 'is_active',
type: 'boolean',
default: true,
})
isActive: boolean;
@Column({
name: 'granted_by',
type: 'uuid',
})
grantedById: string;
@Column({
name: 'revoked_at',
type: 'timestamptz',
nullable: true,
})
revokedAt: Date | null;
@Column({
name: 'revoked_by',
type: 'uuid',
nullable: true,
})
revokedById: string | null;
@Column({
name: 'revoke_reason',
type: 'text',
nullable: true,
})
revokeReason: string | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
// Relations
@ManyToOne(() => Tenant)
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user: User;
@ManyToOne(() => User)
@JoinColumn({ name: 'granted_by' })
grantedBy: User;
@ManyToOne(() => User)
@JoinColumn({ name: 'revoked_by' })
revokedBy: User | null;
}

View File

@ -0,0 +1,10 @@
/**
* Admin Module - Entity Exports
* MAI-013: Administración & Seguridad
*/
export * from './cost-center.entity';
export * from './audit-log.entity';
export * from './system-setting.entity';
export * from './backup.entity';
export * from './custom-permission.entity';

View File

@ -0,0 +1,180 @@
/**
* SystemSetting Entity
* Configuración del sistema por tenant
*
* @module Admin
* @table admin.system_settings
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Tenant } from '../../core/entities/tenant.entity';
import { User } from '../../core/entities/user.entity';
export type SettingCategory =
| 'general'
| 'security'
| 'notifications'
| 'integrations'
| 'workflow'
| 'reports'
| 'backups'
| 'appearance';
export type SettingDataType = 'string' | 'number' | 'boolean' | 'json' | 'array';
@Entity({ schema: 'admin', name: 'system_settings' })
@Index(['tenantId', 'key'], { unique: true })
@Index(['tenantId'])
@Index(['category'])
@Index(['isPublic'])
export class SystemSetting {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({
type: 'varchar',
length: 100,
comment: 'Unique key for the setting',
})
key: string;
@Column({
type: 'varchar',
length: 200,
})
name: string;
@Column({
type: 'text',
nullable: true,
})
description: string | null;
@Column({
type: 'varchar',
length: 30,
default: 'general',
})
category: SettingCategory;
@Column({
name: 'data_type',
type: 'varchar',
length: 20,
default: 'string',
})
dataType: SettingDataType;
@Column({
type: 'text',
comment: 'Current value (stored as string)',
})
value: string;
@Column({
name: 'default_value',
type: 'text',
nullable: true,
comment: 'Default value for reset',
})
defaultValue: string | null;
@Column({
type: 'jsonb',
nullable: true,
comment: 'Validation rules (min, max, pattern, options, etc.)',
})
validation: Record<string, any> | null;
@Column({
type: 'jsonb',
nullable: true,
comment: 'Allowed options for select/enum types',
})
options: Record<string, any>[] | null;
@Column({
name: 'is_public',
type: 'boolean',
default: false,
comment: 'Can be read without authentication',
})
isPublic: boolean;
@Column({
name: 'is_encrypted',
type: 'boolean',
default: false,
comment: 'Value is encrypted (for sensitive data)',
})
isEncrypted: boolean;
@Column({
name: 'is_system',
type: 'boolean',
default: false,
comment: 'System setting cannot be deleted',
})
isSystem: boolean;
@Column({
name: 'requires_restart',
type: 'boolean',
default: false,
comment: 'Requires app restart to take effect',
})
requiresRestart: boolean;
@Column({
name: 'allowed_roles',
type: 'varchar',
array: true,
nullable: true,
comment: 'Roles that can modify this setting',
})
allowedRoles: string[] | null;
@Column({
name: 'sort_order',
type: 'integer',
default: 0,
})
sortOrder: number;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdById: string | null;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true })
updatedAt: Date | null;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedById: string | null;
// Relations
@ManyToOne(() => Tenant)
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by' })
createdBy: User | null;
@ManyToOne(() => User)
@JoinColumn({ name: 'updated_by' })
updatedBy: User | null;
}

View File

@ -0,0 +1,309 @@
/**
* AuditLogService - Gestión de Logs de Auditoría
*
* Registra y consulta eventos de auditoría para trazabilidad.
*
* @module Admin
*/
import { Repository } from 'typeorm';
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
import { AuditLog, AuditCategory, AuditAction, AuditSeverity } from '../entities/audit-log.entity';
export interface CreateAuditLogDto {
userId?: string;
category: AuditCategory;
action: AuditAction;
severity?: AuditSeverity;
entityType?: string;
entityId?: string;
entityName?: string;
description: string;
oldValues?: Record<string, any>;
newValues?: Record<string, any>;
changedFields?: string[];
ipAddress?: string;
userAgent?: string;
requestId?: string;
sessionId?: string;
module?: string;
endpoint?: string;
httpMethod?: string;
responseStatus?: number;
durationMs?: number;
isSuccess?: boolean;
errorMessage?: string;
metadata?: Record<string, any>;
}
export interface AuditLogFilters {
userId?: string;
category?: AuditCategory;
action?: AuditAction;
severity?: AuditSeverity;
entityType?: string;
entityId?: string;
module?: string;
isSuccess?: boolean;
dateFrom?: Date;
dateTo?: Date;
ipAddress?: string;
search?: string;
}
export class AuditLogService {
constructor(private readonly repository: Repository<AuditLog>) {}
/**
* Crear registro de auditoría
*/
async log(ctx: ServiceContext, data: CreateAuditLogDto): Promise<AuditLog> {
// Determinar retención basada en severidad
let retentionDays = 90; // Default for operational logs
if (data.severity === 'critical' || data.category === 'critical_operation') {
retentionDays = 1825; // 5 years for critical
} else if (data.severity === 'high') {
retentionDays = 365; // 1 year for high severity
}
const log = this.repository.create({
tenantId: ctx.tenantId,
...data,
severity: data.severity || 'medium',
isSuccess: data.isSuccess ?? true,
retentionDays,
});
return this.repository.save(log);
}
/**
* Buscar logs con filtros
*/
async findWithFilters(
ctx: ServiceContext,
filters: AuditLogFilters,
page = 1,
limit = 50
): Promise<PaginatedResult<AuditLog>> {
const qb = this.repository
.createQueryBuilder('al')
.leftJoinAndSelect('al.user', 'u')
.where('al.tenant_id = :tenantId', { tenantId: ctx.tenantId });
if (filters.userId) {
qb.andWhere('al.user_id = :userId', { userId: filters.userId });
}
if (filters.category) {
qb.andWhere('al.category = :category', { category: filters.category });
}
if (filters.action) {
qb.andWhere('al.action = :action', { action: filters.action });
}
if (filters.severity) {
qb.andWhere('al.severity = :severity', { severity: filters.severity });
}
if (filters.entityType) {
qb.andWhere('al.entity_type = :entityType', { entityType: filters.entityType });
}
if (filters.entityId) {
qb.andWhere('al.entity_id = :entityId', { entityId: filters.entityId });
}
if (filters.module) {
qb.andWhere('al.module = :module', { module: filters.module });
}
if (filters.isSuccess !== undefined) {
qb.andWhere('al.is_success = :isSuccess', { isSuccess: filters.isSuccess });
}
if (filters.dateFrom) {
qb.andWhere('al.created_at >= :dateFrom', { dateFrom: filters.dateFrom });
}
if (filters.dateTo) {
qb.andWhere('al.created_at <= :dateTo', { dateTo: filters.dateTo });
}
if (filters.ipAddress) {
qb.andWhere('al.ip_address = :ipAddress', { ipAddress: filters.ipAddress });
}
if (filters.search) {
qb.andWhere(
'(al.description ILIKE :search OR al.entity_name ILIKE :search)',
{ search: `%${filters.search}%` }
);
}
const skip = (page - 1) * limit;
qb.orderBy('al.created_at', 'DESC').skip(skip).take(limit);
const [data, total] = await qb.getManyAndCount();
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Obtener logs por entidad
*/
async findByEntity(
ctx: ServiceContext,
entityType: string,
entityId: string,
page = 1,
limit = 20
): Promise<PaginatedResult<AuditLog>> {
return this.findWithFilters(ctx, { entityType, entityId }, page, limit);
}
/**
* Obtener logs por usuario
*/
async findByUser(
ctx: ServiceContext,
userId: string,
page = 1,
limit = 50
): Promise<PaginatedResult<AuditLog>> {
return this.findWithFilters(ctx, { userId }, page, limit);
}
/**
* Obtener logs críticos recientes
*/
async getCriticalLogs(
ctx: ServiceContext,
days = 7,
limit = 100
): Promise<AuditLog[]> {
const dateFrom = new Date();
dateFrom.setDate(dateFrom.getDate() - days);
return this.repository
.createQueryBuilder('al')
.leftJoinAndSelect('al.user', 'u')
.where('al.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('al.severity IN (:...severities)', { severities: ['high', 'critical'] })
.andWhere('al.created_at >= :dateFrom', { dateFrom })
.orderBy('al.created_at', 'DESC')
.take(limit)
.getMany();
}
/**
* Obtener logs fallidos
*/
async getFailedLogs(
ctx: ServiceContext,
hours = 24,
limit = 100
): Promise<AuditLog[]> {
const dateFrom = new Date();
dateFrom.setHours(dateFrom.getHours() - hours);
return this.repository
.createQueryBuilder('al')
.leftJoinAndSelect('al.user', 'u')
.where('al.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('al.is_success = false')
.andWhere('al.created_at >= :dateFrom', { dateFrom })
.orderBy('al.created_at', 'DESC')
.take(limit)
.getMany();
}
/**
* Limpiar logs expirados
*/
async cleanupExpiredLogs(ctx: ServiceContext): Promise<number> {
const result = await this.repository
.createQueryBuilder()
.delete()
.from(AuditLog)
.where('tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere("created_at < NOW() - (retention_days || ' days')::interval")
.execute();
return result.affected || 0;
}
/**
* Obtener estadísticas
*/
async getStats(ctx: ServiceContext, days = 30): Promise<AuditLogStats> {
const dateFrom = new Date();
dateFrom.setDate(dateFrom.getDate() - days);
const qb = this.repository
.createQueryBuilder('al')
.where('al.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('al.created_at >= :dateFrom', { dateFrom });
// Total count
const total = await qb.getCount();
// By category
const byCategory = await this.repository
.createQueryBuilder('al')
.select('al.category', 'category')
.addSelect('COUNT(*)', 'count')
.where('al.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('al.created_at >= :dateFrom', { dateFrom })
.groupBy('al.category')
.getRawMany();
// By action
const byAction = await this.repository
.createQueryBuilder('al')
.select('al.action', 'action')
.addSelect('COUNT(*)', 'count')
.where('al.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('al.created_at >= :dateFrom', { dateFrom })
.groupBy('al.action')
.getRawMany();
// Success/failure
const successCount = await this.repository
.createQueryBuilder('al')
.where('al.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('al.created_at >= :dateFrom', { dateFrom })
.andWhere('al.is_success = true')
.getCount();
// By severity
const bySeverity = await this.repository
.createQueryBuilder('al')
.select('al.severity', 'severity')
.addSelect('COUNT(*)', 'count')
.where('al.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('al.created_at >= :dateFrom', { dateFrom })
.groupBy('al.severity')
.getRawMany();
return {
period: { days, from: dateFrom, to: new Date() },
total,
successCount,
failureCount: total - successCount,
successRate: total > 0 ? (successCount / total) * 100 : 0,
byCategory: byCategory.map((r) => ({ category: r.category, count: parseInt(r.count) })),
byAction: byAction.map((r) => ({ action: r.action, count: parseInt(r.count) })),
bySeverity: bySeverity.map((r) => ({ severity: r.severity, count: parseInt(r.count) })),
};
}
}
export interface AuditLogStats {
period: { days: number; from: Date; to: Date };
total: number;
successCount: number;
failureCount: number;
successRate: number;
byCategory: { category: AuditCategory; count: number }[];
byAction: { action: AuditAction; count: number }[];
bySeverity: { severity: AuditSeverity; count: number }[];
}

View File

@ -0,0 +1,308 @@
/**
* BackupService - Gestión de Backups
*
* Administra creación, verificación y restauración de backups.
*
* @module Admin
*/
import { Repository } from 'typeorm';
import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
import { Backup, BackupType, BackupStatus, BackupStorage } from '../entities/backup.entity';
export interface CreateBackupDto {
backupType: BackupType;
name: string;
description?: string;
storageLocation?: BackupStorage;
tablesIncluded?: string[];
tablesExcluded?: string[];
retentionPolicy?: string;
isScheduled?: boolean;
scheduleId?: string;
metadata?: Record<string, any>;
}
export interface BackupFilters {
backupType?: BackupType;
status?: BackupStatus;
storageLocation?: BackupStorage;
isScheduled?: boolean;
isVerified?: boolean;
dateFrom?: Date;
dateTo?: Date;
}
export class BackupService extends BaseService<Backup> {
constructor(repository: Repository<Backup>) {
super(repository);
}
/**
* Buscar backups con filtros
*/
async findWithFilters(
ctx: ServiceContext,
filters: BackupFilters,
page = 1,
limit = 20
): Promise<PaginatedResult<Backup>> {
const qb = this.repository
.createQueryBuilder('b')
.leftJoinAndSelect('b.createdBy', 'cb')
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId });
if (filters.backupType) {
qb.andWhere('b.backup_type = :backupType', { backupType: filters.backupType });
}
if (filters.status) {
qb.andWhere('b.status = :status', { status: filters.status });
}
if (filters.storageLocation) {
qb.andWhere('b.storage_location = :storageLocation', { storageLocation: filters.storageLocation });
}
if (filters.isScheduled !== undefined) {
qb.andWhere('b.is_scheduled = :isScheduled', { isScheduled: filters.isScheduled });
}
if (filters.isVerified !== undefined) {
qb.andWhere('b.is_verified = :isVerified', { isVerified: filters.isVerified });
}
if (filters.dateFrom) {
qb.andWhere('b.created_at >= :dateFrom', { dateFrom: filters.dateFrom });
}
if (filters.dateTo) {
qb.andWhere('b.created_at <= :dateTo', { dateTo: filters.dateTo });
}
const skip = (page - 1) * limit;
qb.orderBy('b.created_at', 'DESC').skip(skip).take(limit);
const [data, total] = await qb.getManyAndCount();
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Iniciar backup
*/
async initiateBackup(ctx: ServiceContext, data: CreateBackupDto): Promise<Backup> {
// Calculate expiration based on retention policy
let expiresAt: Date | null = null;
if (data.retentionPolicy) {
expiresAt = this.calculateExpiration(data.retentionPolicy);
}
const backup = await this.create(ctx, {
...data,
status: 'pending' as BackupStatus,
isEncrypted: true,
isCompressed: true,
compressionType: 'gzip',
expiresAt,
});
// In production, this would trigger an async job
// For now, we simulate starting the backup
await this.startBackupProcess(ctx, backup.id);
return this.findById(ctx, backup.id) as Promise<Backup>;
}
/**
* Proceso de backup (simulado)
*/
private async startBackupProcess(ctx: ServiceContext, backupId: string): Promise<void> {
const startTime = Date.now();
await this.update(ctx, backupId, {
status: 'running' as BackupStatus,
startedAt: new Date(),
});
// Simulate backup process
// In production, this would be handled by a job queue
const duration = Date.now() - startTime;
await this.update(ctx, backupId, {
status: 'completed' as BackupStatus,
completedAt: new Date(),
durationSeconds: Math.ceil(duration / 1000),
// These would be real values from the backup process
fileSize: 0,
rowCount: 0,
});
}
/**
* Calcular fecha de expiración
*/
private calculateExpiration(policy: string): Date | null {
const now = new Date();
switch (policy) {
case 'daily':
now.setDate(now.getDate() + 7); // Keep 7 days
return now;
case 'weekly':
now.setDate(now.getDate() + 30); // Keep 30 days
return now;
case 'monthly':
now.setMonth(now.getMonth() + 12); // Keep 12 months
return now;
case 'yearly':
now.setFullYear(now.getFullYear() + 7); // Keep 7 years
return now;
case 'permanent':
return null; // Never expires
default:
now.setDate(now.getDate() + 30); // Default 30 days
return now;
}
}
/**
* Marcar backup como verificado
*/
async markVerified(ctx: ServiceContext, id: string): Promise<Backup | null> {
const backup = await this.findById(ctx, id);
if (!backup) {
return null;
}
if (backup.status !== 'completed') {
throw new Error('Only completed backups can be verified');
}
return this.update(ctx, id, {
isVerified: true,
verifiedAt: new Date(),
verifiedById: ctx.userId,
});
}
/**
* Cancelar backup en progreso
*/
async cancelBackup(ctx: ServiceContext, id: string): Promise<Backup | null> {
const backup = await this.findById(ctx, id);
if (!backup) {
return null;
}
if (!['pending', 'running'].includes(backup.status)) {
throw new Error('Only pending or running backups can be cancelled');
}
return this.update(ctx, id, {
status: 'cancelled' as BackupStatus,
completedAt: new Date(),
});
}
/**
* Obtener último backup exitoso
*/
async getLastSuccessful(ctx: ServiceContext, backupType?: BackupType): Promise<Backup | null> {
const qb = this.repository
.createQueryBuilder('b')
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('b.status = :status', { status: 'completed' });
if (backupType) {
qb.andWhere('b.backup_type = :backupType', { backupType });
}
return qb.orderBy('b.completed_at', 'DESC').getOne();
}
/**
* Obtener backups pendientes de verificación
*/
async getPendingVerification(ctx: ServiceContext): Promise<Backup[]> {
return this.repository.find({
where: {
tenantId: ctx.tenantId,
status: 'completed' as BackupStatus,
isVerified: false,
} as any,
order: { completedAt: 'DESC' },
});
}
/**
* Limpiar backups expirados
*/
async cleanupExpired(ctx: ServiceContext): Promise<number> {
const result = await this.repository
.createQueryBuilder()
.update(Backup)
.set({ status: 'expired' as BackupStatus })
.where('tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('expires_at < NOW()')
.andWhere('status = :status', { status: 'completed' })
.execute();
return result.affected || 0;
}
/**
* Obtener estadísticas
*/
async getStats(ctx: ServiceContext): Promise<BackupStats> {
const all = await this.repository.find({
where: { tenantId: ctx.tenantId } as any,
});
const byType = new Map<BackupType, number>();
const byStatus = new Map<BackupStatus, number>();
let totalSize = 0;
let verifiedCount = 0;
for (const backup of all) {
byType.set(backup.backupType, (byType.get(backup.backupType) || 0) + 1);
byStatus.set(backup.status, (byStatus.get(backup.status) || 0) + 1);
totalSize += Number(backup.fileSize) || 0;
if (backup.isVerified) verifiedCount++;
}
const lastBackup = await this.getLastSuccessful(ctx);
return {
total: all.length,
totalSizeBytes: totalSize,
totalSizeHuman: this.formatBytes(totalSize),
verifiedCount,
byType: Array.from(byType.entries()).map(([type, count]) => ({ type, count })),
byStatus: Array.from(byStatus.entries()).map(([status, count]) => ({ status, count })),
lastBackupAt: lastBackup?.completedAt || null,
};
}
/**
* Formatear bytes a formato legible
*/
private formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
}
export interface BackupStats {
total: number;
totalSizeBytes: number;
totalSizeHuman: string;
verifiedCount: number;
byType: { type: BackupType; count: number }[];
byStatus: { status: BackupStatus; count: number }[];
lastBackupAt: Date | null;
}

View File

@ -0,0 +1,336 @@
/**
* CostCenterService - Gestión de Centros de Costo
*
* Administra centros de costo jerárquicos para imputación de gastos.
*
* @module Admin
*/
import { Repository } from 'typeorm';
import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
import { CostCenter, CostCenterType, CostCenterLevel } from '../entities/cost-center.entity';
export interface CreateCostCenterDto {
code: string;
name: string;
description?: string;
costCenterType?: CostCenterType;
level?: CostCenterLevel;
parentId?: string;
fraccionamientoId?: string;
responsibleId?: string;
budget?: number;
distributionPercentage?: number;
distributionBase?: string;
accountingCode?: string;
isBillable?: boolean;
metadata?: Record<string, any>;
}
export interface UpdateCostCenterDto extends Partial<CreateCostCenterDto> {
isActive?: boolean;
sortOrder?: number;
}
export interface CostCenterFilters {
costCenterType?: CostCenterType;
level?: CostCenterLevel;
fraccionamientoId?: string;
parentId?: string;
responsibleId?: string;
isActive?: boolean;
search?: string;
}
export class CostCenterService extends BaseService<CostCenter> {
constructor(repository: Repository<CostCenter>) {
super(repository);
}
/**
* Buscar centros de costo con filtros
*/
async findWithFilters(
ctx: ServiceContext,
filters: CostCenterFilters,
page = 1,
limit = 20
): Promise<PaginatedResult<CostCenter>> {
const qb = this.repository
.createQueryBuilder('cc')
.leftJoinAndSelect('cc.responsible', 'r')
.where('cc.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('cc.deleted_at IS NULL');
if (filters.costCenterType) {
qb.andWhere('cc.cost_center_type = :costCenterType', { costCenterType: filters.costCenterType });
}
if (filters.level) {
qb.andWhere('cc.level = :level', { level: filters.level });
}
if (filters.fraccionamientoId) {
qb.andWhere('cc.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId: filters.fraccionamientoId });
}
if (filters.parentId !== undefined) {
if (filters.parentId === null) {
qb.andWhere('cc.parent_id IS NULL');
} else {
qb.andWhere('cc.parent_id = :parentId', { parentId: filters.parentId });
}
}
if (filters.responsibleId) {
qb.andWhere('cc.responsible_id = :responsibleId', { responsibleId: filters.responsibleId });
}
if (filters.isActive !== undefined) {
qb.andWhere('cc.is_active = :isActive', { isActive: filters.isActive });
}
if (filters.search) {
qb.andWhere('(cc.name ILIKE :search OR cc.code ILIKE :search OR cc.description ILIKE :search)', {
search: `%${filters.search}%`,
});
}
const skip = (page - 1) * limit;
qb.orderBy('cc.sort_order', 'ASC')
.addOrderBy('cc.code', 'ASC')
.skip(skip)
.take(limit);
const [data, total] = await qb.getManyAndCount();
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Buscar por código
*/
async findByCode(ctx: ServiceContext, code: string): Promise<CostCenter | null> {
return this.repository.findOne({
where: {
tenantId: ctx.tenantId,
code,
deletedAt: null,
} as any,
});
}
/**
* Obtener árbol de centros de costo
*/
async getTree(ctx: ServiceContext, fraccionamientoId?: string): Promise<CostCenter[]> {
const qb = this.repository
.createQueryBuilder('cc')
.where('cc.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('cc.deleted_at IS NULL')
.andWhere('cc.parent_id IS NULL');
if (fraccionamientoId) {
qb.andWhere('(cc.fraccionamiento_id = :fraccionamientoId OR cc.fraccionamiento_id IS NULL)', {
fraccionamientoId,
});
}
const roots = await qb.orderBy('cc.sort_order', 'ASC').getMany();
// Load children recursively
for (const root of roots) {
await this.loadChildren(ctx, root, fraccionamientoId);
}
return roots;
}
/**
* Cargar hijos recursivamente
*/
private async loadChildren(
ctx: ServiceContext,
parent: CostCenter,
fraccionamientoId?: string
): Promise<void> {
const qb = this.repository
.createQueryBuilder('cc')
.where('cc.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('cc.deleted_at IS NULL')
.andWhere('cc.parent_id = :parentId', { parentId: parent.id });
if (fraccionamientoId) {
qb.andWhere('(cc.fraccionamiento_id = :fraccionamientoId OR cc.fraccionamiento_id IS NULL)', {
fraccionamientoId,
});
}
parent.children = await qb.orderBy('cc.sort_order', 'ASC').getMany();
for (const child of parent.children) {
await this.loadChildren(ctx, child, fraccionamientoId);
}
}
/**
* Obtener hijos directos
*/
async getChildren(ctx: ServiceContext, parentId: string): Promise<CostCenter[]> {
return this.repository.find({
where: {
tenantId: ctx.tenantId,
parentId,
deletedAt: null,
} as any,
order: { sortOrder: 'ASC', code: 'ASC' },
});
}
/**
* Crear centro de costo
*/
async createCostCenter(ctx: ServiceContext, data: CreateCostCenterDto): Promise<CostCenter> {
const existing = await this.findByCode(ctx, data.code);
if (existing) {
throw new Error(`Cost center with code ${data.code} already exists`);
}
// Validate parent if provided
if (data.parentId) {
const parent = await this.findById(ctx, data.parentId);
if (!parent) {
throw new Error('Parent cost center not found');
}
}
return this.create(ctx, {
...data,
isActive: true,
budgetConsumed: 0,
});
}
/**
* Actualizar presupuesto consumido
*/
async updateBudgetConsumed(
ctx: ServiceContext,
id: string,
amount: number
): Promise<CostCenter | null> {
const cc = await this.findById(ctx, id);
if (!cc) {
return null;
}
return this.update(ctx, id, {
budgetConsumed: Number(cc.budgetConsumed) + amount,
});
}
/**
* Obtener resumen de presupuesto
*/
async getBudgetSummary(
ctx: ServiceContext,
fraccionamientoId?: string
): Promise<CostCenterBudgetSummary> {
const qb = this.repository
.createQueryBuilder('cc')
.select([
'cc.cost_center_type as type',
'SUM(cc.budget) as total_budget',
'SUM(cc.budget_consumed) as total_consumed',
'COUNT(*) as count',
])
.where('cc.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('cc.deleted_at IS NULL')
.andWhere('cc.is_active = true')
.groupBy('cc.cost_center_type');
if (fraccionamientoId) {
qb.andWhere('cc.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
}
const results = await qb.getRawMany();
let totalBudget = 0;
let totalConsumed = 0;
const byType: { type: CostCenterType; budget: number; consumed: number; count: number }[] = [];
for (const r of results) {
const budget = parseFloat(r.total_budget || '0');
const consumed = parseFloat(r.total_consumed || '0');
totalBudget += budget;
totalConsumed += consumed;
byType.push({
type: r.type,
budget,
consumed,
count: parseInt(r.count),
});
}
return {
totalBudget,
totalConsumed,
totalAvailable: totalBudget - totalConsumed,
utilizationPercentage: totalBudget > 0 ? (totalConsumed / totalBudget) * 100 : 0,
byType,
};
}
/**
* Obtener estadísticas
*/
async getStats(ctx: ServiceContext): Promise<CostCenterStats> {
const all = await this.repository.find({
where: {
tenantId: ctx.tenantId,
deletedAt: null,
} as any,
});
const byType = new Map<CostCenterType, number>();
const byLevel = new Map<CostCenterLevel, number>();
let activeCount = 0;
for (const cc of all) {
byType.set(cc.costCenterType, (byType.get(cc.costCenterType) || 0) + 1);
byLevel.set(cc.level, (byLevel.get(cc.level) || 0) + 1);
if (cc.isActive) activeCount++;
}
return {
total: all.length,
active: activeCount,
inactive: all.length - activeCount,
byType: Array.from(byType.entries()).map(([type, count]) => ({ type, count })),
byLevel: Array.from(byLevel.entries()).map(([level, count]) => ({ level, count })),
};
}
}
export interface CostCenterBudgetSummary {
totalBudget: number;
totalConsumed: number;
totalAvailable: number;
utilizationPercentage: number;
byType: {
type: CostCenterType;
budget: number;
consumed: number;
count: number;
}[];
}
export interface CostCenterStats {
total: number;
active: number;
inactive: number;
byType: { type: CostCenterType; count: number }[];
byLevel: { level: CostCenterLevel; count: number }[];
}

View File

@ -0,0 +1,9 @@
/**
* Admin Module - Service Exports
* MAI-013: Administración & Seguridad
*/
export * from './cost-center.service';
export * from './audit-log.service';
export * from './system-setting.service';
export * from './backup.service';

View File

@ -0,0 +1,336 @@
/**
* SystemSettingService - Gestión de Configuración del Sistema
*
* Administra configuraciones del sistema por tenant.
*
* @module Admin
*/
import { Repository } from 'typeorm';
import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
import { SystemSetting, SettingCategory, SettingDataType } from '../entities/system-setting.entity';
export interface CreateSettingDto {
key: string;
name: string;
description?: string;
category?: SettingCategory;
dataType?: SettingDataType;
value: string;
defaultValue?: string;
validation?: Record<string, any>;
options?: Record<string, any>[];
isPublic?: boolean;
isEncrypted?: boolean;
requiresRestart?: boolean;
allowedRoles?: string[];
}
export interface UpdateSettingDto {
name?: string;
description?: string;
value?: string;
validation?: Record<string, any>;
options?: Record<string, any>[];
isPublic?: boolean;
allowedRoles?: string[];
sortOrder?: number;
}
export interface SettingFilters {
category?: SettingCategory;
isPublic?: boolean;
search?: string;
}
export class SystemSettingService extends BaseService<SystemSetting> {
constructor(repository: Repository<SystemSetting>) {
super(repository);
}
/**
* Obtener valor de configuración
*/
async getValue(ctx: ServiceContext, key: string): Promise<any> {
const setting = await this.findByKey(ctx, key);
if (!setting) {
return null;
}
return this.parseValue(setting.value, setting.dataType);
}
/**
* Obtener valor con default
*/
async getValueOrDefault(ctx: ServiceContext, key: string, defaultValue: any): Promise<any> {
const value = await this.getValue(ctx, key);
return value !== null ? value : defaultValue;
}
/**
* Buscar por key
*/
async findByKey(ctx: ServiceContext, key: string): Promise<SystemSetting | null> {
return this.repository.findOne({
where: {
tenantId: ctx.tenantId,
key,
} as any,
});
}
/**
* Buscar configuraciones con filtros
*/
async findWithFilters(
ctx: ServiceContext,
filters: SettingFilters,
page = 1,
limit = 50
): Promise<PaginatedResult<SystemSetting>> {
const qb = this.repository
.createQueryBuilder('ss')
.where('ss.tenant_id = :tenantId', { tenantId: ctx.tenantId });
if (filters.category) {
qb.andWhere('ss.category = :category', { category: filters.category });
}
if (filters.isPublic !== undefined) {
qb.andWhere('ss.is_public = :isPublic', { isPublic: filters.isPublic });
}
if (filters.search) {
qb.andWhere(
'(ss.key ILIKE :search OR ss.name ILIKE :search OR ss.description ILIKE :search)',
{ search: `%${filters.search}%` }
);
}
const skip = (page - 1) * limit;
qb.orderBy('ss.category', 'ASC')
.addOrderBy('ss.sort_order', 'ASC')
.addOrderBy('ss.key', 'ASC')
.skip(skip)
.take(limit);
const [data, total] = await qb.getManyAndCount();
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Obtener por categoría
*/
async findByCategory(ctx: ServiceContext, category: SettingCategory): Promise<SystemSetting[]> {
return this.repository.find({
where: {
tenantId: ctx.tenantId,
category,
} as any,
order: { sortOrder: 'ASC', key: 'ASC' },
});
}
/**
* Obtener configuraciones públicas
*/
async getPublicSettings(ctx: ServiceContext): Promise<Record<string, any>> {
const settings = await this.repository.find({
where: {
tenantId: ctx.tenantId,
isPublic: true,
} as any,
});
const result: Record<string, any> = {};
for (const setting of settings) {
result[setting.key] = this.parseValue(setting.value, setting.dataType);
}
return result;
}
/**
* Crear configuración
*/
async createSetting(ctx: ServiceContext, data: CreateSettingDto): Promise<SystemSetting> {
const existing = await this.findByKey(ctx, data.key);
if (existing) {
throw new Error(`Setting with key ${data.key} already exists`);
}
// Validate value against type and validation rules
this.validateValue(data.value, data.dataType || 'string', data.validation);
return this.create(ctx, {
...data,
isSystem: false,
});
}
/**
* Actualizar valor
*/
async updateValue(ctx: ServiceContext, key: string, value: string): Promise<SystemSetting | null> {
const setting = await this.findByKey(ctx, key);
if (!setting) {
return null;
}
// Validate value against type and validation rules
this.validateValue(value, setting.dataType, setting.validation);
return this.update(ctx, setting.id, { value });
}
/**
* Restablecer a valor por defecto
*/
async resetToDefault(ctx: ServiceContext, key: string): Promise<SystemSetting | null> {
const setting = await this.findByKey(ctx, key);
if (!setting || !setting.defaultValue) {
return null;
}
return this.update(ctx, setting.id, { value: setting.defaultValue });
}
/**
* Actualizar múltiples configuraciones
*/
async updateMultiple(
ctx: ServiceContext,
settings: { key: string; value: string }[]
): Promise<{ updated: number; errors: string[] }> {
let updated = 0;
const errors: string[] = [];
for (const { key, value } of settings) {
try {
const result = await this.updateValue(ctx, key, value);
if (result) {
updated++;
} else {
errors.push(`Setting ${key} not found`);
}
} catch (error) {
errors.push(`${key}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
return { updated, errors };
}
/**
* Parsear valor según tipo
*/
private parseValue(value: string, dataType: SettingDataType): any {
switch (dataType) {
case 'number':
return parseFloat(value);
case 'boolean':
return value === 'true' || value === '1';
case 'json':
case 'array':
try {
return JSON.parse(value);
} catch {
return null;
}
default:
return value;
}
}
/**
* Validar valor
*/
private validateValue(
value: string,
dataType: SettingDataType,
validation?: Record<string, any> | null
): void {
// Basic type validation
switch (dataType) {
case 'number':
if (isNaN(parseFloat(value))) {
throw new Error('Value must be a valid number');
}
break;
case 'boolean':
if (!['true', 'false', '1', '0'].includes(value)) {
throw new Error('Value must be true/false or 1/0');
}
break;
case 'json':
case 'array':
try {
JSON.parse(value);
} catch {
throw new Error('Value must be valid JSON');
}
break;
}
// Custom validation rules
if (validation) {
if (validation.min !== undefined && parseFloat(value) < validation.min) {
throw new Error(`Value must be at least ${validation.min}`);
}
if (validation.max !== undefined && parseFloat(value) > validation.max) {
throw new Error(`Value must be at most ${validation.max}`);
}
if (validation.pattern && !new RegExp(validation.pattern).test(value)) {
throw new Error(`Value does not match required pattern`);
}
if (validation.options && !validation.options.includes(value)) {
throw new Error(`Value must be one of: ${validation.options.join(', ')}`);
}
}
}
/**
* Obtener estadísticas
*/
async getStats(ctx: ServiceContext): Promise<SettingStats> {
const all = await this.repository.find({
where: { tenantId: ctx.tenantId } as any,
});
const byCategory = new Map<SettingCategory, number>();
let publicCount = 0;
let systemCount = 0;
let encryptedCount = 0;
for (const setting of all) {
byCategory.set(setting.category, (byCategory.get(setting.category) || 0) + 1);
if (setting.isPublic) publicCount++;
if (setting.isSystem) systemCount++;
if (setting.isEncrypted) encryptedCount++;
}
return {
total: all.length,
publicCount,
systemCount,
encryptedCount,
byCategory: Array.from(byCategory.entries()).map(([category, count]) => ({ category, count })),
};
}
}
export interface SettingStats {
total: number;
publicCount: number;
systemCount: number;
encryptedCount: number;
byCategory: { category: SettingCategory; count: number }[];
}

View File

@ -0,0 +1,268 @@
/**
* AuthController - Controlador de Autenticación
*
* Endpoints REST para login, register, refresh y logout.
* Implementa validación de datos y manejo de errores.
*
* @module Auth
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { AuthService } from '../services/auth.service';
import { AuthMiddleware } from '../middleware/auth.middleware';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../entities/refresh-token.entity';
import {
LoginDto,
RegisterDto,
RefreshTokenDto,
ChangePasswordDto,
} from '../dto/auth.dto';
/**
* Crear router de autenticación
*/
export function createAuthController(dataSource: DataSource): Router {
const router = Router();
// Inicializar repositorios
const userRepository = dataSource.getRepository(User);
const tenantRepository = dataSource.getRepository(Tenant);
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
// Inicializar servicio
const authService = new AuthService(
userRepository,
tenantRepository,
refreshTokenRepository as any
);
// Inicializar middleware
const authMiddleware = new AuthMiddleware(authService, dataSource);
/**
* POST /auth/login
* Login de usuario
*/
router.post('/login', async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const dto: LoginDto = req.body;
if (!dto.email || !dto.password) {
res.status(400).json({
error: 'Bad Request',
message: 'Email and password are required',
});
return;
}
const result = await authService.login(dto);
res.status(200).json({ success: true, data: result });
} catch (error) {
if (error instanceof Error) {
if (error.message === 'Invalid credentials') {
res.status(401).json({ error: 'Unauthorized', message: 'Invalid email or password' });
return;
}
if (error.message === 'User is not active') {
res.status(403).json({ error: 'Forbidden', message: 'User account is disabled' });
return;
}
if (error.message === 'No tenant specified' || error.message === 'Tenant not found or inactive') {
res.status(400).json({ error: 'Bad Request', message: error.message });
return;
}
}
next(error);
}
});
/**
* POST /auth/register
* Registro de nuevo usuario
*/
router.post('/register', async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const dto: RegisterDto = req.body;
if (!dto.email || !dto.password || !dto.firstName || !dto.lastName || !dto.tenantId) {
res.status(400).json({
error: 'Bad Request',
message: 'Email, password, firstName, lastName and tenantId are required',
});
return;
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(dto.email)) {
res.status(400).json({ error: 'Bad Request', message: 'Invalid email format' });
return;
}
if (dto.password.length < 8) {
res.status(400).json({ error: 'Bad Request', message: 'Password must be at least 8 characters' });
return;
}
const result = await authService.register(dto);
res.status(201).json({ success: true, data: result });
} catch (error) {
if (error instanceof Error) {
if (error.message === 'Email already registered') {
res.status(409).json({ error: 'Conflict', message: 'Email is already registered' });
return;
}
if (error.message === 'Tenant not found') {
res.status(400).json({ error: 'Bad Request', message: 'Invalid tenant ID' });
return;
}
}
next(error);
}
});
/**
* POST /auth/refresh
* Renovar access token usando refresh token
*/
router.post('/refresh', async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const dto: RefreshTokenDto = req.body;
if (!dto.refreshToken) {
res.status(400).json({ error: 'Bad Request', message: 'Refresh token is required' });
return;
}
const result = await authService.refresh(dto);
res.status(200).json({ success: true, data: result });
} catch (error) {
if (error instanceof Error) {
if (error.message === 'Invalid refresh token' || error.message === 'Refresh token expired or revoked') {
res.status(401).json({ error: 'Unauthorized', message: error.message });
return;
}
if (error.message === 'User not found or inactive') {
res.status(401).json({ error: 'Unauthorized', message: 'User account is disabled or deleted' });
return;
}
}
next(error);
}
});
/**
* POST /auth/logout
* Cerrar sesión (revocar refresh token)
*/
router.post('/logout', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const { refreshToken } = req.body;
if (refreshToken) {
await authService.logout(refreshToken);
}
res.status(200).json({ success: true, message: 'Logged out successfully' });
} catch (error) {
next(error);
}
});
/**
* POST /auth/change-password
* Cambiar contraseña (requiere autenticación)
*/
router.post('/change-password', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const dto: ChangePasswordDto = req.body;
const userId = req.user?.sub;
if (!userId) {
res.status(401).json({ error: 'Unauthorized', message: 'User not authenticated' });
return;
}
if (!dto.currentPassword || !dto.newPassword) {
res.status(400).json({ error: 'Bad Request', message: 'Current password and new password are required' });
return;
}
if (dto.newPassword.length < 8) {
res.status(400).json({ error: 'Bad Request', message: 'New password must be at least 8 characters' });
return;
}
await authService.changePassword(userId, dto);
res.status(200).json({ success: true, message: 'Password changed successfully' });
} catch (error) {
if (error instanceof Error && error.message === 'Current password is incorrect') {
res.status(400).json({ error: 'Bad Request', message: 'Current password is incorrect' });
return;
}
next(error);
}
});
/**
* GET /auth/me
* Obtener información del usuario autenticado
*/
router.get('/me', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const userId = req.user?.sub;
if (!userId) {
res.status(401).json({ error: 'Unauthorized', message: 'User not authenticated' });
return;
}
const user = await userRepository.findOne({
where: { id: userId } as any,
select: ['id', 'email', 'firstName', 'lastName', 'isActive', 'createdAt'],
});
if (!user) {
res.status(404).json({ error: 'Not Found', message: 'User not found' });
return;
}
res.status(200).json({
success: true,
data: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
roles: req.user?.roles || [],
tenantId: req.tenantId,
},
});
} catch (error) {
next(error);
}
});
/**
* GET /auth/verify
* Verificar si el token es válido
*/
router.get('/verify', authMiddleware.authenticate, (req: Request, res: Response): void => {
res.status(200).json({
success: true,
data: {
valid: true,
user: {
id: req.user?.sub,
email: req.user?.email,
roles: req.user?.roles,
},
tenantId: req.tenantId,
},
});
});
return router;
}
export default createAuthController;

View File

@ -0,0 +1,5 @@
/**
* Auth Controllers - Export
*/
export * from './auth.controller';

View File

@ -0,0 +1,70 @@
/**
* Auth DTOs - Data Transfer Objects para autenticación
*
* @module Auth
*/
export interface LoginDto {
email: string;
password: string;
tenantId?: string;
}
export interface RegisterDto {
email: string;
password: string;
firstName: string;
lastName: string;
tenantId: string;
}
export interface RefreshTokenDto {
refreshToken: string;
}
export interface ChangePasswordDto {
currentPassword: string;
newPassword: string;
}
export interface ResetPasswordRequestDto {
email: string;
}
export interface ResetPasswordDto {
token: string;
newPassword: string;
}
export interface TokenPayload {
sub: string; // userId
email: string;
tenantId: string;
roles: string[];
type: 'access' | 'refresh';
iat?: number;
exp?: number;
}
export interface AuthResponse {
accessToken: string;
refreshToken: string;
expiresIn: number;
user: {
id: string;
email: string;
firstName: string;
lastName: string;
roles: string[];
};
tenant: {
id: string;
name: string;
};
}
export interface TokenValidationResult {
valid: boolean;
payload?: TokenPayload;
error?: string;
}

View File

@ -0,0 +1,8 @@
/**
* Auth Entities - Export
*/
export { RefreshToken } from './refresh-token.entity';
export { Role } from './role.entity';
export { Permission } from './permission.entity';
export { UserRole } from './user-role.entity';

View File

@ -0,0 +1,34 @@
/**
* Permission Entity
* Permisos granulares del sistema
*
* @module Auth
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
} from 'typeorm';
@Entity({ schema: 'auth', name: 'permissions' })
export class Permission {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 100, unique: true })
code: string;
@Column({ type: 'varchar', length: 200 })
name: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ type: 'varchar', length: 50 })
module: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
}

View File

@ -0,0 +1,73 @@
/**
* RefreshToken Entity
*
* Almacena refresh tokens para autenticación JWT.
* Permite revocar tokens y gestionar sesiones.
*
* @module Auth
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { User } from '../../core/entities/user.entity';
@Entity({ name: 'refresh_tokens', schema: 'auth' })
@Index(['userId', 'revokedAt'])
@Index(['token'])
export class RefreshToken {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
@Column({ type: 'text' })
token: string;
@Column({ name: 'expires_at', type: 'timestamptz' })
expiresAt: Date;
@Column({ name: 'revoked_at', type: 'timestamptz', nullable: true })
revokedAt: Date | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ name: 'user_agent', type: 'varchar', length: 500, nullable: true })
userAgent: string | null;
@Column({ name: 'ip_address', type: 'varchar', length: 45, nullable: true })
ipAddress: string | null;
/**
* Verificar si el token está expirado
*/
isExpired(): boolean {
return this.expiresAt < new Date();
}
/**
* Verificar si el token está revocado
*/
isRevoked(): boolean {
return this.revokedAt !== null;
}
/**
* Verificar si el token es válido
*/
isValid(): boolean {
return !this.isExpired() && !this.isRevoked();
}
}

View File

@ -0,0 +1,58 @@
/**
* Role Entity
* Roles del sistema para RBAC
*
* @module Auth
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
ManyToMany,
JoinTable,
} from 'typeorm';
import { Permission } from './permission.entity';
import { UserRole } from './user-role.entity';
@Entity({ schema: 'auth', name: 'roles' })
export class Role {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 50, unique: true })
code: string;
@Column({ type: 'varchar', length: 100 })
name: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ name: 'is_system', type: 'boolean', default: false })
isSystem: boolean;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
// Relations
@ManyToMany(() => Permission)
@JoinTable({
name: 'role_permissions',
joinColumn: { name: 'role_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'permission_id', referencedColumnName: 'id' },
})
permissions: Permission[];
@OneToMany(() => UserRole, (userRole) => userRole.role)
userRoles: UserRole[];
}

View File

@ -0,0 +1,54 @@
/**
* UserRole Entity
* Relación usuarios-roles con soporte multi-tenant
*
* @module Auth
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { User } from '../../core/entities/user.entity';
import { Role } from './role.entity';
import { Tenant } from '../../core/entities/tenant.entity';
@Entity({ schema: 'auth', name: 'user_roles' })
@Index(['userId', 'roleId', 'tenantId'], { unique: true })
export class UserRole {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@Column({ name: 'role_id', type: 'uuid' })
roleId: string;
@Column({ name: 'tenant_id', type: 'uuid', nullable: true })
tenantId: string;
@Column({ name: 'assigned_by', type: 'uuid', nullable: true })
assignedBy: string;
@CreateDateColumn({ name: 'assigned_at', type: 'timestamptz' })
assignedAt: Date;
// Relations
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
@ManyToOne(() => Role, (role) => role.userRoles, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'role_id' })
role: Role;
@ManyToOne(() => Tenant, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
}

14
src/modules/auth/index.ts Normal file
View File

@ -0,0 +1,14 @@
/**
* Auth Module - Main Exports
*
* Módulo de autenticación con JWT y refresh tokens.
* Implementa multi-tenancy con RLS.
*
* @module Auth
*/
export * from './dto/auth.dto';
export { RefreshToken } from './entities/refresh-token.entity';
export { AuthService } from './services/auth.service';
export { AuthMiddleware, createAuthMiddleware } from './middleware/auth.middleware';
export { createAuthController } from './controllers/auth.controller';

View File

@ -0,0 +1,178 @@
/**
* Auth Middleware - Middleware de Autenticación
*
* Middleware para Express que valida JWT y extrae información del usuario.
* Configura el tenant_id para RLS en PostgreSQL.
*
* @module Auth
*/
import { Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { AuthService } from '../services/auth.service';
import { TokenPayload } from '../dto/auth.dto';
// Extender Request de Express con información de autenticación
declare global {
namespace Express {
interface Request {
user?: TokenPayload;
tenantId?: string;
}
}
}
export class AuthMiddleware {
constructor(
private readonly authService: AuthService,
private readonly dataSource: DataSource
) {}
/**
* Middleware de autenticación requerida
*/
authenticate = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const token = this.extractToken(req);
if (!token) {
res.status(401).json({
error: 'Unauthorized',
message: 'No token provided',
});
return;
}
const validation = this.authService.validateAccessToken(token);
if (!validation.valid || !validation.payload) {
res.status(401).json({
error: 'Unauthorized',
message: validation.error || 'Invalid token',
});
return;
}
// Establecer información en el request
req.user = validation.payload;
req.tenantId = validation.payload.tenantId;
// Configurar tenant_id para RLS en PostgreSQL
await this.setTenantContext(validation.payload.tenantId);
next();
} catch (error) {
res.status(401).json({
error: 'Unauthorized',
message: 'Authentication failed',
});
}
};
/**
* Middleware de autenticación opcional
*/
optionalAuthenticate = async (req: Request, _res: Response, next: NextFunction): Promise<void> => {
try {
const token = this.extractToken(req);
if (token) {
const validation = this.authService.validateAccessToken(token);
if (validation.valid && validation.payload) {
req.user = validation.payload;
req.tenantId = validation.payload.tenantId;
await this.setTenantContext(validation.payload.tenantId);
}
}
next();
} catch {
// Si hay error, continuar sin autenticación
next();
}
};
/**
* Middleware de autorización por roles
*/
authorize = (...allowedRoles: string[]) => {
return (req: Request, res: Response, next: NextFunction): void => {
if (!req.user) {
res.status(401).json({
error: 'Unauthorized',
message: 'Authentication required',
});
return;
}
const hasRole = req.user.roles.some((role) => allowedRoles.includes(role));
if (!hasRole) {
res.status(403).json({
error: 'Forbidden',
message: 'Insufficient permissions',
});
return;
}
next();
};
};
/**
* Middleware que requiere rol de admin
*/
requireAdmin = (req: Request, res: Response, next: NextFunction): void => {
return this.authorize('admin', 'super_admin')(req, res, next);
};
/**
* Middleware que requiere ser supervisor
*/
requireSupervisor = (req: Request, res: Response, next: NextFunction): void => {
return this.authorize('admin', 'super_admin', 'supervisor_obra', 'supervisor_hse')(req, res, next);
};
/**
* Extraer token del header Authorization
*/
private extractToken(req: Request): string | null {
const authHeader = req.headers.authorization;
if (!authHeader) {
return null;
}
// Bearer token
const [type, token] = authHeader.split(' ');
if (type !== 'Bearer' || !token) {
return null;
}
return token;
}
/**
* Configurar contexto de tenant para RLS
*/
private async setTenantContext(tenantId: string): Promise<void> {
try {
await this.dataSource.query(`SET app.current_tenant_id = '${tenantId}'`);
} catch (error) {
console.error('Error setting tenant context:', error);
throw new Error('Failed to set tenant context');
}
}
}
/**
* Factory para crear middleware de autenticación
*/
export function createAuthMiddleware(
authService: AuthService,
dataSource: DataSource
): AuthMiddleware {
return new AuthMiddleware(authService, dataSource);
}

View File

@ -0,0 +1,370 @@
/**
* AuthService - Servicio de Autenticación
*
* Gestiona login, logout, refresh tokens y validación de JWT.
* Implementa patrón multi-tenant con verificación de tenant_id.
*
* @module Auth
*/
import * as jwt from 'jsonwebtoken';
import * as bcrypt from 'bcryptjs';
import { Repository } from 'typeorm';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import {
LoginDto,
RegisterDto,
RefreshTokenDto,
ChangePasswordDto,
TokenPayload,
AuthResponse,
TokenValidationResult,
} from '../dto/auth.dto';
export interface RefreshToken {
id: string;
userId: string;
token: string;
expiresAt: Date;
revokedAt?: Date;
}
export class AuthService {
private readonly jwtSecret: string;
private readonly jwtExpiresIn: string;
private readonly jwtRefreshExpiresIn: string;
constructor(
private readonly userRepository: Repository<User>,
private readonly tenantRepository: Repository<Tenant>,
private readonly refreshTokenRepository: Repository<RefreshToken>
) {
this.jwtSecret = process.env.JWT_SECRET || 'your-super-secret-jwt-key-change-in-production-minimum-32-chars';
this.jwtExpiresIn = process.env.JWT_EXPIRES_IN || '1d';
this.jwtRefreshExpiresIn = process.env.JWT_REFRESH_EXPIRES_IN || '7d';
}
/**
* Login de usuario
*/
async login(dto: LoginDto): Promise<AuthResponse> {
// Buscar usuario por email
const user = await this.userRepository.findOne({
where: { email: dto.email, deletedAt: null } as any,
relations: ['userRoles', 'userRoles.role'],
});
if (!user) {
throw new Error('Invalid credentials');
}
// Verificar password
const isPasswordValid = await bcrypt.compare(dto.password, user.passwordHash);
if (!isPasswordValid) {
throw new Error('Invalid credentials');
}
// Verificar que el usuario esté activo
if (!user.isActive) {
throw new Error('User is not active');
}
// Obtener tenant
const tenantId = dto.tenantId || user.defaultTenantId;
if (!tenantId) {
throw new Error('No tenant specified');
}
const tenant = await this.tenantRepository.findOne({
where: { id: tenantId, isActive: true, deletedAt: null } as any,
});
if (!tenant) {
throw new Error('Tenant not found or inactive');
}
// Obtener roles del usuario
const roles = user.userRoles?.map((ur) => ur.role.code) || [];
// Generar tokens
const accessToken = this.generateAccessToken(user, tenantId, roles);
const refreshToken = await this.generateRefreshToken(user.id);
// Actualizar último login
await this.userRepository.update(user.id, { lastLoginAt: new Date() });
return {
accessToken,
refreshToken,
expiresIn: this.getExpiresInSeconds(this.jwtExpiresIn),
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
roles,
},
tenant: {
id: tenant.id,
name: tenant.name,
},
};
}
/**
* Registro de usuario
*/
async register(dto: RegisterDto): Promise<AuthResponse> {
// Verificar si el email ya existe
const existingUser = await this.userRepository.findOne({
where: { email: dto.email } as any,
});
if (existingUser) {
throw new Error('Email already registered');
}
// Verificar que el tenant existe
const tenant = await this.tenantRepository.findOne({
where: { id: dto.tenantId, isActive: true } as any,
});
if (!tenant) {
throw new Error('Tenant not found');
}
// Hash del password
const passwordHash = await bcrypt.hash(dto.password, 12);
// Crear usuario
const user = await this.userRepository.save(
this.userRepository.create({
email: dto.email,
passwordHash,
firstName: dto.firstName,
lastName: dto.lastName,
defaultTenantId: dto.tenantId,
isActive: true,
})
);
// Generar tokens (rol default: user)
const roles = ['user'];
const accessToken = this.generateAccessToken(user, dto.tenantId, roles);
const refreshToken = await this.generateRefreshToken(user.id);
return {
accessToken,
refreshToken,
expiresIn: this.getExpiresInSeconds(this.jwtExpiresIn),
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
roles,
},
tenant: {
id: tenant.id,
name: tenant.name,
},
};
}
/**
* Refresh de token
*/
async refresh(dto: RefreshTokenDto): Promise<AuthResponse> {
// Validar refresh token
const validation = this.validateToken(dto.refreshToken, 'refresh');
if (!validation.valid || !validation.payload) {
throw new Error('Invalid refresh token');
}
// Verificar que el token no está revocado
const storedToken = await this.refreshTokenRepository.findOne({
where: { token: dto.refreshToken, revokedAt: null } as any,
});
if (!storedToken || storedToken.expiresAt < new Date()) {
throw new Error('Refresh token expired or revoked');
}
// Obtener usuario
const user = await this.userRepository.findOne({
where: { id: validation.payload.sub, deletedAt: null } as any,
relations: ['userRoles', 'userRoles.role'],
});
if (!user || !user.isActive) {
throw new Error('User not found or inactive');
}
// Obtener tenant
const tenant = await this.tenantRepository.findOne({
where: { id: validation.payload.tenantId, isActive: true } as any,
});
if (!tenant) {
throw new Error('Tenant not found or inactive');
}
const roles = user.userRoles?.map((ur) => ur.role.code) || [];
// Revocar token anterior
await this.refreshTokenRepository.update(storedToken.id, { revokedAt: new Date() });
// Generar nuevos tokens
const accessToken = this.generateAccessToken(user, tenant.id, roles);
const refreshToken = await this.generateRefreshToken(user.id);
return {
accessToken,
refreshToken,
expiresIn: this.getExpiresInSeconds(this.jwtExpiresIn),
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
roles,
},
tenant: {
id: tenant.id,
name: tenant.name,
},
};
}
/**
* Logout - Revocar refresh token
*/
async logout(refreshToken: string): Promise<void> {
await this.refreshTokenRepository.update(
{ token: refreshToken } as any,
{ revokedAt: new Date() }
);
}
/**
* Cambiar password
*/
async changePassword(userId: string, dto: ChangePasswordDto): Promise<void> {
const user = await this.userRepository.findOne({
where: { id: userId } as any,
});
if (!user) {
throw new Error('User not found');
}
const isCurrentValid = await bcrypt.compare(dto.currentPassword, user.passwordHash);
if (!isCurrentValid) {
throw new Error('Current password is incorrect');
}
const newPasswordHash = await bcrypt.hash(dto.newPassword, 12);
await this.userRepository.update(userId, { passwordHash: newPasswordHash });
// Revocar todos los refresh tokens del usuario
await this.refreshTokenRepository.update(
{ userId } as any,
{ revokedAt: new Date() }
);
}
/**
* Validar access token
*/
validateAccessToken(token: string): TokenValidationResult {
return this.validateToken(token, 'access');
}
/**
* Validar token
*/
private validateToken(token: string, expectedType: 'access' | 'refresh'): TokenValidationResult {
try {
const payload = jwt.verify(token, this.jwtSecret) as TokenPayload;
if (payload.type !== expectedType) {
return { valid: false, error: 'Invalid token type' };
}
return { valid: true, payload };
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
return { valid: false, error: 'Token expired' };
}
if (error instanceof jwt.JsonWebTokenError) {
return { valid: false, error: 'Invalid token' };
}
return { valid: false, error: 'Token validation failed' };
}
}
/**
* Generar access token
*/
private generateAccessToken(user: User, tenantId: string, roles: string[]): string {
const payload: TokenPayload = {
sub: user.id,
email: user.email,
tenantId,
roles,
type: 'access',
};
return jwt.sign(payload, this.jwtSecret, {
expiresIn: this.jwtExpiresIn as jwt.SignOptions['expiresIn'],
});
}
/**
* Generar refresh token
*/
private async generateRefreshToken(userId: string): Promise<string> {
const payload: Partial<TokenPayload> = {
sub: userId,
type: 'refresh',
};
const token = jwt.sign(payload, this.jwtSecret, {
expiresIn: this.jwtRefreshExpiresIn as jwt.SignOptions['expiresIn'],
});
// Almacenar en DB
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7); // 7 días
await this.refreshTokenRepository.save(
this.refreshTokenRepository.create({
userId,
token,
expiresAt,
})
);
return token;
}
/**
* Convertir expiresIn a segundos
*/
private getExpiresInSeconds(expiresIn: string): number {
const match = expiresIn.match(/^(\d+)([dhms])$/);
if (!match) return 86400; // default 1 día
const value = parseInt(match[1]);
const unit = match[2];
switch (unit) {
case 'd': return value * 86400;
case 'h': return value * 3600;
case 'm': return value * 60;
case 's': return value;
default: return 86400;
}
}
}

View File

@ -0,0 +1,5 @@
/**
* Auth Module - Service Exports
*/
export * from './auth.service';

View File

@ -0,0 +1,175 @@
/**
* BidAnalyticsController - Controller de Análisis de Licitaciones
*
* Endpoints REST para dashboards y análisis de preconstrucción.
*
* @module Bidding
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { BidAnalyticsService } from '../services/bid-analytics.service';
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
import { AuthService } from '../../auth/services/auth.service';
import { Bid } from '../entities/bid.entity';
import { Opportunity } from '../entities/opportunity.entity';
import { BidCompetitor } from '../entities/bid-competitor.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
export function createBidAnalyticsController(dataSource: DataSource): Router {
const router = Router();
// Repositorios
const bidRepository = dataSource.getRepository(Bid);
const opportunityRepository = dataSource.getRepository(Opportunity);
const competitorRepository = dataSource.getRepository(BidCompetitor);
const userRepository = dataSource.getRepository(User);
const tenantRepository = dataSource.getRepository(Tenant);
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
// Servicios
const analyticsService = new BidAnalyticsService(bidRepository, opportunityRepository, competitorRepository);
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
const authMiddleware = new AuthMiddleware(authService, dataSource);
// Helper para crear contexto
const getContext = (req: Request): ServiceContext => {
if (!req.tenantId) {
throw new Error('Tenant ID is required');
}
return {
tenantId: req.tenantId,
userId: req.user?.sub,
};
};
/**
* GET /bid-analytics/dashboard
*/
router.get('/dashboard', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const dashboard = await analyticsService.getDashboard(getContext(req));
res.status(200).json({ success: true, data: dashboard });
} catch (error) {
next(error);
}
});
/**
* GET /bid-analytics/pipeline-by-source
*/
router.get('/pipeline-by-source', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const data = await analyticsService.getPipelineBySource(getContext(req));
res.status(200).json({ success: true, data });
} catch (error) {
next(error);
}
});
/**
* GET /bid-analytics/win-rate-by-type
*/
router.get('/win-rate-by-type', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const months = parseInt(req.query.months as string) || 12;
const data = await analyticsService.getWinRateByType(getContext(req), months);
res.status(200).json({ success: true, data });
} catch (error) {
next(error);
}
});
/**
* GET /bid-analytics/monthly-trend
*/
router.get('/monthly-trend', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const months = parseInt(req.query.months as string) || 12;
const data = await analyticsService.getMonthlyTrend(getContext(req), months);
res.status(200).json({ success: true, data });
} catch (error) {
next(error);
}
});
/**
* GET /bid-analytics/competitors
*/
router.get('/competitors', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const data = await analyticsService.getCompetitorAnalysis(getContext(req));
res.status(200).json({ success: true, data });
} catch (error) {
next(error);
}
});
/**
* GET /bid-analytics/funnel
*/
router.get('/funnel', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const months = parseInt(req.query.months as string) || 12;
const data = await analyticsService.getFunnelAnalysis(getContext(req), months);
res.status(200).json({ success: true, data });
} catch (error) {
next(error);
}
});
/**
* GET /bid-analytics/cycle-time
*/
router.get('/cycle-time', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const months = parseInt(req.query.months as string) || 12;
const data = await analyticsService.getCycleTimeAnalysis(getContext(req), months);
res.status(200).json({ success: true, data });
} catch (error) {
next(error);
}
});
return router;
}
export default createBidAnalyticsController;

View File

@ -0,0 +1,254 @@
/**
* BidBudgetController - Controller de Presupuestos de Licitación
*
* Endpoints REST para gestión de propuestas económicas.
*
* @module Bidding
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { BidBudgetService, CreateBudgetItemDto, UpdateBudgetItemDto, BudgetFilters } from '../services/bid-budget.service';
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
import { AuthService } from '../../auth/services/auth.service';
import { BidBudget } from '../entities/bid-budget.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
export function createBidBudgetController(dataSource: DataSource): Router {
const router = Router();
// Repositorios
const budgetRepository = dataSource.getRepository(BidBudget);
const userRepository = dataSource.getRepository(User);
const tenantRepository = dataSource.getRepository(Tenant);
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
// Servicios
const budgetService = new BidBudgetService(budgetRepository);
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
const authMiddleware = new AuthMiddleware(authService, dataSource);
// Helper para crear contexto
const getContext = (req: Request): ServiceContext => {
if (!req.tenantId) {
throw new Error('Tenant ID is required');
}
return {
tenantId: req.tenantId,
userId: req.user?.sub,
};
};
/**
* GET /bid-budgets
*/
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const bidId = req.query.bidId as string;
if (!bidId) {
res.status(400).json({ error: 'Bad Request', message: 'bidId is required' });
return;
}
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 100, 500);
const filters: BudgetFilters = { bidId };
if (req.query.itemType) filters.itemType = req.query.itemType as any;
if (req.query.status) filters.status = req.query.status as any;
if (req.query.parentId !== undefined) {
filters.parentId = req.query.parentId === 'null' ? null : req.query.parentId as string;
}
if (req.query.isSummary !== undefined) filters.isSummary = req.query.isSummary === 'true';
const result = await budgetService.findWithFilters(getContext(req), filters, page, limit);
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
});
} catch (error) {
next(error);
}
});
/**
* GET /bid-budgets/tree
*/
router.get('/tree', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const bidId = req.query.bidId as string;
if (!bidId) {
res.status(400).json({ error: 'Bad Request', message: 'bidId is required' });
return;
}
const tree = await budgetService.getTree(getContext(req), bidId);
res.status(200).json({ success: true, data: tree });
} catch (error) {
next(error);
}
});
/**
* GET /bid-budgets/summary
*/
router.get('/summary', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const bidId = req.query.bidId as string;
if (!bidId) {
res.status(400).json({ error: 'Bad Request', message: 'bidId is required' });
return;
}
const summary = await budgetService.getSummary(getContext(req), bidId);
res.status(200).json({ success: true, data: summary });
} catch (error) {
next(error);
}
});
/**
* GET /bid-budgets/:id
*/
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const item = await budgetService.findById(getContext(req), req.params.id);
if (!item) {
res.status(404).json({ error: 'Not Found', message: 'Budget item not found' });
return;
}
res.status(200).json({ success: true, data: item });
} catch (error) {
next(error);
}
});
/**
* POST /bid-budgets
*/
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'costos'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const dto: CreateBudgetItemDto = req.body;
if (!dto.bidId || !dto.code || !dto.name || !dto.itemType) {
res.status(400).json({
error: 'Bad Request',
message: 'bidId, code, name, and itemType are required',
});
return;
}
const item = await budgetService.create(getContext(req), dto);
res.status(201).json({ success: true, data: item });
} catch (error) {
next(error);
}
});
/**
* PUT /bid-budgets/:id
*/
router.put('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'costos'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const dto: UpdateBudgetItemDto = req.body;
const item = await budgetService.update(getContext(req), req.params.id, dto);
if (!item) {
res.status(404).json({ error: 'Not Found', message: 'Budget item not found' });
return;
}
res.status(200).json({ success: true, data: item });
} catch (error) {
next(error);
}
});
/**
* POST /bid-budgets/status
*/
router.post('/status', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const { bidId, status } = req.body;
if (!bidId || !status) {
res.status(400).json({ error: 'Bad Request', message: 'bidId and status are required' });
return;
}
const updated = await budgetService.changeStatus(getContext(req), bidId, status);
res.status(200).json({
success: true,
message: `Updated ${updated} budget items`,
data: { updated },
});
} catch (error) {
next(error);
}
});
/**
* DELETE /bid-budgets/:id
*/
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const deleted = await budgetService.softDelete(getContext(req), req.params.id);
if (!deleted) {
res.status(404).json({ error: 'Not Found', message: 'Budget item not found' });
return;
}
res.status(200).json({ success: true, message: 'Budget item deleted' });
} catch (error) {
next(error);
}
});
return router;
}
export default createBidBudgetController;

View File

@ -0,0 +1,370 @@
/**
* BidController - Controller de Licitaciones
*
* Endpoints REST para gestión de licitaciones/propuestas.
*
* @module Bidding
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { BidService, CreateBidDto, UpdateBidDto, BidFilters } from '../services/bid.service';
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
import { AuthService } from '../../auth/services/auth.service';
import { Bid, BidStatus } from '../entities/bid.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
export function createBidController(dataSource: DataSource): Router {
const router = Router();
// Repositorios
const bidRepository = dataSource.getRepository(Bid);
const userRepository = dataSource.getRepository(User);
const tenantRepository = dataSource.getRepository(Tenant);
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
// Servicios
const bidService = new BidService(bidRepository);
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
const authMiddleware = new AuthMiddleware(authService, dataSource);
// Helper para crear contexto
const getContext = (req: Request): ServiceContext => {
if (!req.tenantId) {
throw new Error('Tenant ID is required');
}
return {
tenantId: req.tenantId,
userId: req.user?.sub,
};
};
/**
* GET /bids
*/
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const filters: BidFilters = {};
if (req.query.status) {
const statuses = (req.query.status as string).split(',') as BidStatus[];
filters.status = statuses.length === 1 ? statuses[0] : statuses;
}
if (req.query.bidType) filters.bidType = req.query.bidType as any;
if (req.query.stage) filters.stage = req.query.stage as any;
if (req.query.opportunityId) filters.opportunityId = req.query.opportunityId as string;
if (req.query.bidManagerId) filters.bidManagerId = req.query.bidManagerId as string;
if (req.query.contractingEntity) filters.contractingEntity = req.query.contractingEntity as string;
if (req.query.deadlineFrom) filters.deadlineFrom = new Date(req.query.deadlineFrom as string);
if (req.query.deadlineTo) filters.deadlineTo = new Date(req.query.deadlineTo as string);
if (req.query.minBudget) filters.minBudget = parseFloat(req.query.minBudget as string);
if (req.query.maxBudget) filters.maxBudget = parseFloat(req.query.maxBudget as string);
if (req.query.search) filters.search = req.query.search as string;
const result = await bidService.findWithFilters(getContext(req), filters, page, limit);
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
});
} catch (error) {
next(error);
}
});
/**
* GET /bids/upcoming-deadlines
*/
router.get('/upcoming-deadlines', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const days = parseInt(req.query.days as string) || 7;
const bids = await bidService.getUpcomingDeadlines(getContext(req), days);
res.status(200).json({ success: true, data: bids });
} catch (error) {
next(error);
}
});
/**
* GET /bids/stats
*/
router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const year = req.query.year ? parseInt(req.query.year as string) : undefined;
const stats = await bidService.getStats(getContext(req), year);
res.status(200).json({ success: true, data: stats });
} catch (error) {
next(error);
}
});
/**
* GET /bids/:id
*/
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const bid = await bidService.findById(getContext(req), req.params.id);
if (!bid) {
res.status(404).json({ error: 'Not Found', message: 'Bid not found' });
return;
}
res.status(200).json({ success: true, data: bid });
} catch (error) {
next(error);
}
});
/**
* POST /bids
*/
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'comercial'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const dto: CreateBidDto = req.body;
if (!dto.opportunityId || !dto.code || !dto.name || !dto.bidType || !dto.submissionDeadline) {
res.status(400).json({
error: 'Bad Request',
message: 'opportunityId, code, name, bidType, and submissionDeadline are required',
});
return;
}
const bid = await bidService.create(getContext(req), dto);
res.status(201).json({ success: true, data: bid });
} catch (error) {
next(error);
}
});
/**
* PUT /bids/:id
*/
router.put('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'comercial'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const dto: UpdateBidDto = req.body;
const bid = await bidService.update(getContext(req), req.params.id, dto);
if (!bid) {
res.status(404).json({ error: 'Not Found', message: 'Bid not found' });
return;
}
res.status(200).json({ success: true, data: bid });
} catch (error) {
next(error);
}
});
/**
* POST /bids/:id/status
*/
router.post('/:id/status', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const { status } = req.body;
if (!status) {
res.status(400).json({ error: 'Bad Request', message: 'status is required' });
return;
}
const bid = await bidService.changeStatus(getContext(req), req.params.id, status);
if (!bid) {
res.status(404).json({ error: 'Not Found', message: 'Bid not found' });
return;
}
res.status(200).json({ success: true, data: bid });
} catch (error) {
next(error);
}
});
/**
* POST /bids/:id/stage
*/
router.post('/:id/stage', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const { stage } = req.body;
if (!stage) {
res.status(400).json({ error: 'Bad Request', message: 'stage is required' });
return;
}
const bid = await bidService.changeStage(getContext(req), req.params.id, stage);
if (!bid) {
res.status(404).json({ error: 'Not Found', message: 'Bid not found' });
return;
}
res.status(200).json({ success: true, data: bid });
} catch (error) {
next(error);
}
});
/**
* POST /bids/:id/submit
*/
router.post('/:id/submit', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const { proposalAmount } = req.body;
if (!proposalAmount) {
res.status(400).json({ error: 'Bad Request', message: 'proposalAmount is required' });
return;
}
const bid = await bidService.submit(getContext(req), req.params.id, proposalAmount);
if (!bid) {
res.status(404).json({ error: 'Not Found', message: 'Bid not found' });
return;
}
res.status(200).json({ success: true, data: bid });
} catch (error) {
next(error);
}
});
/**
* POST /bids/:id/result
*/
router.post('/:id/result', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const { won, winnerName, winningAmount, rankingPosition, rejectionReason, lessonsLearned } = req.body;
if (won === undefined) {
res.status(400).json({ error: 'Bad Request', message: 'won is required' });
return;
}
const bid = await bidService.recordResult(getContext(req), req.params.id, won, {
winnerName,
winningAmount,
rankingPosition,
rejectionReason,
lessonsLearned,
});
if (!bid) {
res.status(404).json({ error: 'Not Found', message: 'Bid not found' });
return;
}
res.status(200).json({ success: true, data: bid });
} catch (error) {
next(error);
}
});
/**
* POST /bids/:id/convert
*/
router.post('/:id/convert', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const { projectId } = req.body;
if (!projectId) {
res.status(400).json({ error: 'Bad Request', message: 'projectId is required' });
return;
}
const bid = await bidService.convertToProject(getContext(req), req.params.id, projectId);
if (!bid) {
res.status(404).json({ error: 'Not Found', message: 'Bid not found or not awarded' });
return;
}
res.status(200).json({ success: true, data: bid });
} catch (error) {
next(error);
}
});
/**
* DELETE /bids/:id
*/
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const deleted = await bidService.softDelete(getContext(req), req.params.id);
if (!deleted) {
res.status(404).json({ error: 'Not Found', message: 'Bid not found' });
return;
}
res.status(200).json({ success: true, message: 'Bid deleted' });
} catch (error) {
next(error);
}
});
return router;
}
export default createBidController;

View File

@ -0,0 +1,9 @@
/**
* Bidding Controllers Index
* @module Bidding
*/
export { createOpportunityController } from './opportunity.controller';
export { createBidController } from './bid.controller';
export { createBidBudgetController } from './bid-budget.controller';
export { createBidAnalyticsController } from './bid-analytics.controller';

View File

@ -0,0 +1,266 @@
/**
* OpportunityController - Controller de Oportunidades
*
* Endpoints REST para gestión del pipeline de oportunidades.
*
* @module Bidding
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { OpportunityService, CreateOpportunityDto, UpdateOpportunityDto, OpportunityFilters } from '../services/opportunity.service';
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
import { AuthService } from '../../auth/services/auth.service';
import { Opportunity, OpportunityStatus } from '../entities/opportunity.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
export function createOpportunityController(dataSource: DataSource): Router {
const router = Router();
// Repositorios
const opportunityRepository = dataSource.getRepository(Opportunity);
const userRepository = dataSource.getRepository(User);
const tenantRepository = dataSource.getRepository(Tenant);
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
// Servicios
const opportunityService = new OpportunityService(opportunityRepository);
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
const authMiddleware = new AuthMiddleware(authService, dataSource);
// Helper para crear contexto
const getContext = (req: Request): ServiceContext => {
if (!req.tenantId) {
throw new Error('Tenant ID is required');
}
return {
tenantId: req.tenantId,
userId: req.user?.sub,
};
};
/**
* GET /opportunities
*/
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const filters: OpportunityFilters = {};
if (req.query.status) {
const statuses = (req.query.status as string).split(',') as OpportunityStatus[];
filters.status = statuses.length === 1 ? statuses[0] : statuses;
}
if (req.query.source) filters.source = req.query.source as any;
if (req.query.projectType) filters.projectType = req.query.projectType as any;
if (req.query.priority) filters.priority = req.query.priority as any;
if (req.query.assignedToId) filters.assignedToId = req.query.assignedToId as string;
if (req.query.clientName) filters.clientName = req.query.clientName as string;
if (req.query.state) filters.state = req.query.state as string;
if (req.query.dateFrom) filters.dateFrom = new Date(req.query.dateFrom as string);
if (req.query.dateTo) filters.dateTo = new Date(req.query.dateTo as string);
if (req.query.minValue) filters.minValue = parseFloat(req.query.minValue as string);
if (req.query.maxValue) filters.maxValue = parseFloat(req.query.maxValue as string);
if (req.query.search) filters.search = req.query.search as string;
const result = await opportunityService.findWithFilters(getContext(req), filters, page, limit);
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
});
} catch (error) {
next(error);
}
});
/**
* GET /opportunities/pipeline
*/
router.get('/pipeline', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const pipeline = await opportunityService.getPipeline(getContext(req));
res.status(200).json({ success: true, data: pipeline });
} catch (error) {
next(error);
}
});
/**
* GET /opportunities/upcoming-deadlines
*/
router.get('/upcoming-deadlines', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const days = parseInt(req.query.days as string) || 7;
const opportunities = await opportunityService.getUpcomingDeadlines(getContext(req), days);
res.status(200).json({ success: true, data: opportunities });
} catch (error) {
next(error);
}
});
/**
* GET /opportunities/stats
*/
router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const year = req.query.year ? parseInt(req.query.year as string) : undefined;
const stats = await opportunityService.getStats(getContext(req), year);
res.status(200).json({ success: true, data: stats });
} catch (error) {
next(error);
}
});
/**
* GET /opportunities/:id
*/
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const opportunity = await opportunityService.findById(getContext(req), req.params.id);
if (!opportunity) {
res.status(404).json({ error: 'Not Found', message: 'Opportunity not found' });
return;
}
res.status(200).json({ success: true, data: opportunity });
} catch (error) {
next(error);
}
});
/**
* POST /opportunities
*/
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'comercial'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const dto: CreateOpportunityDto = req.body;
if (!dto.code || !dto.name || !dto.source || !dto.projectType || !dto.clientName || !dto.identificationDate) {
res.status(400).json({
error: 'Bad Request',
message: 'code, name, source, projectType, clientName, and identificationDate are required',
});
return;
}
const opportunity = await opportunityService.create(getContext(req), dto);
res.status(201).json({ success: true, data: opportunity });
} catch (error) {
next(error);
}
});
/**
* PUT /opportunities/:id
*/
router.put('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'comercial'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const dto: UpdateOpportunityDto = req.body;
const opportunity = await opportunityService.update(getContext(req), req.params.id, dto);
if (!opportunity) {
res.status(404).json({ error: 'Not Found', message: 'Opportunity not found' });
return;
}
res.status(200).json({ success: true, data: opportunity });
} catch (error) {
next(error);
}
});
/**
* POST /opportunities/:id/status
*/
router.post('/:id/status', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'comercial'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const { status, reason } = req.body;
if (!status) {
res.status(400).json({ error: 'Bad Request', message: 'status is required' });
return;
}
const opportunity = await opportunityService.changeStatus(getContext(req), req.params.id, status, reason);
if (!opportunity) {
res.status(404).json({ error: 'Not Found', message: 'Opportunity not found' });
return;
}
res.status(200).json({ success: true, data: opportunity });
} catch (error) {
next(error);
}
});
/**
* DELETE /opportunities/:id
*/
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const deleted = await opportunityService.softDelete(getContext(req), req.params.id);
if (!deleted) {
res.status(404).json({ error: 'Not Found', message: 'Opportunity not found' });
return;
}
res.status(200).json({ success: true, message: 'Opportunity deleted' });
} catch (error) {
next(error);
}
});
return router;
}
export default createOpportunityController;

View File

@ -0,0 +1,256 @@
/**
* BidBudget Entity - Presupuesto de Licitación
*
* Desglose del presupuesto para la propuesta económica.
*
* @module Bidding
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Bid } from './bid.entity';
export type BudgetItemType =
| 'direct_cost'
| 'indirect_cost'
| 'labor'
| 'materials'
| 'equipment'
| 'subcontract'
| 'overhead'
| 'profit'
| 'contingency'
| 'financing'
| 'taxes'
| 'bonds'
| 'other';
export type BudgetStatus = 'draft' | 'calculated' | 'reviewed' | 'approved' | 'locked';
@Entity('bid_budget', { schema: 'bidding' })
@Index(['tenantId', 'bidId'])
@Index(['tenantId', 'itemType'])
export class BidBudget {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId!: string;
// Referencia a licitación
@Column({ name: 'bid_id', type: 'uuid' })
bidId!: string;
@ManyToOne(() => Bid, (bid) => bid.budgetItems)
@JoinColumn({ name: 'bid_id' })
bid?: Bid;
// Jerarquía
@Column({ name: 'parent_id', type: 'uuid', nullable: true })
parentId?: string;
@Column({ name: 'sort_order', type: 'int', default: 0 })
sortOrder!: number;
@Column({ type: 'int', default: 0 })
level!: number;
@Column({ length: 50 })
code!: string;
// Información del item
@Column({ length: 255 })
name!: string;
@Column({ type: 'text', nullable: true })
description?: string;
@Column({
name: 'item_type',
type: 'enum',
enum: ['direct_cost', 'indirect_cost', 'labor', 'materials', 'equipment', 'subcontract', 'overhead', 'profit', 'contingency', 'financing', 'taxes', 'bonds', 'other'],
enumName: 'bid_budget_item_type',
})
itemType!: BudgetItemType;
@Column({
type: 'enum',
enum: ['draft', 'calculated', 'reviewed', 'approved', 'locked'],
enumName: 'bid_budget_status',
default: 'draft',
})
status!: BudgetStatus;
// Unidad y cantidad
@Column({ length: 20, nullable: true })
unit?: string;
@Column({
type: 'decimal',
precision: 18,
scale: 4,
default: 0,
})
quantity!: number;
// Precios
@Column({
name: 'unit_price',
type: 'decimal',
precision: 18,
scale: 4,
default: 0,
})
unitPrice!: number;
@Column({
name: 'total_amount',
type: 'decimal',
precision: 18,
scale: 2,
default: 0,
})
totalAmount!: number;
// Desglose de costos directos
@Column({
name: 'materials_cost',
type: 'decimal',
precision: 18,
scale: 2,
nullable: true,
})
materialsCost?: number;
@Column({
name: 'labor_cost',
type: 'decimal',
precision: 18,
scale: 2,
nullable: true,
})
laborCost?: number;
@Column({
name: 'equipment_cost',
type: 'decimal',
precision: 18,
scale: 2,
nullable: true,
})
equipmentCost?: number;
@Column({
name: 'subcontract_cost',
type: 'decimal',
precision: 18,
scale: 2,
nullable: true,
})
subcontractCost?: number;
// Porcentajes
@Column({
name: 'indirect_percentage',
type: 'decimal',
precision: 5,
scale: 2,
nullable: true,
})
indirectPercentage?: number;
@Column({
name: 'profit_percentage',
type: 'decimal',
precision: 5,
scale: 2,
nullable: true,
})
profitPercentage?: number;
@Column({
name: 'financing_percentage',
type: 'decimal',
precision: 5,
scale: 2,
nullable: true,
})
financingPercentage?: number;
// Comparación con base de licitación
@Column({
name: 'base_amount',
type: 'decimal',
precision: 18,
scale: 2,
nullable: true,
})
baseAmount?: number;
@Column({
name: 'variance_amount',
type: 'decimal',
precision: 18,
scale: 2,
nullable: true,
})
varianceAmount?: number;
@Column({
name: 'variance_percentage',
type: 'decimal',
precision: 8,
scale: 2,
nullable: true,
})
variancePercentage?: number;
// Flags
@Column({ name: 'is_summary', type: 'boolean', default: false })
isSummary!: boolean;
@Column({ name: 'is_calculated', type: 'boolean', default: false })
isCalculated!: boolean;
@Column({ name: 'is_adjusted', type: 'boolean', default: false })
isAdjusted!: boolean;
@Column({ name: 'adjustment_reason', type: 'text', nullable: true })
adjustmentReason?: string;
// Referencia a concepto de catálogo
@Column({ name: 'catalog_concept_id', type: 'uuid', nullable: true })
catalogConceptId?: string;
// Notas y metadatos
@Column({ type: 'text', nullable: true })
notes?: string;
@Column({ type: 'jsonb', nullable: true })
metadata?: Record<string, any>;
// Auditoría
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy?: string;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy?: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt?: Date;
}

View File

@ -0,0 +1,188 @@
/**
* BidCalendar Entity - Calendario de Licitación
*
* Eventos y fechas importantes del proceso de licitación.
*
* @module Bidding
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { User } from '../../core/entities/user.entity';
import { Bid } from './bid.entity';
export type CalendarEventType =
| 'publication'
| 'site_visit'
| 'clarification_meeting'
| 'clarification_deadline'
| 'submission_deadline'
| 'opening'
| 'technical_evaluation'
| 'economic_evaluation'
| 'award_notification'
| 'contract_signing'
| 'kick_off'
| 'milestone'
| 'internal_review'
| 'team_meeting'
| 'reminder'
| 'other';
export type EventPriority = 'low' | 'medium' | 'high' | 'critical';
export type EventStatus = 'scheduled' | 'in_progress' | 'completed' | 'cancelled' | 'postponed';
@Entity('bid_calendar', { schema: 'bidding' })
@Index(['tenantId', 'bidId'])
@Index(['tenantId', 'eventDate'])
@Index(['tenantId', 'eventType'])
export class BidCalendar {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId!: string;
// Referencia a licitación
@Column({ name: 'bid_id', type: 'uuid' })
bidId!: string;
@ManyToOne(() => Bid, (bid) => bid.calendarEvents)
@JoinColumn({ name: 'bid_id' })
bid?: Bid;
// Información del evento
@Column({ length: 255 })
title!: string;
@Column({ type: 'text', nullable: true })
description?: string;
@Column({
name: 'event_type',
type: 'enum',
enum: ['publication', 'site_visit', 'clarification_meeting', 'clarification_deadline', 'submission_deadline', 'opening', 'technical_evaluation', 'economic_evaluation', 'award_notification', 'contract_signing', 'kick_off', 'milestone', 'internal_review', 'team_meeting', 'reminder', 'other'],
enumName: 'calendar_event_type',
})
eventType!: CalendarEventType;
@Column({
type: 'enum',
enum: ['low', 'medium', 'high', 'critical'],
enumName: 'event_priority',
default: 'medium',
})
priority!: EventPriority;
@Column({
type: 'enum',
enum: ['scheduled', 'in_progress', 'completed', 'cancelled', 'postponed'],
enumName: 'event_status',
default: 'scheduled',
})
status!: EventStatus;
// Fechas y hora
@Column({ name: 'event_date', type: 'timestamptz' })
eventDate!: Date;
@Column({ name: 'end_date', type: 'timestamptz', nullable: true })
endDate?: Date;
@Column({ name: 'is_all_day', type: 'boolean', default: false })
isAllDay!: boolean;
@Column({ name: 'timezone', length: 50, default: 'America/Mexico_City' })
timezone!: string;
// Ubicación
@Column({ length: 255, nullable: true })
location?: string;
@Column({ name: 'is_virtual', type: 'boolean', default: false })
isVirtual!: boolean;
@Column({ name: 'meeting_link', length: 500, nullable: true })
meetingLink?: string;
// Recordatorios
@Column({ name: 'reminder_minutes', type: 'int', array: true, nullable: true })
reminderMinutes?: number[];
@Column({ name: 'reminder_sent', type: 'boolean', default: false })
reminderSent!: boolean;
@Column({ name: 'last_reminder_at', type: 'timestamptz', nullable: true })
lastReminderAt?: Date;
// Asignación
@Column({ name: 'assigned_to_id', type: 'uuid', nullable: true })
assignedToId?: string;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'assigned_to_id' })
assignedTo?: User;
@Column({ name: 'attendees', type: 'uuid', array: true, nullable: true })
attendees?: string[];
// Resultado del evento
@Column({ name: 'outcome', type: 'text', nullable: true })
outcome?: string;
@Column({ name: 'action_items', type: 'jsonb', nullable: true })
actionItems?: Record<string, any>[];
// Recurrencia
@Column({ name: 'is_recurring', type: 'boolean', default: false })
isRecurring!: boolean;
@Column({ name: 'recurrence_rule', length: 255, nullable: true })
recurrenceRule?: string;
@Column({ name: 'parent_event_id', type: 'uuid', nullable: true })
parentEventId?: string;
// Flags
@Column({ name: 'is_mandatory', type: 'boolean', default: false })
isMandatory!: boolean;
@Column({ name: 'is_external', type: 'boolean', default: false })
isExternal!: boolean;
@Column({ name: 'requires_preparation', type: 'boolean', default: false })
requiresPreparation!: boolean;
// Notas y metadatos
@Column({ type: 'text', nullable: true })
notes?: string;
@Column({ type: 'jsonb', nullable: true })
metadata?: Record<string, any>;
// Auditoría
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy?: string;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy?: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt?: Date;
}

View File

@ -0,0 +1,203 @@
/**
* BidCompetitor Entity - Competidores en Licitación
*
* Información de competidores en el proceso de licitación.
*
* @module Bidding
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Bid } from './bid.entity';
export type CompetitorStatus =
| 'identified'
| 'registered'
| 'qualified'
| 'disqualified'
| 'withdrew'
| 'submitted'
| 'winner'
| 'loser';
export type ThreatLevel = 'low' | 'medium' | 'high' | 'critical';
@Entity('bid_competitors', { schema: 'bidding' })
@Index(['tenantId', 'bidId'])
export class BidCompetitor {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId!: string;
// Referencia a licitación
@Column({ name: 'bid_id', type: 'uuid' })
bidId!: string;
@ManyToOne(() => Bid, (bid) => bid.competitors)
@JoinColumn({ name: 'bid_id' })
bid?: Bid;
// Información del competidor
@Column({ name: 'company_name', length: 255 })
companyName!: string;
@Column({ name: 'trade_name', length: 255, nullable: true })
tradeName?: string;
@Column({ name: 'rfc', length: 13, nullable: true })
rfc?: string;
@Column({ name: 'contact_name', length: 255, nullable: true })
contactName?: string;
@Column({ name: 'contact_email', length: 255, nullable: true })
contactEmail?: string;
@Column({ name: 'contact_phone', length: 50, nullable: true })
contactPhone?: string;
@Column({ length: 255, nullable: true })
website?: string;
// Estado y análisis
@Column({
type: 'enum',
enum: ['identified', 'registered', 'qualified', 'disqualified', 'withdrew', 'submitted', 'winner', 'loser'],
enumName: 'competitor_status',
default: 'identified',
})
status!: CompetitorStatus;
@Column({
name: 'threat_level',
type: 'enum',
enum: ['low', 'medium', 'high', 'critical'],
enumName: 'competitor_threat_level',
default: 'medium',
})
threatLevel!: ThreatLevel;
// Capacidades conocidas
@Column({
name: 'estimated_annual_revenue',
type: 'decimal',
precision: 18,
scale: 2,
nullable: true,
})
estimatedAnnualRevenue?: number;
@Column({ name: 'employee_count', type: 'int', nullable: true })
employeeCount?: number;
@Column({ name: 'years_in_business', type: 'int', nullable: true })
yearsInBusiness?: number;
@Column({ name: 'certifications', type: 'text', array: true, nullable: true })
certifications?: string[];
@Column({ name: 'specializations', type: 'text', array: true, nullable: true })
specializations?: string[];
// Histórico de competencia
@Column({ name: 'previous_encounters', type: 'int', default: 0 })
previousEncounters!: number;
@Column({ name: 'wins_against', type: 'int', default: 0 })
winsAgainst!: number;
@Column({ name: 'losses_against', type: 'int', default: 0 })
lossesAgainst!: number;
// Información de propuesta (si es pública)
@Column({
name: 'proposed_amount',
type: 'decimal',
precision: 18,
scale: 2,
nullable: true,
})
proposedAmount?: number;
@Column({
name: 'technical_score',
type: 'decimal',
precision: 5,
scale: 2,
nullable: true,
})
technicalScore?: number;
@Column({
name: 'economic_score',
type: 'decimal',
precision: 5,
scale: 2,
nullable: true,
})
economicScore?: number;
@Column({
name: 'final_score',
type: 'decimal',
precision: 5,
scale: 2,
nullable: true,
})
finalScore?: number;
@Column({ name: 'ranking_position', type: 'int', nullable: true })
rankingPosition?: number;
// Fortalezas y debilidades
@Column({ type: 'text', array: true, nullable: true })
strengths?: string[];
@Column({ type: 'text', array: true, nullable: true })
weaknesses?: string[];
// Análisis FODA resumido
@Column({ name: 'competitive_advantage', type: 'text', nullable: true })
competitiveAdvantage?: string;
@Column({ name: 'vulnerability', type: 'text', nullable: true })
vulnerability?: string;
// Razón de descalificación/retiro
@Column({ name: 'disqualification_reason', type: 'text', nullable: true })
disqualificationReason?: string;
// Notas y metadatos
@Column({ type: 'text', nullable: true })
notes?: string;
@Column({ type: 'jsonb', nullable: true })
metadata?: Record<string, any>;
// Auditoría
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy?: string;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy?: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt?: Date;
}

View File

@ -0,0 +1,170 @@
/**
* BidDocument Entity - Documentos de Licitación
*
* Almacena documentos asociados a una licitación.
*
* @module Bidding
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { User } from '../../core/entities/user.entity';
import { Bid } from './bid.entity';
export type DocumentCategory =
| 'tender_bases'
| 'clarifications'
| 'annexes'
| 'technical_proposal'
| 'economic_proposal'
| 'legal_documents'
| 'experience_certificates'
| 'financial_statements'
| 'bonds'
| 'contracts'
| 'correspondence'
| 'meeting_minutes'
| 'other';
export type DocumentStatus = 'draft' | 'pending_review' | 'approved' | 'rejected' | 'submitted' | 'archived';
@Entity('bid_documents', { schema: 'bidding' })
@Index(['tenantId', 'bidId'])
@Index(['tenantId', 'category'])
export class BidDocument {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId!: string;
// Referencia a licitación
@Column({ name: 'bid_id', type: 'uuid' })
bidId!: string;
@ManyToOne(() => Bid, (bid) => bid.documents)
@JoinColumn({ name: 'bid_id' })
bid?: Bid;
// Información del documento
@Column({ length: 255 })
name!: string;
@Column({ type: 'text', nullable: true })
description?: string;
@Column({
type: 'enum',
enum: ['tender_bases', 'clarifications', 'annexes', 'technical_proposal', 'economic_proposal', 'legal_documents', 'experience_certificates', 'financial_statements', 'bonds', 'contracts', 'correspondence', 'meeting_minutes', 'other'],
enumName: 'bid_document_category',
})
category!: DocumentCategory;
@Column({
type: 'enum',
enum: ['draft', 'pending_review', 'approved', 'rejected', 'submitted', 'archived'],
enumName: 'bid_document_status',
default: 'draft',
})
status!: DocumentStatus;
// Archivo
@Column({ name: 'file_path', length: 500 })
filePath!: string;
@Column({ name: 'file_name', length: 255 })
fileName!: string;
@Column({ name: 'file_type', length: 100 })
fileType!: string;
@Column({ name: 'file_size', type: 'bigint' })
fileSize!: number;
@Column({ name: 'mime_type', length: 100, nullable: true })
mimeType?: string;
// Versión
@Column({ type: 'int', default: 1 })
version!: number;
@Column({ name: 'is_current_version', type: 'boolean', default: true })
isCurrentVersion!: boolean;
@Column({ name: 'previous_version_id', type: 'uuid', nullable: true })
previousVersionId?: string;
// Metadatos de revisión
@Column({ name: 'reviewed_by_id', type: 'uuid', nullable: true })
reviewedById?: string;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'reviewed_by_id' })
reviewedBy?: User;
@Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true })
reviewedAt?: Date;
@Column({ name: 'review_comments', type: 'text', nullable: true })
reviewComments?: string;
// Flags
@Column({ name: 'is_required', type: 'boolean', default: false })
isRequired!: boolean;
@Column({ name: 'is_confidential', type: 'boolean', default: false })
isConfidential!: boolean;
@Column({ name: 'is_submitted', type: 'boolean', default: false })
isSubmitted!: boolean;
@Column({ name: 'submitted_at', type: 'timestamptz', nullable: true })
submittedAt?: Date;
// Fecha de vencimiento (para documentos con vigencia)
@Column({ name: 'expiry_date', type: 'date', nullable: true })
expiryDate?: Date;
// Hash para verificación de integridad
@Column({ name: 'file_hash', length: 128, nullable: true })
fileHash?: string;
// Notas y metadatos
@Column({ type: 'text', nullable: true })
notes?: string;
@Column({ type: 'jsonb', nullable: true })
metadata?: Record<string, any>;
// Auditoría
@Column({ name: 'uploaded_by_id', type: 'uuid', nullable: true })
uploadedById?: string;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'uploaded_by_id' })
uploadedBy?: User;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy?: string;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy?: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt?: Date;
}

View File

@ -0,0 +1,176 @@
/**
* BidTeam Entity - Equipo de Licitación
*
* Miembros del equipo asignados a una licitación.
*
* @module Bidding
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { User } from '../../core/entities/user.entity';
import { Bid } from './bid.entity';
export type TeamRole =
| 'bid_manager'
| 'technical_lead'
| 'cost_engineer'
| 'legal_advisor'
| 'commercial_manager'
| 'project_manager'
| 'quality_manager'
| 'hse_manager'
| 'procurement_lead'
| 'design_lead'
| 'reviewer'
| 'contributor'
| 'support';
export type MemberStatus = 'active' | 'inactive' | 'pending' | 'removed';
@Entity('bid_team', { schema: 'bidding' })
@Index(['tenantId', 'bidId'])
@Index(['tenantId', 'userId'])
export class BidTeam {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId!: string;
// Referencia a licitación
@Column({ name: 'bid_id', type: 'uuid' })
bidId!: string;
@ManyToOne(() => Bid, (bid) => bid.teamMembers)
@JoinColumn({ name: 'bid_id' })
bid?: Bid;
// Referencia a usuario
@Column({ name: 'user_id', type: 'uuid' })
userId!: string;
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user?: User;
// Rol y responsabilidades
@Column({
type: 'enum',
enum: ['bid_manager', 'technical_lead', 'cost_engineer', 'legal_advisor', 'commercial_manager', 'project_manager', 'quality_manager', 'hse_manager', 'procurement_lead', 'design_lead', 'reviewer', 'contributor', 'support'],
enumName: 'bid_team_role',
})
role!: TeamRole;
@Column({
type: 'enum',
enum: ['active', 'inactive', 'pending', 'removed'],
enumName: 'bid_team_status',
default: 'active',
})
status!: MemberStatus;
@Column({ type: 'text', array: true, nullable: true })
responsibilities?: string[];
// Dedicación
@Column({
name: 'allocation_percentage',
type: 'decimal',
precision: 5,
scale: 2,
default: 100,
})
allocationPercentage!: number;
@Column({ name: 'estimated_hours', type: 'decimal', precision: 8, scale: 2, nullable: true })
estimatedHours?: number;
@Column({ name: 'actual_hours', type: 'decimal', precision: 8, scale: 2, default: 0 })
actualHours!: number;
// Fechas de participación
@Column({ name: 'start_date', type: 'date' })
startDate!: Date;
@Column({ name: 'end_date', type: 'date', nullable: true })
endDate?: Date;
// Permisos específicos
@Column({ name: 'can_edit_technical', type: 'boolean', default: false })
canEditTechnical!: boolean;
@Column({ name: 'can_edit_economic', type: 'boolean', default: false })
canEditEconomic!: boolean;
@Column({ name: 'can_approve', type: 'boolean', default: false })
canApprove!: boolean;
@Column({ name: 'can_submit', type: 'boolean', default: false })
canSubmit!: boolean;
// Notificaciones
@Column({ name: 'receive_notifications', type: 'boolean', default: true })
receiveNotifications!: boolean;
@Column({ name: 'notification_preferences', type: 'jsonb', nullable: true })
notificationPreferences?: Record<string, any>;
// Evaluación de participación
@Column({
name: 'performance_rating',
type: 'decimal',
precision: 3,
scale: 2,
nullable: true,
})
performanceRating?: number;
@Column({ name: 'performance_notes', type: 'text', nullable: true })
performanceNotes?: string;
// Información de contacto externa (si no es empleado)
@Column({ name: 'is_external', type: 'boolean', default: false })
isExternal!: boolean;
@Column({ name: 'external_company', length: 255, nullable: true })
externalCompany?: string;
@Column({ name: 'external_email', length: 255, nullable: true })
externalEmail?: string;
@Column({ name: 'external_phone', length: 50, nullable: true })
externalPhone?: string;
// Notas y metadatos
@Column({ type: 'text', nullable: true })
notes?: string;
@Column({ type: 'jsonb', nullable: true })
metadata?: Record<string, any>;
// Auditoría
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy?: string;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy?: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt?: Date;
}

View File

@ -0,0 +1,311 @@
/**
* Bid Entity - Licitaciones/Propuestas
*
* Representa una licitación o propuesta formal vinculada a una oportunidad.
*
* @module Bidding
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
Index,
} from 'typeorm';
import { User } from '../../core/entities/user.entity';
import { Opportunity } from './opportunity.entity';
import { BidDocument } from './bid-document.entity';
import { BidCalendar } from './bid-calendar.entity';
import { BidBudget } from './bid-budget.entity';
import { BidCompetitor } from './bid-competitor.entity';
import { BidTeam } from './bid-team.entity';
export type BidType = 'public' | 'private' | 'invitation' | 'direct_award' | 'framework_agreement';
export type BidStatus =
| 'draft'
| 'preparation'
| 'review'
| 'approved'
| 'submitted'
| 'clarification'
| 'evaluation'
| 'awarded'
| 'rejected'
| 'cancelled'
| 'withdrawn';
export type BidStage =
| 'initial'
| 'technical_proposal'
| 'economic_proposal'
| 'final_submission'
| 'post_submission';
@Entity('bids', { schema: 'bidding' })
@Index(['tenantId', 'status'])
@Index(['tenantId', 'bidType'])
@Index(['tenantId', 'opportunityId'])
export class Bid {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId!: string;
// Referencia a oportunidad
@Column({ name: 'opportunity_id', type: 'uuid' })
opportunityId!: string;
@ManyToOne(() => Opportunity, (opp) => opp.bids)
@JoinColumn({ name: 'opportunity_id' })
opportunity?: Opportunity;
// Información básica
@Column({ length: 100 })
code!: string;
@Column({ length: 500 })
name!: string;
@Column({ type: 'text', nullable: true })
description?: string;
@Column({
name: 'bid_type',
type: 'enum',
enum: ['public', 'private', 'invitation', 'direct_award', 'framework_agreement'],
enumName: 'bid_type',
})
bidType!: BidType;
@Column({
type: 'enum',
enum: ['draft', 'preparation', 'review', 'approved', 'submitted', 'clarification', 'evaluation', 'awarded', 'rejected', 'cancelled', 'withdrawn'],
enumName: 'bid_status',
default: 'draft',
})
status!: BidStatus;
@Column({
type: 'enum',
enum: ['initial', 'technical_proposal', 'economic_proposal', 'final_submission', 'post_submission'],
enumName: 'bid_stage',
default: 'initial',
})
stage!: BidStage;
// Referencia de convocatoria
@Column({ name: 'tender_number', length: 100, nullable: true })
tenderNumber?: string;
@Column({ name: 'tender_name', length: 500, nullable: true })
tenderName?: string;
@Column({ name: 'contracting_entity', length: 255, nullable: true })
contractingEntity?: string;
// Fechas clave
@Column({ name: 'publication_date', type: 'date', nullable: true })
publicationDate?: Date;
@Column({ name: 'site_visit_date', type: 'timestamptz', nullable: true })
siteVisitDate?: Date;
@Column({ name: 'clarification_deadline', type: 'timestamptz', nullable: true })
clarificationDeadline?: Date;
@Column({ name: 'submission_deadline', type: 'timestamptz' })
submissionDeadline!: Date;
@Column({ name: 'opening_date', type: 'timestamptz', nullable: true })
openingDate?: Date;
@Column({ name: 'award_date', type: 'date', nullable: true })
awardDate?: Date;
@Column({ name: 'contract_signing_date', type: 'date', nullable: true })
contractSigningDate?: Date;
// Montos
@Column({
name: 'base_budget',
type: 'decimal',
precision: 18,
scale: 2,
nullable: true,
})
baseBudget?: number;
@Column({
name: 'our_proposal_amount',
type: 'decimal',
precision: 18,
scale: 2,
nullable: true,
})
ourProposalAmount?: number;
@Column({
name: 'winning_amount',
type: 'decimal',
precision: 18,
scale: 2,
nullable: true,
})
winningAmount?: number;
@Column({ name: 'currency', length: 3, default: 'MXN' })
currency!: string;
// Propuesta técnica
@Column({
name: 'technical_score',
type: 'decimal',
precision: 5,
scale: 2,
nullable: true,
})
technicalScore?: number;
@Column({
name: 'technical_weight',
type: 'decimal',
precision: 5,
scale: 2,
default: 50,
})
technicalWeight!: number;
// Propuesta económica
@Column({
name: 'economic_score',
type: 'decimal',
precision: 5,
scale: 2,
nullable: true,
})
economicScore?: number;
@Column({
name: 'economic_weight',
type: 'decimal',
precision: 5,
scale: 2,
default: 50,
})
economicWeight!: number;
// Puntuación final
@Column({
name: 'final_score',
type: 'decimal',
precision: 5,
scale: 2,
nullable: true,
})
finalScore?: number;
@Column({ name: 'ranking_position', type: 'int', nullable: true })
rankingPosition?: number;
// Garantías
@Column({
name: 'bid_bond_amount',
type: 'decimal',
precision: 18,
scale: 2,
nullable: true,
})
bidBondAmount?: number;
@Column({ name: 'bid_bond_number', length: 100, nullable: true })
bidBondNumber?: string;
@Column({ name: 'bid_bond_expiry', type: 'date', nullable: true })
bidBondExpiry?: Date;
// Asignación
@Column({ name: 'bid_manager_id', type: 'uuid', nullable: true })
bidManagerId?: string;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'bid_manager_id' })
bidManager?: User;
// Resultado
@Column({ name: 'winner_name', length: 255, nullable: true })
winnerName?: string;
@Column({ name: 'rejection_reason', type: 'text', nullable: true })
rejectionReason?: string;
@Column({ name: 'lessons_learned', type: 'text', nullable: true })
lessonsLearned?: string;
// Progreso
@Column({
name: 'completion_percentage',
type: 'decimal',
precision: 5,
scale: 2,
default: 0,
})
completionPercentage!: number;
// Checklist de documentos
@Column({ name: 'checklist', type: 'jsonb', nullable: true })
checklist?: Record<string, any>;
// Notas y metadatos
@Column({ type: 'text', nullable: true })
notes?: string;
@Column({ type: 'jsonb', nullable: true })
metadata?: Record<string, any>;
// Relaciones
@OneToMany(() => BidDocument, (doc) => doc.bid)
documents?: BidDocument[];
@OneToMany(() => BidCalendar, (event) => event.bid)
calendarEvents?: BidCalendar[];
@OneToMany(() => BidBudget, (budget) => budget.bid)
budgetItems?: BidBudget[];
@OneToMany(() => BidCompetitor, (comp) => comp.bid)
competitors?: BidCompetitor[];
@OneToMany(() => BidTeam, (team) => team.bid)
teamMembers?: BidTeam[];
// Conversión a proyecto
@Column({ name: 'converted_to_project_id', type: 'uuid', nullable: true })
convertedToProjectId?: string;
@Column({ name: 'converted_at', type: 'timestamptz', nullable: true })
convertedAt?: Date;
// Auditoría
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy?: string;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy?: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt?: Date;
}

View File

@ -0,0 +1,12 @@
/**
* Bidding Entities Index
* @module Bidding
*/
export { Opportunity, OpportunitySource, OpportunityStatus, OpportunityPriority, ProjectType } from './opportunity.entity';
export { Bid, BidType, BidStatus, BidStage } from './bid.entity';
export { BidDocument, DocumentCategory, DocumentStatus } from './bid-document.entity';
export { BidCalendar, CalendarEventType, EventPriority, EventStatus } from './bid-calendar.entity';
export { BidBudget, BudgetItemType, BudgetStatus } from './bid-budget.entity';
export { BidCompetitor, CompetitorStatus, ThreatLevel } from './bid-competitor.entity';
export { BidTeam, TeamRole, MemberStatus } from './bid-team.entity';

View File

@ -0,0 +1,280 @@
/**
* Opportunity Entity - Oportunidades de Negocio
*
* Representa oportunidades de licitación/proyecto en el pipeline comercial.
*
* @module Bidding
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
Index,
} from 'typeorm';
import { User } from '../../core/entities/user.entity';
import { Bid } from './bid.entity';
export type OpportunitySource =
| 'portal_compranet'
| 'portal_state'
| 'direct_invitation'
| 'referral'
| 'public_notice'
| 'networking'
| 'repeat_client'
| 'cold_call'
| 'website'
| 'other';
export type OpportunityStatus =
| 'identified'
| 'qualified'
| 'pursuing'
| 'bid_submitted'
| 'won'
| 'lost'
| 'cancelled'
| 'on_hold';
export type OpportunityPriority = 'low' | 'medium' | 'high' | 'critical';
export type ProjectType =
| 'residential'
| 'commercial'
| 'industrial'
| 'infrastructure'
| 'institutional'
| 'mixed_use'
| 'renovation'
| 'maintenance';
@Entity('opportunities', { schema: 'bidding' })
@Index(['tenantId', 'status'])
@Index(['tenantId', 'source'])
@Index(['tenantId', 'assignedToId'])
export class Opportunity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId!: string;
// Información básica
@Column({ length: 100 })
code!: string;
@Column({ length: 500 })
name!: string;
@Column({ type: 'text', nullable: true })
description?: string;
@Column({
type: 'enum',
enum: ['portal_compranet', 'portal_state', 'direct_invitation', 'referral', 'public_notice', 'networking', 'repeat_client', 'cold_call', 'website', 'other'],
enumName: 'opportunity_source',
})
source!: OpportunitySource;
@Column({
type: 'enum',
enum: ['identified', 'qualified', 'pursuing', 'bid_submitted', 'won', 'lost', 'cancelled', 'on_hold'],
enumName: 'opportunity_status',
default: 'identified',
})
status!: OpportunityStatus;
@Column({
type: 'enum',
enum: ['low', 'medium', 'high', 'critical'],
enumName: 'opportunity_priority',
default: 'medium',
})
priority!: OpportunityPriority;
@Column({
name: 'project_type',
type: 'enum',
enum: ['residential', 'commercial', 'industrial', 'infrastructure', 'institutional', 'mixed_use', 'renovation', 'maintenance'],
enumName: 'project_type',
})
projectType!: ProjectType;
// Cliente/Convocante
@Column({ name: 'client_name', length: 255 })
clientName!: string;
@Column({ name: 'client_contact', length: 255, nullable: true })
clientContact?: string;
@Column({ name: 'client_email', length: 255, nullable: true })
clientEmail?: string;
@Column({ name: 'client_phone', length: 50, nullable: true })
clientPhone?: string;
@Column({ name: 'client_type', length: 50, nullable: true })
clientType?: string; // 'gobierno_federal', 'gobierno_estatal', 'privado', etc.
// Ubicación
@Column({ length: 255, nullable: true })
location?: string;
@Column({ length: 100, nullable: true })
state?: string;
@Column({ length: 100, nullable: true })
city?: string;
// Montos estimados
@Column({
name: 'estimated_value',
type: 'decimal',
precision: 18,
scale: 2,
nullable: true,
})
estimatedValue?: number;
@Column({ name: 'currency', length: 3, default: 'MXN' })
currency!: string;
@Column({
name: 'construction_area_m2',
type: 'decimal',
precision: 12,
scale: 2,
nullable: true,
})
constructionAreaM2?: number;
@Column({
name: 'land_area_m2',
type: 'decimal',
precision: 12,
scale: 2,
nullable: true,
})
landAreaM2?: number;
// Fechas clave
@Column({ name: 'identification_date', type: 'date' })
identificationDate!: Date;
@Column({ name: 'deadline_date', type: 'timestamptz', nullable: true })
deadlineDate?: Date;
@Column({ name: 'expected_award_date', type: 'date', nullable: true })
expectedAwardDate?: Date;
@Column({ name: 'expected_start_date', type: 'date', nullable: true })
expectedStartDate?: Date;
@Column({ name: 'expected_duration_months', type: 'int', nullable: true })
expectedDurationMonths?: number;
// Probabilidad y análisis
@Column({
name: 'win_probability',
type: 'decimal',
precision: 5,
scale: 2,
default: 0,
})
winProbability!: number;
@Column({
name: 'weighted_value',
type: 'decimal',
precision: 18,
scale: 2,
nullable: true,
})
weightedValue?: number;
// Requisitos
@Column({ name: 'requires_bond', type: 'boolean', default: false })
requiresBond!: boolean;
@Column({ name: 'requires_experience', type: 'boolean', default: false })
requiresExperience!: boolean;
@Column({
name: 'minimum_experience_years',
type: 'int',
nullable: true,
})
minimumExperienceYears?: number;
@Column({
name: 'minimum_capital',
type: 'decimal',
precision: 18,
scale: 2,
nullable: true,
})
minimumCapital?: number;
@Column({
name: 'required_certifications',
type: 'text',
array: true,
nullable: true,
})
requiredCertifications?: string[];
// Asignación
@Column({ name: 'assigned_to_id', type: 'uuid', nullable: true })
assignedToId?: string;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'assigned_to_id' })
assignedTo?: User;
// Razón de resultado
@Column({ name: 'loss_reason', type: 'text', nullable: true })
lossReason?: string;
@Column({ name: 'win_factors', type: 'text', nullable: true })
winFactors?: string;
// Notas y metadatos
@Column({ type: 'text', nullable: true })
notes?: string;
@Column({ name: 'source_url', length: 500, nullable: true })
sourceUrl?: string;
@Column({ name: 'source_reference', length: 255, nullable: true })
sourceReference?: string;
@Column({ type: 'jsonb', nullable: true })
metadata?: Record<string, any>;
// Relaciones
@OneToMany(() => Bid, (bid) => bid.opportunity)
bids?: Bid[];
// Auditoría
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy?: string;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy?: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt?: Date;
}

View File

@ -0,0 +1,385 @@
/**
* BidAnalyticsService - Análisis y Reportes de Licitaciones
*
* Estadísticas, tendencias y análisis de competitividad.
*
* @module Bidding
*/
import { Repository } from 'typeorm';
import { ServiceContext } from '../../../shared/services/base.service';
import { Bid, BidStatus, BidType } from '../entities/bid.entity';
import { Opportunity, OpportunitySource, OpportunityStatus } from '../entities/opportunity.entity';
import { BidCompetitor } from '../entities/bid-competitor.entity';
export class BidAnalyticsService {
constructor(
private readonly bidRepository: Repository<Bid>,
private readonly opportunityRepository: Repository<Opportunity>,
private readonly competitorRepository: Repository<BidCompetitor>
) {}
/**
* Dashboard general de licitaciones
*/
async getDashboard(ctx: ServiceContext): Promise<BidDashboard> {
const now = new Date();
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
// Oportunidades activas
const activeOpportunities = await this.opportunityRepository.count({
where: {
tenantId: ctx.tenantId,
deletedAt: undefined,
status: 'pursuing' as OpportunityStatus,
},
});
// Licitaciones activas
const activeBids = await this.bidRepository.count({
where: {
tenantId: ctx.tenantId,
deletedAt: undefined,
status: 'preparation' as BidStatus,
},
});
// Valor del pipeline
const pipelineValue = await this.opportunityRepository
.createQueryBuilder('o')
.select('SUM(o.weighted_value)', 'value')
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('o.deleted_at IS NULL')
.andWhere('o.status NOT IN (:...closedStatuses)', { closedStatuses: ['won', 'lost', 'cancelled'] })
.getRawOne();
// Próximas fechas límite
const upcomingDeadlines = await this.bidRepository
.createQueryBuilder('b')
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('b.deleted_at IS NULL')
.andWhere('b.status IN (:...activeStatuses)', { activeStatuses: ['draft', 'preparation', 'review', 'approved'] })
.andWhere('b.submission_deadline >= :now', { now })
.orderBy('b.submission_deadline', 'ASC')
.take(5)
.getMany();
// Win rate últimos 12 meses
const yearAgo = new Date();
yearAgo.setFullYear(yearAgo.getFullYear() - 1);
const winRateStats = await this.bidRepository
.createQueryBuilder('b')
.select('b.status', 'status')
.addSelect('COUNT(*)', 'count')
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('b.deleted_at IS NULL')
.andWhere('b.status IN (:...closedStatuses)', { closedStatuses: ['awarded', 'rejected'] })
.andWhere('b.award_date >= :yearAgo', { yearAgo })
.groupBy('b.status')
.getRawMany();
const awarded = winRateStats.find((s) => s.status === 'awarded')?.count || 0;
const rejected = winRateStats.find((s) => s.status === 'rejected')?.count || 0;
const totalClosed = parseInt(awarded) + parseInt(rejected);
const winRate = totalClosed > 0 ? (parseInt(awarded) / totalClosed) * 100 : 0;
// Valor ganado este año
const startOfYear = new Date(now.getFullYear(), 0, 1);
const wonValue = await this.bidRepository
.createQueryBuilder('b')
.select('SUM(b.winning_amount)', 'value')
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('b.deleted_at IS NULL')
.andWhere('b.status = :status', { status: 'awarded' })
.andWhere('b.award_date >= :startOfYear', { startOfYear })
.getRawOne();
return {
activeOpportunities,
activeBids,
pipelineValue: parseFloat(pipelineValue?.value) || 0,
upcomingDeadlines: upcomingDeadlines.map((b) => ({
id: b.id,
name: b.name,
deadline: b.submissionDeadline,
status: b.status,
})),
winRate,
wonValueYTD: parseFloat(wonValue?.value) || 0,
};
}
/**
* Análisis de pipeline por fuente
*/
async getPipelineBySource(ctx: ServiceContext): Promise<PipelineBySource[]> {
const result = await this.opportunityRepository
.createQueryBuilder('o')
.select('o.source', 'source')
.addSelect('COUNT(*)', 'count')
.addSelect('SUM(o.estimated_value)', 'totalValue')
.addSelect('SUM(o.weighted_value)', 'weightedValue')
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('o.deleted_at IS NULL')
.andWhere('o.status NOT IN (:...closedStatuses)', { closedStatuses: ['won', 'lost', 'cancelled'] })
.groupBy('o.source')
.orderBy('SUM(o.weighted_value)', 'DESC')
.getRawMany();
return result.map((r) => ({
source: r.source as OpportunitySource,
count: parseInt(r.count),
totalValue: parseFloat(r.totalValue) || 0,
weightedValue: parseFloat(r.weightedValue) || 0,
}));
}
/**
* Análisis de win rate por tipo de licitación
*/
async getWinRateByType(ctx: ServiceContext, months = 12): Promise<WinRateByType[]> {
const fromDate = new Date();
fromDate.setMonth(fromDate.getMonth() - months);
const result = await this.bidRepository
.createQueryBuilder('b')
.select('b.bid_type', 'bidType')
.addSelect('COUNT(*) FILTER (WHERE b.status = \'awarded\')', 'won')
.addSelect('COUNT(*) FILTER (WHERE b.status IN (\'awarded\', \'rejected\'))', 'total')
.addSelect('SUM(CASE WHEN b.status = \'awarded\' THEN b.winning_amount ELSE 0 END)', 'wonValue')
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('b.deleted_at IS NULL')
.andWhere('b.award_date >= :fromDate', { fromDate })
.groupBy('b.bid_type')
.getRawMany();
return result.map((r) => ({
bidType: r.bidType as BidType,
won: parseInt(r.won) || 0,
total: parseInt(r.total) || 0,
winRate: parseInt(r.total) > 0 ? (parseInt(r.won) / parseInt(r.total)) * 100 : 0,
wonValue: parseFloat(r.wonValue) || 0,
}));
}
/**
* Tendencia mensual de oportunidades
*/
async getMonthlyTrend(ctx: ServiceContext, months = 12): Promise<MonthlyTrend[]> {
const fromDate = new Date();
fromDate.setMonth(fromDate.getMonth() - months);
const result = await this.opportunityRepository
.createQueryBuilder('o')
.select("TO_CHAR(o.identification_date, 'YYYY-MM')", 'month')
.addSelect('COUNT(*)', 'identified')
.addSelect('COUNT(*) FILTER (WHERE o.status = \'won\')', 'won')
.addSelect('COUNT(*) FILTER (WHERE o.status = \'lost\')', 'lost')
.addSelect('SUM(CASE WHEN o.status = \'won\' THEN o.estimated_value ELSE 0 END)', 'wonValue')
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('o.deleted_at IS NULL')
.andWhere('o.identification_date >= :fromDate', { fromDate })
.groupBy("TO_CHAR(o.identification_date, 'YYYY-MM')")
.orderBy("TO_CHAR(o.identification_date, 'YYYY-MM')", 'ASC')
.getRawMany();
return result.map((r) => ({
month: r.month,
identified: parseInt(r.identified) || 0,
won: parseInt(r.won) || 0,
lost: parseInt(r.lost) || 0,
wonValue: parseFloat(r.wonValue) || 0,
}));
}
/**
* Análisis de competidores
*/
async getCompetitorAnalysis(ctx: ServiceContext): Promise<CompetitorAnalysis[]> {
const result = await this.competitorRepository
.createQueryBuilder('c')
.select('c.company_name', 'companyName')
.addSelect('COUNT(*)', 'encounters')
.addSelect('SUM(CASE WHEN c.status = \'winner\' THEN 1 ELSE 0 END)', 'theirWins')
.addSelect('SUM(CASE WHEN c.status = \'loser\' THEN 1 ELSE 0 END)', 'ourWins')
.addSelect('AVG(c.proposed_amount)', 'avgProposedAmount')
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('c.deleted_at IS NULL')
.groupBy('c.company_name')
.having('COUNT(*) >= 2')
.orderBy('COUNT(*)', 'DESC')
.take(20)
.getRawMany();
return result.map((r) => ({
companyName: r.companyName,
encounters: parseInt(r.encounters) || 0,
theirWins: parseInt(r.theirWins) || 0,
ourWins: parseInt(r.ourWins) || 0,
winRateAgainst: parseInt(r.encounters) > 0
? (parseInt(r.ourWins) / parseInt(r.encounters)) * 100
: 0,
avgProposedAmount: parseFloat(r.avgProposedAmount) || 0,
}));
}
/**
* Análisis de conversión del funnel
*/
async getFunnelAnalysis(ctx: ServiceContext, months = 12): Promise<FunnelAnalysis> {
const fromDate = new Date();
fromDate.setMonth(fromDate.getMonth() - months);
const baseQuery = this.opportunityRepository
.createQueryBuilder('o')
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('o.deleted_at IS NULL')
.andWhere('o.identification_date >= :fromDate', { fromDate });
const identified = await baseQuery.clone().getCount();
const qualified = await baseQuery.clone()
.andWhere('o.status NOT IN (:...earlyStatuses)', { earlyStatuses: ['identified'] })
.getCount();
const pursuing = await baseQuery.clone()
.andWhere('o.status IN (:...pursuitStatuses)', { pursuitStatuses: ['pursuing', 'bid_submitted', 'won', 'lost'] })
.getCount();
const bidSubmitted = await baseQuery.clone()
.andWhere('o.status IN (:...submittedStatuses)', { submittedStatuses: ['bid_submitted', 'won', 'lost'] })
.getCount();
const won = await baseQuery.clone()
.andWhere('o.status = :status', { status: 'won' })
.getCount();
return {
identified,
qualified,
pursuing,
bidSubmitted,
won,
conversionRates: {
identifiedToQualified: identified > 0 ? (qualified / identified) * 100 : 0,
qualifiedToPursuing: qualified > 0 ? (pursuing / qualified) * 100 : 0,
pursuingToSubmitted: pursuing > 0 ? (bidSubmitted / pursuing) * 100 : 0,
submittedToWon: bidSubmitted > 0 ? (won / bidSubmitted) * 100 : 0,
overallConversion: identified > 0 ? (won / identified) * 100 : 0,
},
};
}
/**
* Análisis de tiempos de ciclo
*/
async getCycleTimeAnalysis(ctx: ServiceContext, months = 12): Promise<CycleTimeAnalysis> {
const fromDate = new Date();
fromDate.setMonth(fromDate.getMonth() - months);
const result = await this.opportunityRepository
.createQueryBuilder('o')
.select('AVG(EXTRACT(DAY FROM (o.updated_at - o.identification_date)))', 'avgDays')
.addSelect('MIN(EXTRACT(DAY FROM (o.updated_at - o.identification_date)))', 'minDays')
.addSelect('MAX(EXTRACT(DAY FROM (o.updated_at - o.identification_date)))', 'maxDays')
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('o.deleted_at IS NULL')
.andWhere('o.status IN (:...closedStatuses)', { closedStatuses: ['won', 'lost'] })
.andWhere('o.identification_date >= :fromDate', { fromDate })
.getRawOne();
const byOutcome = await this.opportunityRepository
.createQueryBuilder('o')
.select('o.status', 'outcome')
.addSelect('AVG(EXTRACT(DAY FROM (o.updated_at - o.identification_date)))', 'avgDays')
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('o.deleted_at IS NULL')
.andWhere('o.status IN (:...closedStatuses)', { closedStatuses: ['won', 'lost'] })
.andWhere('o.identification_date >= :fromDate', { fromDate })
.groupBy('o.status')
.getRawMany();
return {
overall: {
avgDays: Math.round(parseFloat(result?.avgDays) || 0),
minDays: Math.round(parseFloat(result?.minDays) || 0),
maxDays: Math.round(parseFloat(result?.maxDays) || 0),
},
byOutcome: byOutcome.map((r) => ({
outcome: r.outcome as 'won' | 'lost',
avgDays: Math.round(parseFloat(r.avgDays) || 0),
})),
};
}
}
// Types
export interface BidDashboard {
activeOpportunities: number;
activeBids: number;
pipelineValue: number;
upcomingDeadlines: { id: string; name: string; deadline: Date; status: BidStatus }[];
winRate: number;
wonValueYTD: number;
}
export interface PipelineBySource {
source: OpportunitySource;
count: number;
totalValue: number;
weightedValue: number;
}
export interface WinRateByType {
bidType: BidType;
won: number;
total: number;
winRate: number;
wonValue: number;
}
export interface MonthlyTrend {
month: string;
identified: number;
won: number;
lost: number;
wonValue: number;
}
export interface CompetitorAnalysis {
companyName: string;
encounters: number;
theirWins: number;
ourWins: number;
winRateAgainst: number;
avgProposedAmount: number;
}
export interface FunnelAnalysis {
identified: number;
qualified: number;
pursuing: number;
bidSubmitted: number;
won: number;
conversionRates: {
identifiedToQualified: number;
qualifiedToPursuing: number;
pursuingToSubmitted: number;
submittedToWon: number;
overallConversion: number;
};
}
export interface CycleTimeAnalysis {
overall: {
avgDays: number;
minDays: number;
maxDays: number;
};
byOutcome: {
outcome: 'won' | 'lost';
avgDays: number;
}[];
}

View File

@ -0,0 +1,388 @@
/**
* BidBudgetService - Gestión de Presupuestos de Licitación
*
* CRUD y cálculos para propuestas económicas.
*
* @module Bidding
*/
import { Repository } from 'typeorm';
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
import { BidBudget, BudgetItemType, BudgetStatus } from '../entities/bid-budget.entity';
export interface CreateBudgetItemDto {
bidId: string;
parentId?: string;
code: string;
name: string;
description?: string;
itemType: BudgetItemType;
unit?: string;
quantity?: number;
unitPrice?: number;
materialsCost?: number;
laborCost?: number;
equipmentCost?: number;
subcontractCost?: number;
indirectPercentage?: number;
profitPercentage?: number;
financingPercentage?: number;
baseAmount?: number;
catalogConceptId?: string;
notes?: string;
metadata?: Record<string, any>;
}
export interface UpdateBudgetItemDto extends Partial<CreateBudgetItemDto> {
status?: BudgetStatus;
adjustmentReason?: string;
}
export interface BudgetFilters {
bidId: string;
itemType?: BudgetItemType;
status?: BudgetStatus;
parentId?: string | null;
isSummary?: boolean;
}
export class BidBudgetService {
constructor(private readonly repository: Repository<BidBudget>) {}
/**
* Crear item de presupuesto
*/
async create(ctx: ServiceContext, data: CreateBudgetItemDto): Promise<BidBudget> {
// Calcular nivel jerárquico
let level = 0;
if (data.parentId) {
const parent = await this.repository.findOne({
where: { id: data.parentId, tenantId: ctx.tenantId },
});
if (parent) {
level = parent.level + 1;
}
}
// Calcular orden
const lastItem = await this.repository
.createQueryBuilder('bb')
.where('bb.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('bb.bid_id = :bidId', { bidId: data.bidId })
.andWhere(data.parentId ? 'bb.parent_id = :parentId' : 'bb.parent_id IS NULL', { parentId: data.parentId })
.orderBy('bb.sort_order', 'DESC')
.getOne();
const sortOrder = lastItem ? lastItem.sortOrder + 1 : 0;
// Calcular totales
const quantity = data.quantity || 0;
const unitPrice = data.unitPrice || 0;
const totalAmount = quantity * unitPrice;
// Calcular varianza si hay base
let varianceAmount = null;
let variancePercentage = null;
if (data.baseAmount !== undefined && data.baseAmount > 0) {
varianceAmount = totalAmount - data.baseAmount;
variancePercentage = (varianceAmount / data.baseAmount) * 100;
}
const item = this.repository.create({
tenantId: ctx.tenantId,
bidId: data.bidId,
parentId: data.parentId,
code: data.code,
name: data.name,
description: data.description,
itemType: data.itemType,
unit: data.unit,
quantity: data.quantity || 0,
unitPrice: data.unitPrice || 0,
materialsCost: data.materialsCost,
laborCost: data.laborCost,
equipmentCost: data.equipmentCost,
subcontractCost: data.subcontractCost,
indirectPercentage: data.indirectPercentage,
profitPercentage: data.profitPercentage,
financingPercentage: data.financingPercentage,
baseAmount: data.baseAmount,
catalogConceptId: data.catalogConceptId,
notes: data.notes,
metadata: data.metadata,
level,
sortOrder,
totalAmount,
varianceAmount: varianceAmount ?? undefined,
variancePercentage: variancePercentage ?? undefined,
status: 'draft',
isSummary: false,
isCalculated: true,
createdBy: ctx.userId,
updatedBy: ctx.userId,
});
const saved = await this.repository.save(item);
// Recalcular padres
if (data.parentId) {
await this.recalculateParent(ctx, data.parentId);
}
return saved;
}
/**
* Buscar por ID
*/
async findById(ctx: ServiceContext, id: string): Promise<BidBudget | null> {
return this.repository.findOne({
where: { id, tenantId: ctx.tenantId, deletedAt: undefined },
});
}
/**
* Buscar items de un presupuesto
*/
async findByBid(ctx: ServiceContext, bidId: string): Promise<BidBudget[]> {
return this.repository.find({
where: { bidId, tenantId: ctx.tenantId, deletedAt: undefined },
order: { sortOrder: 'ASC' },
});
}
/**
* Buscar items con filtros
*/
async findWithFilters(
ctx: ServiceContext,
filters: BudgetFilters,
page = 1,
limit = 100
): Promise<PaginatedResult<BidBudget>> {
const qb = this.repository
.createQueryBuilder('bb')
.where('bb.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('bb.bid_id = :bidId', { bidId: filters.bidId })
.andWhere('bb.deleted_at IS NULL');
if (filters.itemType) {
qb.andWhere('bb.item_type = :itemType', { itemType: filters.itemType });
}
if (filters.status) {
qb.andWhere('bb.status = :status', { status: filters.status });
}
if (filters.parentId !== undefined) {
if (filters.parentId === null) {
qb.andWhere('bb.parent_id IS NULL');
} else {
qb.andWhere('bb.parent_id = :parentId', { parentId: filters.parentId });
}
}
if (filters.isSummary !== undefined) {
qb.andWhere('bb.is_summary = :isSummary', { isSummary: filters.isSummary });
}
const skip = (page - 1) * limit;
qb.orderBy('bb.sort_order', 'ASC').skip(skip).take(limit);
const [data, total] = await qb.getManyAndCount();
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Obtener árbol jerárquico
*/
async getTree(ctx: ServiceContext, bidId: string): Promise<BidBudget[]> {
const items = await this.findByBid(ctx, bidId);
return this.buildTree(items);
}
private buildTree(items: BidBudget[], parentId: string | null = null): (BidBudget & { children?: BidBudget[] })[] {
return items
.filter((item) => item.parentId === parentId)
.map((item) => ({
...item,
children: this.buildTree(items, item.id),
}));
}
/**
* Actualizar item
*/
async update(ctx: ServiceContext, id: string, data: UpdateBudgetItemDto): Promise<BidBudget | null> {
const item = await this.findById(ctx, id);
if (!item) return null;
// Recalcular totales si cambian cantidad o precio
const quantity = data.quantity ?? item.quantity;
const unitPrice = data.unitPrice ?? item.unitPrice;
const totalAmount = quantity * unitPrice;
// Recalcular varianza
const baseAmount = data.baseAmount ?? item.baseAmount;
let varianceAmount = item.varianceAmount;
let variancePercentage = item.variancePercentage;
if (baseAmount !== undefined && baseAmount > 0) {
varianceAmount = totalAmount - baseAmount;
variancePercentage = (varianceAmount / baseAmount) * 100;
}
// Marcar como ajustado si hay razón
const isAdjusted = data.adjustmentReason ? true : item.isAdjusted;
Object.assign(item, {
...data,
totalAmount,
varianceAmount,
variancePercentage,
isAdjusted,
isCalculated: true,
updatedBy: ctx.userId,
});
const saved = await this.repository.save(item);
// Recalcular padres
if (item.parentId) {
await this.recalculateParent(ctx, item.parentId);
}
return saved;
}
/**
* Recalcular item padre
*/
private async recalculateParent(ctx: ServiceContext, parentId: string): Promise<void> {
const parent = await this.findById(ctx, parentId);
if (!parent) return;
const children = await this.repository.find({
where: { parentId, tenantId: ctx.tenantId, deletedAt: undefined },
});
const totalAmount = children.reduce((sum, child) => sum + (Number(child.totalAmount) || 0), 0);
parent.totalAmount = totalAmount;
parent.isSummary = children.length > 0;
parent.isCalculated = true;
parent.updatedBy = ctx.userId;
// Recalcular varianza
if (parent.baseAmount !== undefined && parent.baseAmount > 0) {
parent.varianceAmount = totalAmount - Number(parent.baseAmount);
parent.variancePercentage = (parent.varianceAmount / Number(parent.baseAmount)) * 100;
}
await this.repository.save(parent);
// Recursivamente actualizar ancestros
if (parent.parentId) {
await this.recalculateParent(ctx, parent.parentId);
}
}
/**
* Obtener resumen de presupuesto
*/
async getSummary(ctx: ServiceContext, bidId: string): Promise<BudgetSummary> {
const items = await this.findByBid(ctx, bidId);
const directCosts = items
.filter((i) => i.itemType === 'direct_cost' || i.itemType === 'labor' || i.itemType === 'materials' || i.itemType === 'equipment' || i.itemType === 'subcontract')
.reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0);
const indirectCosts = items
.filter((i) => i.itemType === 'indirect_cost' || i.itemType === 'overhead')
.reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0);
const profit = items
.filter((i) => i.itemType === 'profit')
.reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0);
const financing = items
.filter((i) => i.itemType === 'financing')
.reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0);
const taxes = items
.filter((i) => i.itemType === 'taxes')
.reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0);
const bonds = items
.filter((i) => i.itemType === 'bonds')
.reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0);
const contingency = items
.filter((i) => i.itemType === 'contingency')
.reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0);
const subtotal = directCosts + indirectCosts + profit + financing + contingency + bonds;
const total = subtotal + taxes;
const baseTotal = items.reduce((sum, i) => sum + (Number(i.baseAmount) || 0), 0);
return {
directCosts,
indirectCosts,
profit,
financing,
taxes,
bonds,
contingency,
subtotal,
total,
baseTotal,
variance: total - baseTotal,
variancePercentage: baseTotal > 0 ? ((total - baseTotal) / baseTotal) * 100 : 0,
itemCount: items.length,
};
}
/**
* Cambiar estado del presupuesto
*/
async changeStatus(ctx: ServiceContext, bidId: string, status: BudgetStatus): Promise<number> {
const result = await this.repository.update(
{ bidId, tenantId: ctx.tenantId, deletedAt: undefined },
{ status, updatedBy: ctx.userId }
);
return result.affected || 0;
}
/**
* Soft delete
*/
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
const result = await this.repository.update(
{ id, tenantId: ctx.tenantId },
{ deletedAt: new Date(), updatedBy: ctx.userId }
);
return (result.affected || 0) > 0;
}
}
export interface BudgetSummary {
directCosts: number;
indirectCosts: number;
profit: number;
financing: number;
taxes: number;
bonds: number;
contingency: number;
subtotal: number;
total: number;
baseTotal: number;
variance: number;
variancePercentage: number;
itemCount: number;
}

View File

@ -0,0 +1,384 @@
/**
* BidService - Gestión de Licitaciones
*
* CRUD y lógica de negocio para licitaciones/propuestas.
*
* @module Bidding
*/
import { Repository, In, Between } from 'typeorm';
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
import { Bid, BidType, BidStatus, BidStage } from '../entities/bid.entity';
export interface CreateBidDto {
opportunityId: string;
code: string;
name: string;
description?: string;
bidType: BidType;
tenderNumber?: string;
tenderName?: string;
contractingEntity?: string;
publicationDate?: Date;
siteVisitDate?: Date;
clarificationDeadline?: Date;
submissionDeadline: Date;
openingDate?: Date;
baseBudget?: number;
currency?: string;
technicalWeight?: number;
economicWeight?: number;
bidBondAmount?: number;
bidManagerId?: string;
notes?: string;
metadata?: Record<string, any>;
}
export interface UpdateBidDto extends Partial<CreateBidDto> {
status?: BidStatus;
stage?: BidStage;
ourProposalAmount?: number;
technicalScore?: number;
economicScore?: number;
finalScore?: number;
rankingPosition?: number;
bidBondNumber?: string;
bidBondExpiry?: Date;
awardDate?: Date;
contractSigningDate?: Date;
winnerName?: string;
winningAmount?: number;
rejectionReason?: string;
lessonsLearned?: string;
completionPercentage?: number;
checklist?: Record<string, any>;
}
export interface BidFilters {
status?: BidStatus | BidStatus[];
bidType?: BidType;
stage?: BidStage;
opportunityId?: string;
bidManagerId?: string;
contractingEntity?: string;
deadlineFrom?: Date;
deadlineTo?: Date;
minBudget?: number;
maxBudget?: number;
search?: string;
}
export class BidService {
constructor(private readonly repository: Repository<Bid>) {}
/**
* Crear licitación
*/
async create(ctx: ServiceContext, data: CreateBidDto): Promise<Bid> {
const bid = this.repository.create({
tenantId: ctx.tenantId,
...data,
status: 'draft',
stage: 'initial',
completionPercentage: 0,
createdBy: ctx.userId,
updatedBy: ctx.userId,
});
return this.repository.save(bid);
}
/**
* Buscar por ID
*/
async findById(ctx: ServiceContext, id: string): Promise<Bid | null> {
return this.repository.findOne({
where: { id, tenantId: ctx.tenantId, deletedAt: undefined },
relations: ['opportunity', 'bidManager', 'documents', 'calendarEvents', 'teamMembers'],
});
}
/**
* Buscar con filtros
*/
async findWithFilters(
ctx: ServiceContext,
filters: BidFilters,
page = 1,
limit = 20
): Promise<PaginatedResult<Bid>> {
const qb = this.repository
.createQueryBuilder('b')
.leftJoinAndSelect('b.opportunity', 'o')
.leftJoinAndSelect('b.bidManager', 'm')
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('b.deleted_at IS NULL');
if (filters.status) {
if (Array.isArray(filters.status)) {
qb.andWhere('b.status IN (:...statuses)', { statuses: filters.status });
} else {
qb.andWhere('b.status = :status', { status: filters.status });
}
}
if (filters.bidType) {
qb.andWhere('b.bid_type = :bidType', { bidType: filters.bidType });
}
if (filters.stage) {
qb.andWhere('b.stage = :stage', { stage: filters.stage });
}
if (filters.opportunityId) {
qb.andWhere('b.opportunity_id = :opportunityId', { opportunityId: filters.opportunityId });
}
if (filters.bidManagerId) {
qb.andWhere('b.bid_manager_id = :bidManagerId', { bidManagerId: filters.bidManagerId });
}
if (filters.contractingEntity) {
qb.andWhere('b.contracting_entity ILIKE :entity', { entity: `%${filters.contractingEntity}%` });
}
if (filters.deadlineFrom) {
qb.andWhere('b.submission_deadline >= :deadlineFrom', { deadlineFrom: filters.deadlineFrom });
}
if (filters.deadlineTo) {
qb.andWhere('b.submission_deadline <= :deadlineTo', { deadlineTo: filters.deadlineTo });
}
if (filters.minBudget !== undefined) {
qb.andWhere('b.base_budget >= :minBudget', { minBudget: filters.minBudget });
}
if (filters.maxBudget !== undefined) {
qb.andWhere('b.base_budget <= :maxBudget', { maxBudget: filters.maxBudget });
}
if (filters.search) {
qb.andWhere(
'(b.name ILIKE :search OR b.code ILIKE :search OR b.tender_number ILIKE :search)',
{ search: `%${filters.search}%` }
);
}
const skip = (page - 1) * limit;
qb.orderBy('b.submission_deadline', 'ASC').skip(skip).take(limit);
const [data, total] = await qb.getManyAndCount();
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Actualizar licitación
*/
async update(ctx: ServiceContext, id: string, data: UpdateBidDto): Promise<Bid | null> {
const bid = await this.findById(ctx, id);
if (!bid) return null;
// Calcular puntuación final si hay scores
let finalScore = data.finalScore ?? bid.finalScore;
const techScore = data.technicalScore ?? bid.technicalScore;
const econScore = data.economicScore ?? bid.economicScore;
const techWeight = data.technicalWeight ?? bid.technicalWeight;
const econWeight = data.economicWeight ?? bid.economicWeight;
if (techScore !== undefined && econScore !== undefined) {
finalScore = (techScore * techWeight / 100) + (econScore * econWeight / 100);
}
Object.assign(bid, {
...data,
finalScore,
updatedBy: ctx.userId,
});
return this.repository.save(bid);
}
/**
* Cambiar estado
*/
async changeStatus(ctx: ServiceContext, id: string, status: BidStatus): Promise<Bid | null> {
const bid = await this.findById(ctx, id);
if (!bid) return null;
bid.status = status;
bid.updatedBy = ctx.userId;
return this.repository.save(bid);
}
/**
* Cambiar etapa
*/
async changeStage(ctx: ServiceContext, id: string, stage: BidStage): Promise<Bid | null> {
const bid = await this.findById(ctx, id);
if (!bid) return null;
bid.stage = stage;
bid.updatedBy = ctx.userId;
return this.repository.save(bid);
}
/**
* Marcar como presentada
*/
async submit(ctx: ServiceContext, id: string, proposalAmount: number): Promise<Bid | null> {
const bid = await this.findById(ctx, id);
if (!bid) return null;
bid.status = 'submitted';
bid.stage = 'post_submission';
bid.ourProposalAmount = proposalAmount;
bid.updatedBy = ctx.userId;
return this.repository.save(bid);
}
/**
* Registrar resultado
*/
async recordResult(
ctx: ServiceContext,
id: string,
won: boolean,
details: {
winnerName?: string;
winningAmount?: number;
rankingPosition?: number;
rejectionReason?: string;
lessonsLearned?: string;
}
): Promise<Bid | null> {
const bid = await this.findById(ctx, id);
if (!bid) return null;
bid.status = won ? 'awarded' : 'rejected';
bid.awardDate = new Date();
Object.assign(bid, details);
bid.updatedBy = ctx.userId;
return this.repository.save(bid);
}
/**
* Convertir a proyecto
*/
async convertToProject(ctx: ServiceContext, id: string, projectId: string): Promise<Bid | null> {
const bid = await this.findById(ctx, id);
if (!bid || bid.status !== 'awarded') return null;
bid.convertedToProjectId = projectId;
bid.convertedAt = new Date();
bid.updatedBy = ctx.userId;
return this.repository.save(bid);
}
/**
* Obtener próximas fechas límite
*/
async getUpcomingDeadlines(ctx: ServiceContext, days = 7): Promise<Bid[]> {
const now = new Date();
const future = new Date();
future.setDate(future.getDate() + days);
return this.repository.find({
where: {
tenantId: ctx.tenantId,
deletedAt: undefined,
status: In(['draft', 'preparation', 'review', 'approved']),
submissionDeadline: Between(now, future),
},
relations: ['opportunity', 'bidManager'],
order: { submissionDeadline: 'ASC' },
});
}
/**
* Obtener estadísticas
*/
async getStats(ctx: ServiceContext, year?: number): Promise<BidStats> {
const currentYear = year || new Date().getFullYear();
const startDate = new Date(currentYear, 0, 1);
const endDate = new Date(currentYear, 11, 31);
const total = await this.repository
.createQueryBuilder('b')
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('b.deleted_at IS NULL')
.andWhere('b.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
.getCount();
const byStatus = await this.repository
.createQueryBuilder('b')
.select('b.status', 'status')
.addSelect('COUNT(*)', 'count')
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('b.deleted_at IS NULL')
.andWhere('b.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
.groupBy('b.status')
.getRawMany();
const byType = await this.repository
.createQueryBuilder('b')
.select('b.bid_type', 'bidType')
.addSelect('COUNT(*)', 'count')
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('b.deleted_at IS NULL')
.andWhere('b.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
.groupBy('b.bid_type')
.getRawMany();
const valueStats = await this.repository
.createQueryBuilder('b')
.select('SUM(b.base_budget)', 'totalBudget')
.addSelect('SUM(b.our_proposal_amount)', 'totalProposed')
.addSelect('SUM(CASE WHEN b.status = \'awarded\' THEN b.winning_amount ELSE 0 END)', 'totalWon')
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('b.deleted_at IS NULL')
.andWhere('b.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
.getRawOne();
const awardedCount = byStatus.find((s) => s.status === 'awarded')?.count || 0;
const rejectedCount = byStatus.find((s) => s.status === 'rejected')?.count || 0;
const closedCount = parseInt(awardedCount) + parseInt(rejectedCount);
return {
year: currentYear,
total,
byStatus: byStatus.map((r) => ({ status: r.status, count: parseInt(r.count) })),
byType: byType.map((r) => ({ bidType: r.bidType, count: parseInt(r.count) })),
totalBudget: parseFloat(valueStats?.totalBudget) || 0,
totalProposed: parseFloat(valueStats?.totalProposed) || 0,
totalWon: parseFloat(valueStats?.totalWon) || 0,
winRate: closedCount > 0 ? (parseInt(awardedCount) / closedCount) * 100 : 0,
};
}
/**
* Soft delete
*/
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
const result = await this.repository.update(
{ id, tenantId: ctx.tenantId },
{ deletedAt: new Date(), updatedBy: ctx.userId }
);
return (result.affected || 0) > 0;
}
}
export interface BidStats {
year: number;
total: number;
byStatus: { status: BidStatus; count: number }[];
byType: { bidType: BidType; count: number }[];
totalBudget: number;
totalProposed: number;
totalWon: number;
winRate: number;
}

View File

@ -0,0 +1,9 @@
/**
* Bidding Services Index
* @module Bidding
*/
export { OpportunityService, CreateOpportunityDto, UpdateOpportunityDto, OpportunityFilters, PipelineData, OpportunityStats } from './opportunity.service';
export { BidService, CreateBidDto, UpdateBidDto, BidFilters, BidStats } from './bid.service';
export { BidBudgetService, CreateBudgetItemDto, UpdateBudgetItemDto, BudgetFilters, BudgetSummary } from './bid-budget.service';
export { BidAnalyticsService, BidDashboard, PipelineBySource, WinRateByType, MonthlyTrend, CompetitorAnalysis, FunnelAnalysis, CycleTimeAnalysis } from './bid-analytics.service';

View File

@ -0,0 +1,392 @@
/**
* OpportunityService - Gestión de Oportunidades de Negocio
*
* CRUD y lógica de negocio para el pipeline de oportunidades.
*
* @module Bidding
*/
import { Repository, In, Between } from 'typeorm';
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
import { Opportunity, OpportunitySource, OpportunityStatus, OpportunityPriority, ProjectType } from '../entities/opportunity.entity';
export interface CreateOpportunityDto {
code: string;
name: string;
description?: string;
source: OpportunitySource;
projectType: ProjectType;
clientName: string;
clientContact?: string;
clientEmail?: string;
clientPhone?: string;
clientType?: string;
location?: string;
state?: string;
city?: string;
estimatedValue?: number;
currency?: string;
constructionAreaM2?: number;
landAreaM2?: number;
identificationDate: Date;
deadlineDate?: Date;
expectedAwardDate?: Date;
expectedStartDate?: Date;
expectedDurationMonths?: number;
winProbability?: number;
requiresBond?: boolean;
requiresExperience?: boolean;
minimumExperienceYears?: number;
minimumCapital?: number;
requiredCertifications?: string[];
assignedToId?: string;
sourceUrl?: string;
sourceReference?: string;
notes?: string;
metadata?: Record<string, any>;
}
export interface UpdateOpportunityDto extends Partial<CreateOpportunityDto> {
status?: OpportunityStatus;
priority?: OpportunityPriority;
lossReason?: string;
winFactors?: string;
}
export interface OpportunityFilters {
status?: OpportunityStatus | OpportunityStatus[];
source?: OpportunitySource;
projectType?: ProjectType;
priority?: OpportunityPriority;
assignedToId?: string;
clientName?: string;
state?: string;
dateFrom?: Date;
dateTo?: Date;
minValue?: number;
maxValue?: number;
search?: string;
}
export class OpportunityService {
constructor(private readonly repository: Repository<Opportunity>) {}
/**
* Crear oportunidad
*/
async create(ctx: ServiceContext, data: CreateOpportunityDto): Promise<Opportunity> {
const weightedValue = data.estimatedValue && data.winProbability
? data.estimatedValue * (data.winProbability / 100)
: undefined;
const opportunity = this.repository.create({
tenantId: ctx.tenantId,
code: data.code,
name: data.name,
description: data.description,
source: data.source,
projectType: data.projectType,
clientName: data.clientName,
clientContact: data.clientContact,
clientEmail: data.clientEmail,
clientPhone: data.clientPhone,
clientType: data.clientType,
location: data.location,
state: data.state,
city: data.city,
estimatedValue: data.estimatedValue,
currency: data.currency || 'MXN',
constructionAreaM2: data.constructionAreaM2,
landAreaM2: data.landAreaM2,
identificationDate: data.identificationDate,
deadlineDate: data.deadlineDate,
expectedAwardDate: data.expectedAwardDate,
expectedStartDate: data.expectedStartDate,
expectedDurationMonths: data.expectedDurationMonths,
winProbability: data.winProbability || 0,
requiresBond: data.requiresBond || false,
requiresExperience: data.requiresExperience || false,
minimumExperienceYears: data.minimumExperienceYears,
minimumCapital: data.minimumCapital,
requiredCertifications: data.requiredCertifications,
assignedToId: data.assignedToId,
sourceUrl: data.sourceUrl,
sourceReference: data.sourceReference,
notes: data.notes,
metadata: data.metadata,
status: 'identified',
priority: 'medium',
weightedValue,
createdBy: ctx.userId,
updatedBy: ctx.userId,
});
return this.repository.save(opportunity);
}
/**
* Buscar por ID
*/
async findById(ctx: ServiceContext, id: string): Promise<Opportunity | null> {
return this.repository.findOne({
where: { id, tenantId: ctx.tenantId, deletedAt: undefined },
relations: ['assignedTo', 'bids'],
});
}
/**
* Buscar con filtros
*/
async findWithFilters(
ctx: ServiceContext,
filters: OpportunityFilters,
page = 1,
limit = 20
): Promise<PaginatedResult<Opportunity>> {
const qb = this.repository
.createQueryBuilder('o')
.leftJoinAndSelect('o.assignedTo', 'u')
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('o.deleted_at IS NULL');
if (filters.status) {
if (Array.isArray(filters.status)) {
qb.andWhere('o.status IN (:...statuses)', { statuses: filters.status });
} else {
qb.andWhere('o.status = :status', { status: filters.status });
}
}
if (filters.source) {
qb.andWhere('o.source = :source', { source: filters.source });
}
if (filters.projectType) {
qb.andWhere('o.project_type = :projectType', { projectType: filters.projectType });
}
if (filters.priority) {
qb.andWhere('o.priority = :priority', { priority: filters.priority });
}
if (filters.assignedToId) {
qb.andWhere('o.assigned_to_id = :assignedToId', { assignedToId: filters.assignedToId });
}
if (filters.clientName) {
qb.andWhere('o.client_name ILIKE :clientName', { clientName: `%${filters.clientName}%` });
}
if (filters.state) {
qb.andWhere('o.state = :state', { state: filters.state });
}
if (filters.dateFrom) {
qb.andWhere('o.identification_date >= :dateFrom', { dateFrom: filters.dateFrom });
}
if (filters.dateTo) {
qb.andWhere('o.identification_date <= :dateTo', { dateTo: filters.dateTo });
}
if (filters.minValue !== undefined) {
qb.andWhere('o.estimated_value >= :minValue', { minValue: filters.minValue });
}
if (filters.maxValue !== undefined) {
qb.andWhere('o.estimated_value <= :maxValue', { maxValue: filters.maxValue });
}
if (filters.search) {
qb.andWhere(
'(o.name ILIKE :search OR o.code ILIKE :search OR o.client_name ILIKE :search)',
{ search: `%${filters.search}%` }
);
}
const skip = (page - 1) * limit;
qb.orderBy('o.created_at', 'DESC').skip(skip).take(limit);
const [data, total] = await qb.getManyAndCount();
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Actualizar oportunidad
*/
async update(ctx: ServiceContext, id: string, data: UpdateOpportunityDto): Promise<Opportunity | null> {
const opportunity = await this.findById(ctx, id);
if (!opportunity) return null;
// Recalcular weighted value si cambian los factores
let weightedValue = opportunity.weightedValue;
const estimatedValue = data.estimatedValue ?? opportunity.estimatedValue;
const winProbability = data.winProbability ?? opportunity.winProbability;
if (estimatedValue && winProbability) {
weightedValue = estimatedValue * (winProbability / 100);
}
Object.assign(opportunity, {
...data,
weightedValue,
updatedBy: ctx.userId,
});
return this.repository.save(opportunity);
}
/**
* Cambiar estado
*/
async changeStatus(
ctx: ServiceContext,
id: string,
status: OpportunityStatus,
reason?: string
): Promise<Opportunity | null> {
const opportunity = await this.findById(ctx, id);
if (!opportunity) return null;
opportunity.status = status;
if (status === 'lost' && reason) {
opportunity.lossReason = reason;
} else if (status === 'won' && reason) {
opportunity.winFactors = reason;
}
opportunity.updatedBy = ctx.userId;
return this.repository.save(opportunity);
}
/**
* Obtener pipeline (agrupado por status)
*/
async getPipeline(ctx: ServiceContext): Promise<PipelineData[]> {
const result = await this.repository
.createQueryBuilder('o')
.select('o.status', 'status')
.addSelect('COUNT(*)', 'count')
.addSelect('SUM(o.estimated_value)', 'totalValue')
.addSelect('SUM(o.weighted_value)', 'weightedValue')
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('o.deleted_at IS NULL')
.groupBy('o.status')
.getRawMany();
return result.map((r) => ({
status: r.status as OpportunityStatus,
count: parseInt(r.count),
totalValue: parseFloat(r.totalValue) || 0,
weightedValue: parseFloat(r.weightedValue) || 0,
}));
}
/**
* Obtener oportunidades próximas a vencer
*/
async getUpcomingDeadlines(ctx: ServiceContext, days = 7): Promise<Opportunity[]> {
const now = new Date();
const future = new Date();
future.setDate(future.getDate() + days);
return this.repository.find({
where: {
tenantId: ctx.tenantId,
deletedAt: undefined,
status: In(['identified', 'qualified', 'pursuing']),
deadlineDate: Between(now, future),
},
relations: ['assignedTo'],
order: { deadlineDate: 'ASC' },
});
}
/**
* Obtener estadísticas
*/
async getStats(ctx: ServiceContext, year?: number): Promise<OpportunityStats> {
const currentYear = year || new Date().getFullYear();
const startDate = new Date(currentYear, 0, 1);
const endDate = new Date(currentYear, 11, 31);
const baseQuery = this.repository
.createQueryBuilder('o')
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('o.deleted_at IS NULL')
.andWhere('o.identification_date BETWEEN :startDate AND :endDate', { startDate, endDate });
const total = await baseQuery.getCount();
const byStatus = await this.repository
.createQueryBuilder('o')
.select('o.status', 'status')
.addSelect('COUNT(*)', 'count')
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('o.deleted_at IS NULL')
.andWhere('o.identification_date BETWEEN :startDate AND :endDate', { startDate, endDate })
.groupBy('o.status')
.getRawMany();
const bySource = await this.repository
.createQueryBuilder('o')
.select('o.source', 'source')
.addSelect('COUNT(*)', 'count')
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('o.deleted_at IS NULL')
.andWhere('o.identification_date BETWEEN :startDate AND :endDate', { startDate, endDate })
.groupBy('o.source')
.getRawMany();
const valueStats = await this.repository
.createQueryBuilder('o')
.select('SUM(o.estimated_value)', 'totalValue')
.addSelect('SUM(o.weighted_value)', 'weightedValue')
.addSelect('AVG(o.estimated_value)', 'avgValue')
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('o.deleted_at IS NULL')
.andWhere('o.identification_date BETWEEN :startDate AND :endDate', { startDate, endDate })
.getRawOne();
const wonCount = byStatus.find((s) => s.status === 'won')?.count || 0;
const lostCount = byStatus.find((s) => s.status === 'lost')?.count || 0;
const closedCount = parseInt(wonCount) + parseInt(lostCount);
return {
year: currentYear,
total,
byStatus: byStatus.map((r) => ({ status: r.status, count: parseInt(r.count) })),
bySource: bySource.map((r) => ({ source: r.source, count: parseInt(r.count) })),
totalValue: parseFloat(valueStats?.totalValue) || 0,
weightedValue: parseFloat(valueStats?.weightedValue) || 0,
avgValue: parseFloat(valueStats?.avgValue) || 0,
winRate: closedCount > 0 ? (parseInt(wonCount) / closedCount) * 100 : 0,
};
}
/**
* Soft delete
*/
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
const result = await this.repository.update(
{ id, tenantId: ctx.tenantId },
{ deletedAt: new Date(), updatedBy: ctx.userId }
);
return (result.affected || 0) > 0;
}
}
export interface PipelineData {
status: OpportunityStatus;
count: number;
totalValue: number;
weightedValue: number;
}
export interface OpportunityStats {
year: number;
total: number;
byStatus: { status: OpportunityStatus; count: number }[];
bySource: { source: OpportunitySource; count: number }[];
totalValue: number;
weightedValue: number;
avgValue: number;
winRate: number;
}

View File

@ -0,0 +1,252 @@
/**
* ConceptoController - Controller de conceptos de obra
*
* Endpoints REST para gestión del catálogo de conceptos.
*
* @module Budgets
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { ConceptoService, CreateConceptoDto, UpdateConceptoDto } from '../services/concepto.service';
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
import { AuthService } from '../../auth/services/auth.service';
import { Concepto } from '../entities/concepto.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
/**
* Crear router de conceptos
*/
export function createConceptoController(dataSource: DataSource): Router {
const router = Router();
// Repositorios
const conceptoRepository = dataSource.getRepository(Concepto);
const userRepository = dataSource.getRepository(User);
const tenantRepository = dataSource.getRepository(Tenant);
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
// Servicios
const conceptoService = new ConceptoService(conceptoRepository);
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
const authMiddleware = new AuthMiddleware(authService, dataSource);
// Helper para crear contexto de servicio
const getContext = (req: Request): ServiceContext => {
if (!req.tenantId) {
throw new Error('Tenant ID is required');
}
return {
tenantId: req.tenantId,
userId: req.user?.sub,
};
};
/**
* GET /conceptos
* Listar conceptos raíz
*/
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
const result = await conceptoService.findRootConceptos(getContext(req), page, limit);
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
});
} catch (error) {
next(error);
}
});
/**
* GET /conceptos/search
* Buscar conceptos por código o nombre
*/
router.get('/search', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const term = req.query.q as string;
if (!term || term.length < 2) {
res.status(400).json({ error: 'Bad Request', message: 'Search term must be at least 2 characters' });
return;
}
const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
const conceptos = await conceptoService.search(getContext(req), term, limit);
res.status(200).json({ success: true, data: conceptos });
} catch (error) {
next(error);
}
});
/**
* GET /conceptos/tree
* Obtener árbol de conceptos
*/
router.get('/tree', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const rootId = req.query.rootId as string;
const tree = await conceptoService.getConceptoTree(getContext(req), rootId);
res.status(200).json({ success: true, data: tree });
} catch (error) {
next(error);
}
});
/**
* GET /conceptos/:id
* Obtener concepto por ID
*/
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const concepto = await conceptoService.findById(getContext(req), req.params.id);
if (!concepto) {
res.status(404).json({ error: 'Not Found', message: 'Concept not found' });
return;
}
res.status(200).json({ success: true, data: concepto });
} catch (error) {
next(error);
}
});
/**
* GET /conceptos/:id/children
* Obtener hijos de un concepto
*/
router.get('/:id/children', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const children = await conceptoService.findChildren(getContext(req), req.params.id);
res.status(200).json({ success: true, data: children });
} catch (error) {
next(error);
}
});
/**
* POST /conceptos
* Crear concepto
*/
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const dto: CreateConceptoDto = req.body;
if (!dto.code || !dto.name) {
res.status(400).json({ error: 'Bad Request', message: 'code and name are required' });
return;
}
// Verificar código único
const exists = await conceptoService.codeExists(getContext(req), dto.code);
if (exists) {
res.status(409).json({ error: 'Conflict', message: 'Concept code already exists' });
return;
}
const concepto = await conceptoService.createConcepto(getContext(req), dto);
res.status(201).json({ success: true, data: concepto });
} catch (error) {
next(error);
}
});
/**
* PATCH /conceptos/:id
* Actualizar concepto
*/
router.patch('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const dto: UpdateConceptoDto = req.body;
const concepto = await conceptoService.update(getContext(req), req.params.id, dto);
if (!concepto) {
res.status(404).json({ error: 'Not Found', message: 'Concept not found' });
return;
}
res.status(200).json({ success: true, data: concepto });
} catch (error) {
next(error);
}
});
/**
* DELETE /conceptos/:id
* Eliminar concepto (soft delete)
*/
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const deleted = await conceptoService.softDelete(getContext(req), req.params.id);
if (!deleted) {
res.status(404).json({ error: 'Not Found', message: 'Concept not found' });
return;
}
res.status(200).json({ success: true, message: 'Concept deleted' });
} catch (error) {
next(error);
}
});
return router;
}
export default createConceptoController;

View File

@ -0,0 +1,7 @@
/**
* Budgets Controllers Index
* @module Budgets
*/
export { createConceptoController } from './concepto.controller';
export { createPresupuestoController } from './presupuesto.controller';

View File

@ -0,0 +1,287 @@
/**
* PresupuestoController - Controller de presupuestos
*
* Endpoints REST para gestión de presupuestos de obra.
*
* @module Budgets
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { PresupuestoService, CreatePresupuestoDto, AddPartidaDto, UpdatePartidaDto } from '../services/presupuesto.service';
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
import { AuthService } from '../../auth/services/auth.service';
import { Presupuesto } from '../entities/presupuesto.entity';
import { PresupuestoPartida } from '../entities/presupuesto-partida.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
/**
* Crear router de presupuestos
*/
export function createPresupuestoController(dataSource: DataSource): Router {
const router = Router();
// Repositorios
const presupuestoRepository = dataSource.getRepository(Presupuesto);
const partidaRepository = dataSource.getRepository(PresupuestoPartida);
const userRepository = dataSource.getRepository(User);
const tenantRepository = dataSource.getRepository(Tenant);
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
// Servicios
const presupuestoService = new PresupuestoService(presupuestoRepository, partidaRepository);
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
const authMiddleware = new AuthMiddleware(authService, dataSource);
// Helper para crear contexto de servicio
const getContext = (req: Request): ServiceContext => {
if (!req.tenantId) {
throw new Error('Tenant ID is required');
}
return {
tenantId: req.tenantId,
userId: req.user?.sub,
};
};
/**
* GET /presupuestos
* Listar presupuestos
*/
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const fraccionamientoId = req.query.fraccionamientoId as string;
let result;
if (fraccionamientoId) {
result = await presupuestoService.findByFraccionamiento(getContext(req), fraccionamientoId, page, limit);
} else {
result = await presupuestoService.findAll(getContext(req), { page, limit });
}
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
});
} catch (error) {
next(error);
}
});
/**
* GET /presupuestos/:id
* Obtener presupuesto por ID con sus partidas
*/
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const presupuesto = await presupuestoService.findWithPartidas(getContext(req), req.params.id);
if (!presupuesto) {
res.status(404).json({ error: 'Not Found', message: 'Budget not found' });
return;
}
res.status(200).json({ success: true, data: presupuesto });
} catch (error) {
next(error);
}
});
/**
* POST /presupuestos
* Crear presupuesto
*/
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const dto: CreatePresupuestoDto = req.body;
if (!dto.code || !dto.name) {
res.status(400).json({ error: 'Bad Request', message: 'code and name are required' });
return;
}
const presupuesto = await presupuestoService.createPresupuesto(getContext(req), dto);
res.status(201).json({ success: true, data: presupuesto });
} catch (error) {
next(error);
}
});
/**
* POST /presupuestos/:id/partidas
* Agregar partida al presupuesto
*/
router.post('/:id/partidas', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const dto: AddPartidaDto = req.body;
if (!dto.conceptoId || dto.quantity === undefined || dto.unitPrice === undefined) {
res.status(400).json({ error: 'Bad Request', message: 'conceptoId, quantity and unitPrice are required' });
return;
}
const partida = await presupuestoService.addPartida(getContext(req), req.params.id, dto);
res.status(201).json({ success: true, data: partida });
} catch (error) {
if (error instanceof Error && error.message === 'Presupuesto not found') {
res.status(404).json({ error: 'Not Found', message: error.message });
return;
}
next(error);
}
});
/**
* PATCH /presupuestos/:id/partidas/:partidaId
* Actualizar partida
*/
router.patch('/:id/partidas/:partidaId', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const dto: UpdatePartidaDto = req.body;
const partida = await presupuestoService.updatePartida(getContext(req), req.params.partidaId, dto);
if (!partida) {
res.status(404).json({ error: 'Not Found', message: 'Budget item not found' });
return;
}
res.status(200).json({ success: true, data: partida });
} catch (error) {
next(error);
}
});
/**
* DELETE /presupuestos/:id/partidas/:partidaId
* Eliminar partida
*/
router.delete('/:id/partidas/:partidaId', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const deleted = await presupuestoService.removePartida(getContext(req), req.params.partidaId);
if (!deleted) {
res.status(404).json({ error: 'Not Found', message: 'Budget item not found' });
return;
}
res.status(200).json({ success: true, message: 'Budget item deleted' });
} catch (error) {
next(error);
}
});
/**
* POST /presupuestos/:id/version
* Crear nueva versión del presupuesto
*/
router.post('/:id/version', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const newVersion = await presupuestoService.createNewVersion(getContext(req), req.params.id);
res.status(201).json({ success: true, data: newVersion });
} catch (error) {
if (error instanceof Error && error.message === 'Presupuesto not found') {
res.status(404).json({ error: 'Not Found', message: error.message });
return;
}
next(error);
}
});
/**
* POST /presupuestos/:id/approve
* Aprobar presupuesto
*/
router.post('/:id/approve', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const presupuesto = await presupuestoService.approve(getContext(req), req.params.id);
if (!presupuesto) {
res.status(404).json({ error: 'Not Found', message: 'Budget not found' });
return;
}
res.status(200).json({ success: true, data: presupuesto, message: 'Budget approved' });
} catch (error) {
next(error);
}
});
/**
* DELETE /presupuestos/:id
* Eliminar presupuesto (soft delete)
*/
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const deleted = await presupuestoService.softDelete(getContext(req), req.params.id);
if (!deleted) {
res.status(404).json({ error: 'Not Found', message: 'Budget not found' });
return;
}
res.status(200).json({ success: true, message: 'Budget deleted' });
} catch (error) {
next(error);
}
});
return router;
}
export default createPresupuestoController;

View File

@ -0,0 +1,100 @@
/**
* Concepto Entity
* Catalogo de conceptos de obra (estructura jerarquica)
*
* @module Budgets
* @table construction.conceptos
* @ddl schemas/01-construction-schema-ddl.sql
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
Index,
} from 'typeorm';
import { Tenant } from '../../core/entities/tenant.entity';
import { User } from '../../core/entities/user.entity';
@Entity({ schema: 'construction', name: 'conceptos' })
@Index(['tenantId', 'code'], { unique: true })
@Index(['tenantId'])
@Index(['parentId'])
@Index(['code'])
export class Concepto {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'parent_id', type: 'uuid', nullable: true })
parentId: string | null;
@Column({ type: 'varchar', length: 50 })
code: string;
@Column({ type: 'varchar', length: 255 })
name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ name: 'unit_id', type: 'uuid', nullable: true })
unitId: string | null;
@Column({ name: 'unit_price', type: 'decimal', precision: 12, scale: 4, nullable: true })
unitPrice: number | null;
@Column({ name: 'is_composite', type: 'boolean', default: false })
isComposite: boolean;
@Column({ type: 'integer', default: 0 })
level: number;
@Column({ type: 'varchar', length: 500, nullable: true })
path: string | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdById: string | null;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true })
updatedAt: Date | null;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedById: string | null;
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date | null;
@Column({ name: 'deleted_by', type: 'uuid', nullable: true })
deletedById: string | null;
// Relations
@ManyToOne(() => Tenant)
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@ManyToOne(() => Concepto, (c) => c.children, { nullable: true })
@JoinColumn({ name: 'parent_id' })
parent: Concepto | null;
@OneToMany(() => Concepto, (c) => c.parent)
children: Concepto[];
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by' })
createdBy: User | null;
@ManyToOne(() => User)
@JoinColumn({ name: 'updated_by' })
updatedBy: User | null;
}

View File

@ -0,0 +1,8 @@
/**
* Budgets Module - Entity Exports
* MAI-003: Presupuestos
*/
export * from './concepto.entity';
export * from './presupuesto.entity';
export * from './presupuesto-partida.entity';

View File

@ -0,0 +1,95 @@
/**
* PresupuestoPartida Entity
* Lineas/partidas de un presupuesto
*
* @module Budgets
* @table construction.presupuesto_partidas
* @ddl schemas/01-construction-schema-ddl.sql
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Tenant } from '../../core/entities/tenant.entity';
import { User } from '../../core/entities/user.entity';
import { Presupuesto } from './presupuesto.entity';
import { Concepto } from './concepto.entity';
@Entity({ schema: 'construction', name: 'presupuesto_partidas' })
@Index(['presupuestoId', 'conceptoId'], { unique: true })
@Index(['tenantId'])
export class PresupuestoPartida {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'presupuesto_id', type: 'uuid' })
presupuestoId: string;
@Column({ name: 'concepto_id', type: 'uuid' })
conceptoId: string;
@Column({ type: 'integer', default: 0 })
sequence: number;
@Column({ type: 'decimal', precision: 12, scale: 4, default: 0 })
quantity: number;
@Column({ name: 'unit_price', type: 'decimal', precision: 12, scale: 4, default: 0 })
unitPrice: number;
// Columna calculada (GENERATED ALWAYS AS) - solo lectura
@Column({
name: 'total_amount',
type: 'decimal',
precision: 14,
scale: 2,
insert: false,
update: false,
})
totalAmount: number;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdById: string | null;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true })
updatedAt: Date | null;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedById: string | null;
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date | null;
@Column({ name: 'deleted_by', type: 'uuid', nullable: true })
deletedById: string | null;
// Relations
@ManyToOne(() => Tenant)
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@ManyToOne(() => Presupuesto, (p) => p.partidas)
@JoinColumn({ name: 'presupuesto_id' })
presupuesto: Presupuesto;
@ManyToOne(() => Concepto)
@JoinColumn({ name: 'concepto_id' })
concepto: Concepto;
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by' })
createdBy: User | null;
}

View File

@ -0,0 +1,107 @@
/**
* Presupuesto Entity
* Presupuestos de obra por prototipo o fraccionamiento
*
* @module Budgets
* @table construction.presupuestos
* @ddl schemas/01-construction-schema-ddl.sql
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
Index,
} from 'typeorm';
import { Tenant } from '../../core/entities/tenant.entity';
import { User } from '../../core/entities/user.entity';
import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity';
import { PresupuestoPartida } from './presupuesto-partida.entity';
@Entity({ schema: 'construction', name: 'presupuestos' })
@Index(['tenantId', 'code', 'version'], { unique: true })
@Index(['tenantId'])
@Index(['fraccionamientoId'])
export class Presupuesto {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'fraccionamiento_id', type: 'uuid', nullable: true })
fraccionamientoId: string | null;
@Column({ name: 'prototipo_id', type: 'uuid', nullable: true })
prototipoId: string | null;
@Column({ type: 'varchar', length: 30 })
code: string;
@Column({ type: 'varchar', length: 255 })
name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'integer', default: 1 })
version: number;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@Column({ name: 'total_amount', type: 'decimal', precision: 16, scale: 2, default: 0 })
totalAmount: number;
@Column({ name: 'currency_id', type: 'uuid', nullable: true })
currencyId: string | null;
@Column({ name: 'approved_at', type: 'timestamptz', nullable: true })
approvedAt: Date | null;
@Column({ name: 'approved_by', type: 'uuid', nullable: true })
approvedById: string | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdById: string | null;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true })
updatedAt: Date | null;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedById: string | null;
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date | null;
@Column({ name: 'deleted_by', type: 'uuid', nullable: true })
deletedById: string | null;
// Relations
@ManyToOne(() => Tenant)
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@ManyToOne(() => Fraccionamiento, { nullable: true })
@JoinColumn({ name: 'fraccionamiento_id' })
fraccionamiento: Fraccionamiento | null;
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by' })
createdBy: User | null;
@ManyToOne(() => User)
@JoinColumn({ name: 'approved_by' })
approvedBy: User | null;
@OneToMany(() => PresupuestoPartida, (p) => p.presupuesto)
partidas: PresupuestoPartida[];
}

View File

@ -0,0 +1,160 @@
/**
* ConceptoService - Catalogo de Conceptos de Obra
*
* Gestiona el catálogo jerárquico de conceptos de obra.
* Los conceptos pueden tener estructura padre-hijo (niveles).
*
* @module Budgets
*/
import { Repository, IsNull } from 'typeorm';
import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
import { Concepto } from '../entities/concepto.entity';
export interface CreateConceptoDto {
code: string;
name: string;
description?: string;
parentId?: string;
unitId?: string;
unitPrice?: number;
isComposite?: boolean;
}
export interface UpdateConceptoDto {
name?: string;
description?: string;
unitId?: string;
unitPrice?: number;
isComposite?: boolean;
}
export class ConceptoService extends BaseService<Concepto> {
constructor(repository: Repository<Concepto>) {
super(repository);
}
/**
* Crear un nuevo concepto con cálculo automático de nivel y path
*/
async createConcepto(
ctx: ServiceContext,
data: CreateConceptoDto
): Promise<Concepto> {
let level = 0;
let path = data.code;
if (data.parentId) {
const parent = await this.findById(ctx, data.parentId);
if (parent) {
level = parent.level + 1;
path = `${parent.path}/${data.code}`;
}
}
return this.create(ctx, {
...data,
level,
path,
});
}
/**
* Obtener conceptos raíz (sin padre)
*/
async findRootConceptos(
ctx: ServiceContext,
page = 1,
limit = 50
): Promise<PaginatedResult<Concepto>> {
return this.findAll(ctx, {
page,
limit,
where: { parentId: IsNull() } as any,
});
}
/**
* Obtener hijos de un concepto
*/
async findChildren(
ctx: ServiceContext,
parentId: string
): Promise<Concepto[]> {
return this.find(ctx, {
where: { parentId } as any,
order: { code: 'ASC' },
});
}
/**
* Obtener árbol completo de conceptos
*/
async getConceptoTree(
ctx: ServiceContext,
rootId?: string
): Promise<ConceptoNode[]> {
const where = rootId
? { parentId: rootId }
: { parentId: IsNull() };
const roots = await this.find(ctx, {
where: where as any,
order: { code: 'ASC' },
});
return this.buildTree(ctx, roots);
}
private async buildTree(
ctx: ServiceContext,
conceptos: Concepto[]
): Promise<ConceptoNode[]> {
const tree: ConceptoNode[] = [];
for (const concepto of conceptos) {
const children = await this.findChildren(ctx, concepto.id);
const childNodes = children.length > 0
? await this.buildTree(ctx, children)
: [];
tree.push({
...concepto,
children: childNodes,
});
}
return tree;
}
/**
* Buscar conceptos por código o nombre
*/
async search(
ctx: ServiceContext,
term: string,
limit = 20
): Promise<Concepto[]> {
return this.repository
.createQueryBuilder('c')
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('c.deleted_at IS NULL')
.andWhere('(c.code ILIKE :term OR c.name ILIKE :term)', {
term: `%${term}%`,
})
.orderBy('c.code', 'ASC')
.take(limit)
.getMany();
}
/**
* Verificar si un código ya existe
*/
async codeExists(ctx: ServiceContext, code: string): Promise<boolean> {
return this.exists(ctx, { code } as any);
}
}
interface ConceptoNode extends Concepto {
children: ConceptoNode[];
}

View File

@ -0,0 +1,6 @@
/**
* Budgets Module - Service Exports
*/
export * from './concepto.service';
export * from './presupuesto.service';

View File

@ -0,0 +1,262 @@
/**
* PresupuestoService - Gestión de Presupuestos de Obra
*
* Gestiona presupuestos de obra con sus partidas.
* Soporta versionamiento y aprobación.
*
* @module Budgets
*/
import { Repository } from 'typeorm';
import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
import { Presupuesto } from '../entities/presupuesto.entity';
import { PresupuestoPartida } from '../entities/presupuesto-partida.entity';
export interface CreatePresupuestoDto {
code: string;
name: string;
description?: string;
fraccionamientoId?: string;
prototipoId?: string;
currencyId?: string;
}
export interface AddPartidaDto {
conceptoId: string;
quantity: number;
unitPrice: number;
sequence?: number;
}
export interface UpdatePartidaDto {
quantity?: number;
unitPrice?: number;
sequence?: number;
}
export class PresupuestoService extends BaseService<Presupuesto> {
constructor(
repository: Repository<Presupuesto>,
private readonly partidaRepository: Repository<PresupuestoPartida>
) {
super(repository);
}
/**
* Crear nuevo presupuesto
*/
async createPresupuesto(
ctx: ServiceContext,
data: CreatePresupuestoDto
): Promise<Presupuesto> {
return this.create(ctx, {
...data,
version: 1,
isActive: true,
totalAmount: 0,
});
}
/**
* Obtener presupuestos por fraccionamiento
*/
async findByFraccionamiento(
ctx: ServiceContext,
fraccionamientoId: string,
page = 1,
limit = 20
): Promise<PaginatedResult<Presupuesto>> {
return this.findAll(ctx, {
page,
limit,
where: { fraccionamientoId, isActive: true } as any,
});
}
/**
* Obtener presupuesto con sus partidas
*/
async findWithPartidas(
ctx: ServiceContext,
id: string
): Promise<Presupuesto | null> {
return this.repository.findOne({
where: {
id,
tenantId: ctx.tenantId,
deletedAt: null,
} as any,
relations: ['partidas', 'partidas.concepto'],
});
}
/**
* Agregar partida al presupuesto
*/
async addPartida(
ctx: ServiceContext,
presupuestoId: string,
data: AddPartidaDto
): Promise<PresupuestoPartida> {
const presupuesto = await this.findById(ctx, presupuestoId);
if (!presupuesto) {
throw new Error('Presupuesto not found');
}
const partida = this.partidaRepository.create({
tenantId: ctx.tenantId,
presupuestoId,
conceptoId: data.conceptoId,
quantity: data.quantity,
unitPrice: data.unitPrice,
sequence: data.sequence || 0,
createdById: ctx.userId,
});
const savedPartida = await this.partidaRepository.save(partida);
await this.recalculateTotal(ctx, presupuestoId);
return savedPartida;
}
/**
* Actualizar partida
*/
async updatePartida(
ctx: ServiceContext,
partidaId: string,
data: UpdatePartidaDto
): Promise<PresupuestoPartida | null> {
const partida = await this.partidaRepository.findOne({
where: {
id: partidaId,
tenantId: ctx.tenantId,
deletedAt: null,
} as any,
});
if (!partida) {
return null;
}
const updated = this.partidaRepository.merge(partida, {
...data,
updatedById: ctx.userId,
});
const saved = await this.partidaRepository.save(updated);
await this.recalculateTotal(ctx, partida.presupuestoId);
return saved;
}
/**
* Eliminar partida
*/
async removePartida(ctx: ServiceContext, partidaId: string): Promise<boolean> {
const partida = await this.partidaRepository.findOne({
where: {
id: partidaId,
tenantId: ctx.tenantId,
} as any,
});
if (!partida) {
return false;
}
await this.partidaRepository.update(
{ id: partidaId },
{
deletedAt: new Date(),
deletedById: ctx.userId,
}
);
await this.recalculateTotal(ctx, partida.presupuestoId);
return true;
}
/**
* Recalcular total del presupuesto
*/
async recalculateTotal(ctx: ServiceContext, presupuestoId: string): Promise<void> {
const result = await this.partidaRepository
.createQueryBuilder('p')
.select('SUM(p.quantity * p.unit_price)', 'total')
.where('p.presupuesto_id = :presupuestoId', { presupuestoId })
.andWhere('p.deleted_at IS NULL')
.getRawOne();
const total = parseFloat(result?.total || '0');
await this.repository.update(
{ id: presupuestoId },
{ totalAmount: total, updatedById: ctx.userId }
);
}
/**
* Crear nueva versión del presupuesto
*/
async createNewVersion(
ctx: ServiceContext,
presupuestoId: string
): Promise<Presupuesto> {
const original = await this.findWithPartidas(ctx, presupuestoId);
if (!original) {
throw new Error('Presupuesto not found');
}
// Desactivar versión anterior
await this.repository.update(
{ id: presupuestoId },
{ isActive: false, updatedById: ctx.userId }
);
// Crear nueva versión
const newVersion = await this.create(ctx, {
code: original.code,
name: original.name,
description: original.description,
fraccionamientoId: original.fraccionamientoId,
prototipoId: original.prototipoId,
currencyId: original.currencyId,
version: original.version + 1,
isActive: true,
totalAmount: original.totalAmount,
});
// Copiar partidas
for (const partida of original.partidas) {
await this.partidaRepository.save(
this.partidaRepository.create({
tenantId: ctx.tenantId,
presupuestoId: newVersion.id,
conceptoId: partida.conceptoId,
quantity: partida.quantity,
unitPrice: partida.unitPrice,
sequence: partida.sequence,
createdById: ctx.userId,
})
);
}
return newVersion;
}
/**
* Aprobar presupuesto
*/
async approve(ctx: ServiceContext, presupuestoId: string): Promise<Presupuesto | null> {
const presupuesto = await this.findById(ctx, presupuestoId);
if (!presupuesto) {
return null;
}
return this.update(ctx, presupuestoId, {
approvedAt: new Date(),
approvedById: ctx.userId,
});
}
}

View File

@ -0,0 +1,181 @@
/**
* EtapaController - Controller de etapas
*
* Endpoints REST para gestión de etapas de fraccionamientos.
*
* @module Construction
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { EtapaService, CreateEtapaDto, UpdateEtapaDto } from '../services/etapa.service';
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
import { AuthService } from '../../auth/services/auth.service';
import { Etapa } from '../entities/etapa.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
/**
* Crear router de etapas
*/
export function createEtapaController(dataSource: DataSource): Router {
const router = Router();
// Repositorios
const etapaRepository = dataSource.getRepository(Etapa);
const userRepository = dataSource.getRepository(User);
const tenantRepository = dataSource.getRepository(Tenant);
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
// Servicios
const etapaService = new EtapaService(etapaRepository);
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
const authMiddleware = new AuthMiddleware(authService, dataSource);
/**
* GET /etapas
* Listar etapas
*/
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const search = req.query.search as string;
const status = req.query.status as string;
const fraccionamientoId = req.query.fraccionamientoId as string;
const result = await etapaService.findAll({ tenantId, page, limit, search, status, fraccionamientoId });
res.status(200).json({
success: true,
data: result.items,
pagination: {
page,
limit,
total: result.total,
totalPages: Math.ceil(result.total / limit),
},
});
} catch (error) {
next(error);
}
});
/**
* GET /etapas/:id
* Obtener etapa por ID
*/
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const etapa = await etapaService.findById(req.params.id, tenantId);
if (!etapa) {
res.status(404).json({ error: 'Not Found', message: 'Stage not found' });
return;
}
res.status(200).json({ success: true, data: etapa });
} catch (error) {
next(error);
}
});
/**
* POST /etapas
* Crear etapa
*/
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const dto: CreateEtapaDto = req.body;
if (!dto.fraccionamientoId || !dto.code || !dto.name) {
res.status(400).json({ error: 'Bad Request', message: 'fraccionamientoId, code and name are required' });
return;
}
const etapa = await etapaService.create(tenantId, dto, req.user?.sub);
res.status(201).json({ success: true, data: etapa });
} catch (error) {
if (error instanceof Error && error.message.includes('already exists')) {
res.status(409).json({ error: 'Conflict', message: error.message });
return;
}
next(error);
}
});
/**
* PATCH /etapas/:id
* Actualizar etapa
*/
router.patch('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const dto: UpdateEtapaDto = req.body;
const etapa = await etapaService.update(req.params.id, tenantId, dto, req.user?.sub);
res.status(200).json({ success: true, data: etapa });
} catch (error) {
if (error instanceof Error) {
if (error.message === 'Stage not found') {
res.status(404).json({ error: 'Not Found', message: error.message });
return;
}
if (error.message.includes('already exists')) {
res.status(409).json({ error: 'Conflict', message: error.message });
return;
}
}
next(error);
}
});
/**
* DELETE /etapas/:id
* Eliminar etapa
*/
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
await etapaService.delete(req.params.id, tenantId, req.user?.sub);
res.status(200).json({ success: true, message: 'Stage deleted' });
} catch (error) {
if (error instanceof Error && error.message === 'Stage not found') {
res.status(404).json({ error: 'Not Found', message: error.message });
return;
}
next(error);
}
});
return router;
}
export default createEtapaController;

View File

@ -0,0 +1,157 @@
/**
* Fraccionamiento Controller
* API endpoints para gestión de fraccionamientos/obras
*
* @module Construction
* @prefix /api/v1/fraccionamientos
*/
import { Router, Request, Response, NextFunction } from 'express';
import {
FraccionamientoService,
CreateFraccionamientoDto,
UpdateFraccionamientoDto
} from '../services/fraccionamiento.service';
const router = Router();
const fraccionamientoService = new FraccionamientoService();
/**
* GET /api/v1/fraccionamientos
* Lista todos los fraccionamientos del tenant
*/
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
return res.status(400).json({ error: 'X-Tenant-Id header required' });
}
const { proyectoId, estado } = req.query;
const fraccionamientos = await fraccionamientoService.findAll({
tenantId,
proyectoId: proyectoId as string,
estado: estado as any,
});
return res.json({
success: true,
data: fraccionamientos,
count: fraccionamientos.length,
});
} catch (error) {
return next(error);
}
});
/**
* GET /api/v1/fraccionamientos/:id
* Obtiene un fraccionamiento por ID
*/
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
return res.status(400).json({ error: 'X-Tenant-Id header required' });
}
const fraccionamiento = await fraccionamientoService.findById(req.params.id, tenantId);
if (!fraccionamiento) {
return res.status(404).json({ error: 'Fraccionamiento no encontrado' });
}
return res.json({ success: true, data: fraccionamiento });
} catch (error) {
return next(error);
}
});
/**
* POST /api/v1/fraccionamientos
* Crea un nuevo fraccionamiento
*/
router.post('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
return res.status(400).json({ error: 'X-Tenant-Id header required' });
}
const data: CreateFraccionamientoDto = {
...req.body,
tenantId,
createdById: (req as any).user?.id,
};
// Validate required fields
if (!data.codigo || !data.nombre || !data.proyectoId) {
return res.status(400).json({
error: 'codigo, nombre y proyectoId son requeridos'
});
}
// Check if codigo already exists
const existing = await fraccionamientoService.findByCodigo(data.codigo, tenantId);
if (existing) {
return res.status(409).json({ error: 'Ya existe un fraccionamiento con ese código' });
}
const fraccionamiento = await fraccionamientoService.create(data);
return res.status(201).json({ success: true, data: fraccionamiento });
} catch (error) {
return next(error);
}
});
/**
* PATCH /api/v1/fraccionamientos/:id
* Actualiza un fraccionamiento
*/
router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
return res.status(400).json({ error: 'X-Tenant-Id header required' });
}
const data: UpdateFraccionamientoDto = req.body;
const fraccionamiento = await fraccionamientoService.update(
req.params.id,
tenantId,
data
);
if (!fraccionamiento) {
return res.status(404).json({ error: 'Fraccionamiento no encontrado' });
}
return res.json({ success: true, data: fraccionamiento });
} catch (error) {
return next(error);
}
});
/**
* DELETE /api/v1/fraccionamientos/:id
* Elimina un fraccionamiento
*/
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
return res.status(400).json({ error: 'X-Tenant-Id header required' });
}
const deleted = await fraccionamientoService.delete(req.params.id, tenantId);
if (!deleted) {
return res.status(404).json({ error: 'Fraccionamiento no encontrado' });
}
return res.json({ success: true, message: 'Fraccionamiento eliminado' });
} catch (error) {
return next(error);
}
});
export default router;

View File

@ -0,0 +1,11 @@
/**
* Construction Controllers Index
* @module Construction
*/
export { default as proyectoController } from './proyecto.controller';
export { default as fraccionamientoController } from './fraccionamiento.controller';
export { createEtapaController } from './etapa.controller';
export { createManzanaController } from './manzana.controller';
export { createLoteController } from './lote.controller';
export { createPrototipoController } from './prototipo.controller';

View File

@ -0,0 +1,273 @@
/**
* LoteController - Controller de lotes
*
* Endpoints REST para gestión de lotes/terrenos.
*
* @module Construction
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { LoteService, CreateLoteDto, UpdateLoteDto } from '../services/lote.service';
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
import { AuthService } from '../../auth/services/auth.service';
import { Lote } from '../entities/lote.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
/**
* Crear router de lotes
*/
export function createLoteController(dataSource: DataSource): Router {
const router = Router();
// Repositorios
const loteRepository = dataSource.getRepository(Lote);
const userRepository = dataSource.getRepository(User);
const tenantRepository = dataSource.getRepository(Tenant);
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
// Servicios
const loteService = new LoteService(loteRepository);
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
const authMiddleware = new AuthMiddleware(authService, dataSource);
/**
* GET /lotes
* Listar lotes
*/
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const search = req.query.search as string;
const status = req.query.status as string;
const manzanaId = req.query.manzanaId as string;
const prototipoId = req.query.prototipoId as string;
const result = await loteService.findAll({ tenantId, page, limit, search, status, manzanaId, prototipoId });
res.status(200).json({
success: true,
data: result.items,
pagination: {
page,
limit,
total: result.total,
totalPages: Math.ceil(result.total / limit),
},
});
} catch (error) {
next(error);
}
});
/**
* GET /lotes/stats
* Estadísticas de lotes por estado
*/
router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const manzanaId = req.query.manzanaId as string;
const stats = await loteService.getStatsByStatus(tenantId, manzanaId);
res.status(200).json({ success: true, data: stats });
} catch (error) {
next(error);
}
});
/**
* GET /lotes/:id
* Obtener lote por ID
*/
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const lote = await loteService.findById(req.params.id, tenantId);
if (!lote) {
res.status(404).json({ error: 'Not Found', message: 'Lot not found' });
return;
}
res.status(200).json({ success: true, data: lote });
} catch (error) {
next(error);
}
});
/**
* POST /lotes
* Crear lote
*/
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const dto: CreateLoteDto = req.body;
if (!dto.manzanaId || !dto.code) {
res.status(400).json({ error: 'Bad Request', message: 'manzanaId and code are required' });
return;
}
const lote = await loteService.create(tenantId, dto, req.user?.sub);
res.status(201).json({ success: true, data: lote });
} catch (error) {
if (error instanceof Error && error.message.includes('already exists')) {
res.status(409).json({ error: 'Conflict', message: error.message });
return;
}
next(error);
}
});
/**
* PATCH /lotes/:id
* Actualizar lote
*/
router.patch('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const dto: UpdateLoteDto = req.body;
const lote = await loteService.update(req.params.id, tenantId, dto, req.user?.sub);
res.status(200).json({ success: true, data: lote });
} catch (error) {
if (error instanceof Error) {
if (error.message === 'Lot not found') {
res.status(404).json({ error: 'Not Found', message: error.message });
return;
}
if (error.message.includes('already exists')) {
res.status(409).json({ error: 'Conflict', message: error.message });
return;
}
}
next(error);
}
});
/**
* PATCH /lotes/:id/prototipo
* Asignar prototipo a lote
*/
router.patch('/:id/prototipo', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const { prototipoId } = req.body;
if (!prototipoId) {
res.status(400).json({ error: 'Bad Request', message: 'prototipoId is required' });
return;
}
const lote = await loteService.assignPrototipo(req.params.id, tenantId, prototipoId, req.user?.sub);
res.status(200).json({ success: true, data: lote });
} catch (error) {
if (error instanceof Error && error.message === 'Lot not found') {
res.status(404).json({ error: 'Not Found', message: error.message });
return;
}
next(error);
}
});
/**
* PATCH /lotes/:id/status
* Cambiar estado del lote
*/
router.patch('/:id/status', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'finance'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const { status } = req.body;
if (!status) {
res.status(400).json({ error: 'Bad Request', message: 'status is required' });
return;
}
const validStatuses = ['available', 'reserved', 'sold', 'blocked', 'in_construction'];
if (!validStatuses.includes(status)) {
res.status(400).json({ error: 'Bad Request', message: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
return;
}
const lote = await loteService.changeStatus(req.params.id, tenantId, status, req.user?.sub);
res.status(200).json({ success: true, data: lote });
} catch (error) {
if (error instanceof Error && error.message === 'Lot not found') {
res.status(404).json({ error: 'Not Found', message: error.message });
return;
}
next(error);
}
});
/**
* DELETE /lotes/:id
* Eliminar lote
*/
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
await loteService.delete(req.params.id, tenantId, req.user?.sub);
res.status(200).json({ success: true, message: 'Lot deleted' });
} catch (error) {
if (error instanceof Error) {
if (error.message === 'Lot not found') {
res.status(404).json({ error: 'Not Found', message: error.message });
return;
}
if (error.message === 'Cannot delete a sold lot') {
res.status(400).json({ error: 'Bad Request', message: error.message });
return;
}
}
next(error);
}
});
return router;
}
export default createLoteController;

View File

@ -0,0 +1,180 @@
/**
* ManzanaController - Controller de manzanas
*
* Endpoints REST para gestión de manzanas (bloques).
*
* @module Construction
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { ManzanaService, CreateManzanaDto, UpdateManzanaDto } from '../services/manzana.service';
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
import { AuthService } from '../../auth/services/auth.service';
import { Manzana } from '../entities/manzana.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
/**
* Crear router de manzanas
*/
export function createManzanaController(dataSource: DataSource): Router {
const router = Router();
// Repositorios
const manzanaRepository = dataSource.getRepository(Manzana);
const userRepository = dataSource.getRepository(User);
const tenantRepository = dataSource.getRepository(Tenant);
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
// Servicios
const manzanaService = new ManzanaService(manzanaRepository);
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
const authMiddleware = new AuthMiddleware(authService, dataSource);
/**
* GET /manzanas
* Listar manzanas
*/
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const search = req.query.search as string;
const etapaId = req.query.etapaId as string;
const result = await manzanaService.findAll({ tenantId, page, limit, search, etapaId });
res.status(200).json({
success: true,
data: result.items,
pagination: {
page,
limit,
total: result.total,
totalPages: Math.ceil(result.total / limit),
},
});
} catch (error) {
next(error);
}
});
/**
* GET /manzanas/:id
* Obtener manzana por ID
*/
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const manzana = await manzanaService.findById(req.params.id, tenantId);
if (!manzana) {
res.status(404).json({ error: 'Not Found', message: 'Block not found' });
return;
}
res.status(200).json({ success: true, data: manzana });
} catch (error) {
next(error);
}
});
/**
* POST /manzanas
* Crear manzana
*/
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const dto: CreateManzanaDto = req.body;
if (!dto.etapaId || !dto.code) {
res.status(400).json({ error: 'Bad Request', message: 'etapaId and code are required' });
return;
}
const manzana = await manzanaService.create(tenantId, dto, req.user?.sub);
res.status(201).json({ success: true, data: manzana });
} catch (error) {
if (error instanceof Error && error.message.includes('already exists')) {
res.status(409).json({ error: 'Conflict', message: error.message });
return;
}
next(error);
}
});
/**
* PATCH /manzanas/:id
* Actualizar manzana
*/
router.patch('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const dto: UpdateManzanaDto = req.body;
const manzana = await manzanaService.update(req.params.id, tenantId, dto, req.user?.sub);
res.status(200).json({ success: true, data: manzana });
} catch (error) {
if (error instanceof Error) {
if (error.message === 'Block not found') {
res.status(404).json({ error: 'Not Found', message: error.message });
return;
}
if (error.message.includes('already exists')) {
res.status(409).json({ error: 'Conflict', message: error.message });
return;
}
}
next(error);
}
});
/**
* DELETE /manzanas/:id
* Eliminar manzana
*/
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
await manzanaService.delete(req.params.id, tenantId, req.user?.sub);
res.status(200).json({ success: true, message: 'Block deleted' });
} catch (error) {
if (error instanceof Error && error.message === 'Block not found') {
res.status(404).json({ error: 'Not Found', message: error.message });
return;
}
next(error);
}
});
return router;
}
export default createManzanaController;

View File

@ -0,0 +1,181 @@
/**
* PrototipoController - Controller de prototipos
*
* Endpoints REST para gestión de prototipos de vivienda.
*
* @module Construction
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { PrototipoService, CreatePrototipoDto, UpdatePrototipoDto } from '../services/prototipo.service';
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
import { AuthService } from '../../auth/services/auth.service';
import { Prototipo } from '../entities/prototipo.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
/**
* Crear router de prototipos
*/
export function createPrototipoController(dataSource: DataSource): Router {
const router = Router();
// Repositorios
const prototipoRepository = dataSource.getRepository(Prototipo);
const userRepository = dataSource.getRepository(User);
const tenantRepository = dataSource.getRepository(Tenant);
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
// Servicios
const prototipoService = new PrototipoService(prototipoRepository);
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
const authMiddleware = new AuthMiddleware(authService, dataSource);
/**
* GET /prototipos
* Listar prototipos
*/
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const search = req.query.search as string;
const type = req.query.type as string;
const isActive = req.query.isActive === 'true' ? true : req.query.isActive === 'false' ? false : undefined;
const result = await prototipoService.findAll({ tenantId, page, limit, search, type, isActive });
res.status(200).json({
success: true,
data: result.items,
pagination: {
page,
limit,
total: result.total,
totalPages: Math.ceil(result.total / limit),
},
});
} catch (error) {
next(error);
}
});
/**
* GET /prototipos/:id
* Obtener prototipo por ID
*/
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const prototipo = await prototipoService.findById(req.params.id, tenantId);
if (!prototipo) {
res.status(404).json({ error: 'Not Found', message: 'Prototype not found' });
return;
}
res.status(200).json({ success: true, data: prototipo });
} catch (error) {
next(error);
}
});
/**
* POST /prototipos
* Crear prototipo
*/
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const dto: CreatePrototipoDto = req.body;
if (!dto.code || !dto.name) {
res.status(400).json({ error: 'Bad Request', message: 'Code and name are required' });
return;
}
const prototipo = await prototipoService.create(tenantId, dto, req.user?.sub);
res.status(201).json({ success: true, data: prototipo });
} catch (error) {
if (error instanceof Error && error.message === 'Prototype code already exists') {
res.status(409).json({ error: 'Conflict', message: error.message });
return;
}
next(error);
}
});
/**
* PATCH /prototipos/:id
* Actualizar prototipo
*/
router.patch('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const dto: UpdatePrototipoDto = req.body;
const prototipo = await prototipoService.update(req.params.id, tenantId, dto, req.user?.sub);
res.status(200).json({ success: true, data: prototipo });
} catch (error) {
if (error instanceof Error) {
if (error.message === 'Prototype not found') {
res.status(404).json({ error: 'Not Found', message: error.message });
return;
}
if (error.message === 'Prototype code already exists') {
res.status(409).json({ error: 'Conflict', message: error.message });
return;
}
}
next(error);
}
});
/**
* DELETE /prototipos/:id
* Eliminar prototipo
*/
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
await prototipoService.delete(req.params.id, tenantId, req.user?.sub);
res.status(200).json({ success: true, message: 'Prototype deleted' });
} catch (error) {
if (error instanceof Error && error.message === 'Prototype not found') {
res.status(404).json({ error: 'Not Found', message: error.message });
return;
}
next(error);
}
});
return router;
}
export default createPrototipoController;

View File

@ -0,0 +1,165 @@
/**
* Proyecto Controller
* API endpoints para gestión de proyectos
*
* @module Construction
* @prefix /api/v1/proyectos
*/
import { Router, Request, Response, NextFunction } from 'express';
import { ProyectoService, CreateProyectoDto, UpdateProyectoDto } from '../services/proyecto.service';
const router = Router();
const proyectoService = new ProyectoService();
/**
* GET /api/v1/proyectos
* Lista todos los proyectos del tenant
*/
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
return res.status(400).json({ error: 'X-Tenant-Id header required' });
}
const { estadoProyecto, ciudad } = req.query;
const proyectos = await proyectoService.findAll({
tenantId,
estadoProyecto: estadoProyecto as any,
ciudad: ciudad as string,
});
return res.json({
success: true,
data: proyectos,
count: proyectos.length,
});
} catch (error) {
return next(error);
}
});
/**
* GET /api/v1/proyectos/statistics
* Estadísticas de proyectos
*/
router.get('/statistics', async (req: Request, res: Response, next: NextFunction) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
return res.status(400).json({ error: 'X-Tenant-Id header required' });
}
const stats = await proyectoService.getStatistics(tenantId);
return res.json({ success: true, data: stats });
} catch (error) {
return next(error);
}
});
/**
* GET /api/v1/proyectos/:id
* Obtiene un proyecto por ID
*/
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
return res.status(400).json({ error: 'X-Tenant-Id header required' });
}
const proyecto = await proyectoService.findById(req.params.id, tenantId);
if (!proyecto) {
return res.status(404).json({ error: 'Proyecto no encontrado' });
}
return res.json({ success: true, data: proyecto });
} catch (error) {
return next(error);
}
});
/**
* POST /api/v1/proyectos
* Crea un nuevo proyecto
*/
router.post('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
return res.status(400).json({ error: 'X-Tenant-Id header required' });
}
const data: CreateProyectoDto = {
...req.body,
tenantId,
createdById: (req as any).user?.id,
};
// Validate required fields
if (!data.codigo || !data.nombre) {
return res.status(400).json({ error: 'codigo y nombre son requeridos' });
}
// Check if codigo already exists
const existing = await proyectoService.findByCodigo(data.codigo, tenantId);
if (existing) {
return res.status(409).json({ error: 'Ya existe un proyecto con ese código' });
}
const proyecto = await proyectoService.create(data);
return res.status(201).json({ success: true, data: proyecto });
} catch (error) {
return next(error);
}
});
/**
* PATCH /api/v1/proyectos/:id
* Actualiza un proyecto
*/
router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
return res.status(400).json({ error: 'X-Tenant-Id header required' });
}
const data: UpdateProyectoDto = req.body;
const proyecto = await proyectoService.update(req.params.id, tenantId, data);
if (!proyecto) {
return res.status(404).json({ error: 'Proyecto no encontrado' });
}
return res.json({ success: true, data: proyecto });
} catch (error) {
return next(error);
}
});
/**
* DELETE /api/v1/proyectos/:id
* Elimina un proyecto
*/
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
return res.status(400).json({ error: 'X-Tenant-Id header required' });
}
const deleted = await proyectoService.delete(req.params.id, tenantId);
if (!deleted) {
return res.status(404).json({ error: 'Proyecto no encontrado' });
}
return res.json({ success: true, message: 'Proyecto eliminado' });
} catch (error) {
return next(error);
}
});
export default router;

View File

@ -0,0 +1,83 @@
/**
* Etapa Entity
* Etapas/Fases de un fraccionamiento
*
* @module Construction
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
Index,
} from 'typeorm';
import { Fraccionamiento } from './fraccionamiento.entity';
import { Manzana } from './manzana.entity';
@Entity({ schema: 'construction', name: 'etapas' })
@Index(['fraccionamientoId', 'code'], { unique: true })
export class Etapa {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'fraccionamiento_id', type: 'uuid' })
fraccionamientoId: string;
@Column({ type: 'varchar', length: 20 })
code: string;
@Column({ type: 'varchar', length: 100 })
name: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ type: 'integer', default: 1 })
sequence: number;
@Column({ name: 'total_lots', type: 'integer', default: 0 })
totalLots: number;
@Column({ type: 'varchar', length: 50, default: 'draft' })
status: string;
@Column({ name: 'start_date', type: 'date', nullable: true })
startDate: Date;
@Column({ name: 'expected_end_date', type: 'date', nullable: true })
expectedEndDate: Date;
@Column({ name: 'actual_end_date', type: 'date', nullable: true })
actualEndDate: Date;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy: string;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true })
updatedAt: Date;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy: string;
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date;
// Relations
@ManyToOne(() => Fraccionamiento, (f) => f.etapas, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'fraccionamiento_id' })
fraccionamiento: Fraccionamiento;
@OneToMany(() => Manzana, (m) => m.etapa)
manzanas: Manzana[];
}

View File

@ -0,0 +1,95 @@
/**
* Fraccionamiento Entity
* Obras/fraccionamientos dentro de un proyecto
*
* @module Construction
* @table construction.fraccionamientos
* @ddl schemas/01-construction-schema-ddl.sql
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
Index,
} from 'typeorm';
import { Tenant } from '../../core/entities/tenant.entity';
import { User } from '../../core/entities/user.entity';
import { Proyecto } from './proyecto.entity';
import { Etapa } from './etapa.entity';
export type EstadoFraccionamiento = 'activo' | 'pausado' | 'completado' | 'cancelado';
@Entity({ schema: 'construction', name: 'fraccionamientos' })
@Index(['tenantId', 'codigo'], { unique: true })
export class Fraccionamiento {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'proyecto_id', type: 'uuid' })
proyectoId: string;
@Column({ type: 'varchar', length: 20 })
codigo: string;
@Column({ type: 'varchar', length: 200 })
nombre: string;
@Column({ type: 'text', nullable: true })
descripcion: string;
@Column({ type: 'text', nullable: true })
direccion: string;
// PostGIS geometry - stored as GeoJSON for TypeORM compatibility
@Column({
name: 'ubicacion_geo',
type: 'geometry',
spatialFeatureType: 'Point',
srid: 4326,
nullable: true
})
ubicacionGeo: string;
@Column({ name: 'fecha_inicio', type: 'date', nullable: true })
fechaInicio: Date;
@Column({ name: 'fecha_fin_estimada', type: 'date', nullable: true })
fechaFinEstimada: Date;
@Column({ type: 'varchar', length: 20, default: 'activo' })
estado: EstadoFraccionamiento;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdById: string;
// Relations
@ManyToOne(() => Tenant)
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@ManyToOne(() => Proyecto, (p) => p.fraccionamientos)
@JoinColumn({ name: 'proyecto_id' })
proyecto: Proyecto;
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by' })
createdBy: User;
@OneToMany(() => Etapa, (e) => e.fraccionamiento)
etapas: Etapa[];
}

View File

@ -0,0 +1,11 @@
/**
* Construction Entities Index
* @module Construction
*/
export { Proyecto } from './proyecto.entity';
export { Fraccionamiento } from './fraccionamiento.entity';
export { Etapa } from './etapa.entity';
export { Manzana } from './manzana.entity';
export { Lote } from './lote.entity';
export { Prototipo } from './prototipo.entity';

View File

@ -0,0 +1,92 @@
/**
* Lote Entity
* Lotes/Terrenos individuales
*
* @module Construction
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Manzana } from './manzana.entity';
import { Prototipo } from './prototipo.entity';
@Entity({ schema: 'construction', name: 'lotes' })
@Index(['manzanaId', 'code'], { unique: true })
export class Lote {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'manzana_id', type: 'uuid' })
manzanaId: string;
@Column({ name: 'prototipo_id', type: 'uuid', nullable: true })
prototipoId: string;
@Column({ type: 'varchar', length: 30 })
code: string;
@Column({ name: 'official_number', type: 'varchar', length: 50, nullable: true })
officialNumber: string;
@Column({ name: 'area_m2', type: 'decimal', precision: 10, scale: 2, nullable: true })
areaM2: number;
@Column({ name: 'front_m', type: 'decimal', precision: 8, scale: 2, nullable: true })
frontM: number;
@Column({ name: 'depth_m', type: 'decimal', precision: 8, scale: 2, nullable: true })
depthM: number;
@Column({ type: 'varchar', length: 50, default: 'available' })
status: string;
@Column({ name: 'price_base', type: 'decimal', precision: 14, scale: 2, nullable: true })
priceBase: number;
@Column({ name: 'price_final', type: 'decimal', precision: 14, scale: 2, nullable: true })
priceFinal: number;
@Column({ name: 'buyer_id', type: 'uuid', nullable: true })
buyerId: string;
@Column({ name: 'sale_date', type: 'date', nullable: true })
saleDate: Date;
@Column({ name: 'delivery_date', type: 'date', nullable: true })
deliveryDate: Date;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy: string;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true })
updatedAt: Date;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy: string;
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date;
// Relations
@ManyToOne(() => Manzana, (m) => m.lotes, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'manzana_id' })
manzana: Manzana;
@ManyToOne(() => Prototipo)
@JoinColumn({ name: 'prototipo_id' })
prototipo: Prototipo;
}

View File

@ -0,0 +1,65 @@
/**
* Manzana Entity
* Manzanas (bloques) dentro de una etapa
*
* @module Construction
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
Index,
} from 'typeorm';
import { Etapa } from './etapa.entity';
import { Lote } from './lote.entity';
@Entity({ schema: 'construction', name: 'manzanas' })
@Index(['etapaId', 'code'], { unique: true })
export class Manzana {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'etapa_id', type: 'uuid' })
etapaId: string;
@Column({ type: 'varchar', length: 20 })
code: string;
@Column({ type: 'varchar', length: 100, nullable: true })
name: string;
@Column({ name: 'total_lots', type: 'integer', default: 0 })
totalLots: number;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy: string;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true })
updatedAt: Date;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy: string;
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date;
// Relations
@ManyToOne(() => Etapa, (e) => e.manzanas, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'etapa_id' })
etapa: Etapa;
@OneToMany(() => Lote, (l) => l.manzana)
lotes: Lote[];
}

View File

@ -0,0 +1,85 @@
/**
* Prototipo Entity
* Prototipos de vivienda (modelos)
*
* @module Construction
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
@Entity({ schema: 'construction', name: 'prototipos' })
@Index(['tenantId', 'code'], { unique: true })
export class Prototipo {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ type: 'varchar', length: 20 })
code: string;
@Column({ type: 'varchar', length: 100 })
name: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ type: 'varchar', length: 50, default: 'horizontal' })
type: string;
@Column({ name: 'area_construction_m2', type: 'decimal', precision: 10, scale: 2, nullable: true })
areaConstructionM2: number;
@Column({ name: 'area_terrain_m2', type: 'decimal', precision: 10, scale: 2, nullable: true })
areaTerrainM2: number;
@Column({ type: 'integer', default: 0 })
bedrooms: number;
@Column({ type: 'decimal', precision: 3, scale: 1, default: 0 })
bathrooms: number;
@Column({ name: 'parking_spaces', type: 'integer', default: 0 })
parkingSpaces: number;
@Column({ type: 'integer', default: 1 })
floors: number;
@Column({ name: 'base_price', type: 'decimal', precision: 14, scale: 2, nullable: true })
basePrice: number;
@Column({ name: 'blueprint_url', type: 'varchar', length: 500, nullable: true })
blueprintUrl: string;
@Column({ name: 'render_url', type: 'varchar', length: 500, nullable: true })
renderUrl: string;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@Column({ type: 'jsonb', default: {} })
metadata: Record<string, unknown>;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy: string;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true })
updatedAt: Date;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy: string;
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date;
}

View File

@ -0,0 +1,88 @@
/**
* Proyecto Entity
* Proyectos de desarrollo inmobiliario
*
* @module Construction
* @table construction.proyectos
* @ddl schemas/01-construction-schema-ddl.sql
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
Index,
} from 'typeorm';
import { Tenant } from '../../core/entities/tenant.entity';
import { User } from '../../core/entities/user.entity';
import { Fraccionamiento } from './fraccionamiento.entity';
export type EstadoProyecto = 'activo' | 'pausado' | 'completado' | 'cancelado';
@Entity({ schema: 'construction', name: 'proyectos' })
@Index(['tenantId', 'codigo'], { unique: true })
export class Proyecto {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ type: 'varchar', length: 20 })
codigo: string;
@Column({ type: 'varchar', length: 200 })
nombre: string;
@Column({ type: 'text', nullable: true })
descripcion: string;
@Column({ type: 'text', nullable: true })
direccion: string;
@Column({ type: 'varchar', length: 100, nullable: true })
ciudad: string;
@Column({ type: 'varchar', length: 100, nullable: true })
estado: string;
@Column({ name: 'fecha_inicio', type: 'date', nullable: true })
fechaInicio: Date;
@Column({ name: 'fecha_fin_estimada', type: 'date', nullable: true })
fechaFinEstimada: Date;
@Column({
name: 'estado_proyecto',
type: 'varchar',
length: 20,
default: 'activo'
})
estadoProyecto: EstadoProyecto;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdById: string;
// Relations
@ManyToOne(() => Tenant)
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by' })
createdBy: User;
@OneToMany(() => Fraccionamiento, (f) => f.proyecto)
fraccionamientos: Fraccionamiento[];
}

View File

@ -0,0 +1,163 @@
/**
* EtapaService - Gestión de etapas de fraccionamientos
*
* CRUD de etapas con soporte multi-tenant.
*
* @module Construction
*/
import { Repository, IsNull } from 'typeorm';
import { Etapa } from '../entities/etapa.entity';
export interface CreateEtapaDto {
fraccionamientoId: string;
code: string;
name: string;
description?: string;
sequence?: number;
totalLots?: number;
status?: string;
startDate?: Date;
expectedEndDate?: Date;
}
export interface UpdateEtapaDto extends Partial<CreateEtapaDto> {
actualEndDate?: Date;
}
export interface EtapaListOptions {
tenantId: string;
fraccionamientoId?: string;
page?: number;
limit?: number;
search?: string;
status?: string;
}
export class EtapaService {
constructor(private readonly repository: Repository<Etapa>) {}
/**
* Listar etapas
*/
async findAll(options: EtapaListOptions): Promise<{ items: Etapa[]; total: number }> {
const { tenantId, fraccionamientoId, page = 1, limit = 20, search, status } = options;
const query = this.repository
.createQueryBuilder('e')
.where('e.tenant_id = :tenantId', { tenantId })
.andWhere('e.deleted_at IS NULL');
if (fraccionamientoId) {
query.andWhere('e.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
}
if (search) {
query.andWhere('(e.code ILIKE :search OR e.name ILIKE :search)', { search: `%${search}%` });
}
if (status) {
query.andWhere('e.status = :status', { status });
}
const total = await query.getCount();
const items = await query
.skip((page - 1) * limit)
.take(limit)
.orderBy('e.sequence', 'ASC')
.addOrderBy('e.name', 'ASC')
.getMany();
return { items, total };
}
/**
* Obtener etapa por ID
*/
async findById(id: string, tenantId: string): Promise<Etapa | null> {
return this.repository.findOne({
where: { id, tenantId, deletedAt: IsNull() } as any,
relations: ['manzanas'],
});
}
/**
* Obtener etapa por código dentro de un fraccionamiento
*/
async findByCode(code: string, fraccionamientoId: string, tenantId: string): Promise<Etapa | null> {
return this.repository.findOne({
where: { code, fraccionamientoId, tenantId, deletedAt: IsNull() } as any,
});
}
/**
* Crear etapa
*/
async create(tenantId: string, dto: CreateEtapaDto, createdBy?: string): Promise<Etapa> {
const existing = await this.findByCode(dto.code, dto.fraccionamientoId, tenantId);
if (existing) {
throw new Error('Stage code already exists in this fraccionamiento');
}
return this.repository.save(
this.repository.create({
tenantId,
...dto,
createdBy,
status: dto.status || 'draft',
})
);
}
/**
* Actualizar etapa
*/
async update(id: string, tenantId: string, dto: UpdateEtapaDto, updatedBy?: string): Promise<Etapa> {
const etapa = await this.findById(id, tenantId);
if (!etapa) {
throw new Error('Stage not found');
}
// Verificar código único si se está cambiando
if (dto.code && dto.code !== etapa.code) {
const existing = await this.findByCode(dto.code, etapa.fraccionamientoId, tenantId);
if (existing) {
throw new Error('Stage code already exists in this fraccionamiento');
}
}
await this.repository.update(id, {
...dto,
updatedBy,
updatedAt: new Date(),
});
return this.findById(id, tenantId) as Promise<Etapa>;
}
/**
* Eliminar etapa (soft delete)
*/
async delete(id: string, tenantId: string, _deletedBy?: string): Promise<void> {
const etapa = await this.findById(id, tenantId);
if (!etapa) {
throw new Error('Stage not found');
}
// TODO: Verificar si tiene manzanas antes de eliminar
await this.repository.update(id, {
deletedAt: new Date(),
});
}
/**
* Obtener etapas por fraccionamiento
*/
async findByFraccionamiento(fraccionamientoId: string, tenantId: string): Promise<Etapa[]> {
return this.repository.find({
where: { fraccionamientoId, tenantId, deletedAt: IsNull() } as any,
order: { sequence: 'ASC' },
});
}
}

View File

@ -0,0 +1,117 @@
/**
* Fraccionamiento Service
* Servicio para gestión de fraccionamientos/obras
*
* @module Construction
*/
import { Repository, FindOptionsWhere } from 'typeorm';
import { AppDataSource } from '../../../shared/database/typeorm.config';
import { Fraccionamiento, EstadoFraccionamiento } from '../entities/fraccionamiento.entity';
export interface CreateFraccionamientoDto {
tenantId: string;
proyectoId: string;
codigo: string;
nombre: string;
descripcion?: string;
direccion?: string;
ubicacionGeo?: string;
fechaInicio?: Date;
fechaFinEstimada?: Date;
createdById?: string;
}
export interface UpdateFraccionamientoDto {
nombre?: string;
descripcion?: string;
direccion?: string;
ubicacionGeo?: string;
fechaInicio?: Date;
fechaFinEstimada?: Date;
estado?: EstadoFraccionamiento;
}
export interface FraccionamientoFilters {
tenantId: string;
proyectoId?: string;
estado?: EstadoFraccionamiento;
}
export class FraccionamientoService {
private repository: Repository<Fraccionamiento>;
constructor() {
this.repository = AppDataSource.getRepository(Fraccionamiento);
}
async findAll(filters: FraccionamientoFilters): Promise<Fraccionamiento[]> {
const where: FindOptionsWhere<Fraccionamiento> = {
tenantId: filters.tenantId,
};
if (filters.proyectoId) {
where.proyectoId = filters.proyectoId;
}
if (filters.estado) {
where.estado = filters.estado;
}
return this.repository.find({
where,
relations: ['proyecto'],
order: { createdAt: 'DESC' },
});
}
async findById(id: string, tenantId: string): Promise<Fraccionamiento | null> {
return this.repository.findOne({
where: { id, tenantId },
relations: ['proyecto', 'createdBy'],
});
}
async findByCodigo(codigo: string, tenantId: string): Promise<Fraccionamiento | null> {
return this.repository.findOne({
where: { codigo, tenantId },
});
}
async findByProyecto(proyectoId: string, tenantId: string): Promise<Fraccionamiento[]> {
return this.repository.find({
where: { proyectoId, tenantId },
order: { codigo: 'ASC' },
});
}
async create(data: CreateFraccionamientoDto): Promise<Fraccionamiento> {
const fraccionamiento = this.repository.create(data);
return this.repository.save(fraccionamiento);
}
async update(
id: string,
tenantId: string,
data: UpdateFraccionamientoDto
): Promise<Fraccionamiento | null> {
const fraccionamiento = await this.findById(id, tenantId);
if (!fraccionamiento) {
return null;
}
Object.assign(fraccionamiento, data);
return this.repository.save(fraccionamiento);
}
async delete(id: string, tenantId: string): Promise<boolean> {
const result = await this.repository.delete({ id, tenantId });
return result.affected ? result.affected > 0 : false;
}
async countByProyecto(proyectoId: string, tenantId: string): Promise<number> {
return this.repository.count({
where: { proyectoId, tenantId },
});
}
}

View File

@ -0,0 +1,11 @@
/**
* Construction Services Index
* @module Construction
*/
export * from './proyecto.service';
export * from './fraccionamiento.service';
export * from './etapa.service';
export * from './manzana.service';
export * from './lote.service';
export * from './prototipo.service';

View File

@ -0,0 +1,230 @@
/**
* LoteService - Gestión de lotes/terrenos
*
* CRUD de lotes con soporte multi-tenant.
*
* @module Construction
*/
import { Repository, IsNull } from 'typeorm';
import { Lote } from '../entities/lote.entity';
export interface CreateLoteDto {
manzanaId: string;
prototipoId?: string;
code: string;
officialNumber?: string;
areaM2?: number;
frontM?: number;
depthM?: number;
status?: string;
priceBase?: number;
priceFinal?: number;
}
export interface UpdateLoteDto extends Partial<CreateLoteDto> {
buyerId?: string;
saleDate?: Date;
deliveryDate?: Date;
}
export interface LoteListOptions {
tenantId: string;
manzanaId?: string;
prototipoId?: string;
page?: number;
limit?: number;
search?: string;
status?: string;
}
export class LoteService {
constructor(private readonly repository: Repository<Lote>) {}
/**
* Listar lotes
*/
async findAll(options: LoteListOptions): Promise<{ items: Lote[]; total: number }> {
const { tenantId, manzanaId, prototipoId, page = 1, limit = 20, search, status } = options;
const query = this.repository
.createQueryBuilder('l')
.where('l.tenant_id = :tenantId', { tenantId })
.andWhere('l.deleted_at IS NULL');
if (manzanaId) {
query.andWhere('l.manzana_id = :manzanaId', { manzanaId });
}
if (prototipoId) {
query.andWhere('l.prototipo_id = :prototipoId', { prototipoId });
}
if (search) {
query.andWhere('(l.code ILIKE :search OR l.official_number ILIKE :search)', { search: `%${search}%` });
}
if (status) {
query.andWhere('l.status = :status', { status });
}
const total = await query.getCount();
const items = await query
.leftJoinAndSelect('l.prototipo', 'prototipo')
.skip((page - 1) * limit)
.take(limit)
.orderBy('l.code', 'ASC')
.getMany();
return { items, total };
}
/**
* Obtener lote por ID
*/
async findById(id: string, tenantId: string): Promise<Lote | null> {
return this.repository.findOne({
where: { id, tenantId, deletedAt: IsNull() } as any,
relations: ['prototipo', 'manzana'],
});
}
/**
* Obtener lote por código dentro de una manzana
*/
async findByCode(code: string, manzanaId: string, tenantId: string): Promise<Lote | null> {
return this.repository.findOne({
where: { code, manzanaId, tenantId, deletedAt: IsNull() } as any,
});
}
/**
* Crear lote
*/
async create(tenantId: string, dto: CreateLoteDto, createdBy?: string): Promise<Lote> {
const existing = await this.findByCode(dto.code, dto.manzanaId, tenantId);
if (existing) {
throw new Error('Lot code already exists in this block');
}
return this.repository.save(
this.repository.create({
tenantId,
...dto,
createdBy,
status: dto.status || 'available',
})
);
}
/**
* Actualizar lote
*/
async update(id: string, tenantId: string, dto: UpdateLoteDto, updatedBy?: string): Promise<Lote> {
const lote = await this.findById(id, tenantId);
if (!lote) {
throw new Error('Lot not found');
}
// Verificar código único si se está cambiando
if (dto.code && dto.code !== lote.code) {
const existing = await this.findByCode(dto.code, lote.manzanaId, tenantId);
if (existing) {
throw new Error('Lot code already exists in this block');
}
}
await this.repository.update(id, {
...dto,
updatedBy,
updatedAt: new Date(),
});
return this.findById(id, tenantId) as Promise<Lote>;
}
/**
* Eliminar lote (soft delete)
*/
async delete(id: string, tenantId: string, _deletedBy?: string): Promise<void> {
const lote = await this.findById(id, tenantId);
if (!lote) {
throw new Error('Lot not found');
}
// Verificar que no esté vendido
if (lote.status === 'sold') {
throw new Error('Cannot delete a sold lot');
}
await this.repository.update(id, {
deletedAt: new Date(),
});
}
/**
* Obtener lotes por manzana
*/
async findByManzana(manzanaId: string, tenantId: string): Promise<Lote[]> {
return this.repository.find({
where: { manzanaId, tenantId, deletedAt: IsNull() } as any,
order: { code: 'ASC' },
relations: ['prototipo'],
});
}
/**
* Asignar prototipo a lote
*/
async assignPrototipo(id: string, tenantId: string, prototipoId: string, updatedBy?: string): Promise<Lote> {
const lote = await this.findById(id, tenantId);
if (!lote) {
throw new Error('Lot not found');
}
await this.repository.update(id, {
prototipoId,
updatedBy,
updatedAt: new Date(),
});
return this.findById(id, tenantId) as Promise<Lote>;
}
/**
* Cambiar estado del lote
*/
async changeStatus(id: string, tenantId: string, status: string, updatedBy?: string): Promise<Lote> {
const lote = await this.findById(id, tenantId);
if (!lote) {
throw new Error('Lot not found');
}
await this.repository.update(id, {
status,
updatedBy,
updatedAt: new Date(),
});
return this.findById(id, tenantId) as Promise<Lote>;
}
/**
* Obtener estadísticas de lotes por estado
*/
async getStatsByStatus(tenantId: string, manzanaId?: string): Promise<{ status: string; count: number }[]> {
const query = this.repository
.createQueryBuilder('l')
.select('l.status', 'status')
.addSelect('COUNT(*)', 'count')
.where('l.tenant_id = :tenantId', { tenantId })
.andWhere('l.deleted_at IS NULL')
.groupBy('l.status');
if (manzanaId) {
query.andWhere('l.manzana_id = :manzanaId', { manzanaId });
}
return query.getRawMany();
}
}

View File

@ -0,0 +1,149 @@
/**
* ManzanaService - Gestión de manzanas (bloques)
*
* CRUD de manzanas con soporte multi-tenant.
*
* @module Construction
*/
import { Repository, IsNull } from 'typeorm';
import { Manzana } from '../entities/manzana.entity';
export interface CreateManzanaDto {
etapaId: string;
code: string;
name?: string;
totalLots?: number;
}
export interface UpdateManzanaDto extends Partial<CreateManzanaDto> {}
export interface ManzanaListOptions {
tenantId: string;
etapaId?: string;
page?: number;
limit?: number;
search?: string;
}
export class ManzanaService {
constructor(private readonly repository: Repository<Manzana>) {}
/**
* Listar manzanas
*/
async findAll(options: ManzanaListOptions): Promise<{ items: Manzana[]; total: number }> {
const { tenantId, etapaId, page = 1, limit = 20, search } = options;
const query = this.repository
.createQueryBuilder('m')
.where('m.tenant_id = :tenantId', { tenantId })
.andWhere('m.deleted_at IS NULL');
if (etapaId) {
query.andWhere('m.etapa_id = :etapaId', { etapaId });
}
if (search) {
query.andWhere('(m.code ILIKE :search OR m.name ILIKE :search)', { search: `%${search}%` });
}
const total = await query.getCount();
const items = await query
.skip((page - 1) * limit)
.take(limit)
.orderBy('m.code', 'ASC')
.getMany();
return { items, total };
}
/**
* Obtener manzana por ID
*/
async findById(id: string, tenantId: string): Promise<Manzana | null> {
return this.repository.findOne({
where: { id, tenantId, deletedAt: IsNull() } as any,
relations: ['lotes'],
});
}
/**
* Obtener manzana por código dentro de una etapa
*/
async findByCode(code: string, etapaId: string, tenantId: string): Promise<Manzana | null> {
return this.repository.findOne({
where: { code, etapaId, tenantId, deletedAt: IsNull() } as any,
});
}
/**
* Crear manzana
*/
async create(tenantId: string, dto: CreateManzanaDto, createdBy?: string): Promise<Manzana> {
const existing = await this.findByCode(dto.code, dto.etapaId, tenantId);
if (existing) {
throw new Error('Block code already exists in this stage');
}
return this.repository.save(
this.repository.create({
tenantId,
...dto,
createdBy,
})
);
}
/**
* Actualizar manzana
*/
async update(id: string, tenantId: string, dto: UpdateManzanaDto, updatedBy?: string): Promise<Manzana> {
const manzana = await this.findById(id, tenantId);
if (!manzana) {
throw new Error('Block not found');
}
// Verificar código único si se está cambiando
if (dto.code && dto.code !== manzana.code) {
const existing = await this.findByCode(dto.code, manzana.etapaId, tenantId);
if (existing) {
throw new Error('Block code already exists in this stage');
}
}
await this.repository.update(id, {
...dto,
updatedBy,
updatedAt: new Date(),
});
return this.findById(id, tenantId) as Promise<Manzana>;
}
/**
* Eliminar manzana (soft delete)
*/
async delete(id: string, tenantId: string, _deletedBy?: string): Promise<void> {
const manzana = await this.findById(id, tenantId);
if (!manzana) {
throw new Error('Block not found');
}
// TODO: Verificar si tiene lotes antes de eliminar
await this.repository.update(id, {
deletedAt: new Date(),
});
}
/**
* Obtener manzanas por etapa
*/
async findByEtapa(etapaId: string, tenantId: string): Promise<Manzana[]> {
return this.repository.find({
where: { etapaId, tenantId, deletedAt: IsNull() } as any,
order: { code: 'ASC' },
});
}
}

View File

@ -0,0 +1,173 @@
/**
* PrototipoService - Gestión de prototipos de vivienda
*
* CRUD de prototipos con soporte multi-tenant.
*
* @module Construction
*/
import { Repository, IsNull } from 'typeorm';
import { Prototipo } from '../entities/prototipo.entity';
export interface CreatePrototipoDto {
code: string;
name: string;
description?: string;
type?: string;
areaConstructionM2?: number;
areaTerrainM2?: number;
bedrooms?: number;
bathrooms?: number;
parkingSpaces?: number;
floors?: number;
basePrice?: number;
blueprintUrl?: string;
renderUrl?: string;
metadata?: Record<string, unknown>;
}
export interface UpdatePrototipoDto extends Partial<CreatePrototipoDto> {
isActive?: boolean;
}
export interface PrototipoListOptions {
tenantId: string;
page?: number;
limit?: number;
search?: string;
type?: string;
isActive?: boolean;
}
export class PrototipoService {
constructor(private readonly repository: Repository<Prototipo>) {}
/**
* Listar prototipos
*/
async findAll(options: PrototipoListOptions): Promise<{ items: Prototipo[]; total: number }> {
const { tenantId, page = 1, limit = 20, search, type, isActive } = options;
const query = this.repository
.createQueryBuilder('p')
.where('p.tenant_id = :tenantId', { tenantId })
.andWhere('p.deleted_at IS NULL');
if (search) {
query.andWhere('(p.code ILIKE :search OR p.name ILIKE :search)', { search: `%${search}%` });
}
if (type) {
query.andWhere('p.type = :type', { type });
}
if (isActive !== undefined) {
query.andWhere('p.is_active = :isActive', { isActive });
}
const total = await query.getCount();
const items = await query
.skip((page - 1) * limit)
.take(limit)
.orderBy('p.name', 'ASC')
.getMany();
return { items, total };
}
/**
* Obtener prototipo por ID
*/
async findById(id: string, tenantId: string): Promise<Prototipo | null> {
return this.repository.findOne({
where: { id, tenantId, deletedAt: IsNull() } as any,
});
}
/**
* Obtener prototipo por código
*/
async findByCode(code: string, tenantId: string): Promise<Prototipo | null> {
return this.repository.findOne({
where: { code, tenantId, deletedAt: IsNull() } as any,
});
}
/**
* Crear prototipo
*/
async create(tenantId: string, dto: CreatePrototipoDto, createdBy?: string): Promise<Prototipo> {
const existing = await this.findByCode(dto.code, tenantId);
if (existing) {
throw new Error('Prototype code already exists');
}
return this.repository.save(
this.repository.create({
tenantId,
...dto,
createdBy,
isActive: true,
})
);
}
/**
* Actualizar prototipo
*/
async update(id: string, tenantId: string, dto: UpdatePrototipoDto, updatedBy?: string): Promise<Prototipo> {
const prototipo = await this.findById(id, tenantId);
if (!prototipo) {
throw new Error('Prototype not found');
}
// Verificar código único si se está cambiando
if (dto.code && dto.code !== prototipo.code) {
const existing = await this.findByCode(dto.code, tenantId);
if (existing) {
throw new Error('Prototype code already exists');
}
}
// Exclude metadata from spread to avoid TypeORM type issues
const { metadata, ...updateData } = dto;
await this.repository.update(id, {
...updateData,
...(metadata && { metadata: metadata as any }),
updatedBy,
updatedAt: new Date(),
});
return this.findById(id, tenantId) as Promise<Prototipo>;
}
/**
* Eliminar prototipo (soft delete)
*/
async delete(id: string, tenantId: string, _deletedBy?: string): Promise<void> {
const prototipo = await this.findById(id, tenantId);
if (!prototipo) {
throw new Error('Prototype not found');
}
// TODO: Verificar si está asignado a lotes antes de eliminar
await this.repository.update(id, {
deletedAt: new Date(),
isActive: false,
});
}
/**
* Activar/Desactivar prototipo
*/
async setActive(id: string, tenantId: string, isActive: boolean): Promise<Prototipo> {
const prototipo = await this.findById(id, tenantId);
if (!prototipo) {
throw new Error('Prototype not found');
}
await this.repository.update(id, { isActive });
return this.findById(id, tenantId) as Promise<Prototipo>;
}
}

View File

@ -0,0 +1,117 @@
/**
* Proyecto Service
* Servicio para gestión de proyectos de construcción
*
* @module Construction
*/
import { Repository, FindOptionsWhere } from 'typeorm';
import { AppDataSource } from '../../../shared/database/typeorm.config';
import { Proyecto, EstadoProyecto } from '../entities/proyecto.entity';
export interface CreateProyectoDto {
tenantId: string;
codigo: string;
nombre: string;
descripcion?: string;
direccion?: string;
ciudad?: string;
estado?: string;
fechaInicio?: Date;
fechaFinEstimada?: Date;
createdById?: string;
}
export interface UpdateProyectoDto {
nombre?: string;
descripcion?: string;
direccion?: string;
ciudad?: string;
estado?: string;
fechaInicio?: Date;
fechaFinEstimada?: Date;
estadoProyecto?: EstadoProyecto;
}
export interface ProyectoFilters {
tenantId: string;
estadoProyecto?: EstadoProyecto;
ciudad?: string;
}
export class ProyectoService {
private repository: Repository<Proyecto>;
constructor() {
this.repository = AppDataSource.getRepository(Proyecto);
}
async findAll(filters: ProyectoFilters): Promise<Proyecto[]> {
const where: FindOptionsWhere<Proyecto> = {
tenantId: filters.tenantId,
};
if (filters.estadoProyecto) {
where.estadoProyecto = filters.estadoProyecto;
}
if (filters.ciudad) {
where.ciudad = filters.ciudad;
}
return this.repository.find({
where,
relations: ['fraccionamientos'],
order: { createdAt: 'DESC' },
});
}
async findById(id: string, tenantId: string): Promise<Proyecto | null> {
return this.repository.findOne({
where: { id, tenantId },
relations: ['fraccionamientos', 'createdBy'],
});
}
async findByCodigo(codigo: string, tenantId: string): Promise<Proyecto | null> {
return this.repository.findOne({
where: { codigo, tenantId },
});
}
async create(data: CreateProyectoDto): Promise<Proyecto> {
const proyecto = this.repository.create(data);
return this.repository.save(proyecto);
}
async update(id: string, tenantId: string, data: UpdateProyectoDto): Promise<Proyecto | null> {
const proyecto = await this.findById(id, tenantId);
if (!proyecto) {
return null;
}
Object.assign(proyecto, data);
return this.repository.save(proyecto);
}
async delete(id: string, tenantId: string): Promise<boolean> {
const result = await this.repository.delete({ id, tenantId });
return result.affected ? result.affected > 0 : false;
}
async getStatistics(tenantId: string): Promise<{
total: number;
activos: number;
completados: number;
pausados: number;
}> {
const proyectos = await this.repository.find({ where: { tenantId } });
return {
total: proyectos.length,
activos: proyectos.filter(p => p.estadoProyecto === 'activo').length,
completados: proyectos.filter(p => p.estadoProyecto === 'completado').length,
pausados: proyectos.filter(p => p.estadoProyecto === 'pausado').length,
};
}
}

View File

@ -0,0 +1,415 @@
/**
* ContractController - REST API for contracts
*
* Endpoints para gestión de contratos.
*
* @module Contracts
* @routes /api/contracts
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { ContractService, ContractFilters } from '../services/contract.service';
import { Contract } from '../entities/contract.entity';
import { ContractAddendum } from '../entities/contract-addendum.entity';
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
import { AuthService } from '../../auth/services/auth.service';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
export function createContractController(dataSource: DataSource): Router {
const router = Router();
// Repositories
const contractRepo = dataSource.getRepository(Contract);
const addendumRepo = dataSource.getRepository(ContractAddendum);
const userRepository = dataSource.getRepository(User);
const tenantRepository = dataSource.getRepository(Tenant);
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
// Services
const service = new ContractService(contractRepo, addendumRepo);
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
const authMiddleware = new AuthMiddleware(authService, dataSource);
// Helper for service context
const getContext = (req: Request): ServiceContext => {
if (!req.tenantId) {
throw new Error('Tenant ID is required');
}
return {
tenantId: req.tenantId,
userId: req.user?.sub,
};
};
/**
* GET /api/contracts
* List contracts with filters
*/
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const filters: ContractFilters = {};
if (req.query.projectId) filters.projectId = req.query.projectId as string;
if (req.query.fraccionamientoId) filters.fraccionamientoId = req.query.fraccionamientoId as string;
if (req.query.contractType) filters.contractType = req.query.contractType as ContractFilters['contractType'];
if (req.query.subcontractorId) filters.subcontractorId = req.query.subcontractorId as string;
if (req.query.status) filters.status = req.query.status as ContractFilters['status'];
if (req.query.expiringInDays) filters.expiringInDays = parseInt(req.query.expiringInDays as string);
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const result = await service.findWithFilters(getContext(req), filters, page, limit);
res.status(200).json({ success: true, data: result.data, pagination: result.meta });
} catch (error) {
next(error);
}
});
/**
* GET /api/contracts/expiring
* Get contracts expiring soon
*/
router.get('/expiring', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const days = parseInt(req.query.days as string) || 30;
const contracts = await service.getExpiringContracts(getContext(req), days);
res.status(200).json({ success: true, data: contracts });
} catch (error) {
next(error);
}
});
/**
* GET /api/contracts/:id
* Get contract with details
*/
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const contract = await service.findWithDetails(getContext(req), req.params.id);
if (!contract) {
res.status(404).json({ error: 'Not Found', message: 'Contract not found' });
return;
}
res.status(200).json({ success: true, data: contract });
} catch (error) {
next(error);
}
});
/**
* POST /api/contracts
* Create new contract
*/
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'legal', 'contracts'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const contract = await service.create(getContext(req), {
projectId: req.body.projectId,
fraccionamientoId: req.body.fraccionamientoId,
contractType: req.body.contractType,
clientContractType: req.body.clientContractType,
name: req.body.name,
description: req.body.description,
clientName: req.body.clientName,
clientRfc: req.body.clientRfc,
clientAddress: req.body.clientAddress,
subcontractorId: req.body.subcontractorId,
specialty: req.body.specialty,
startDate: new Date(req.body.startDate),
endDate: new Date(req.body.endDate),
contractAmount: req.body.contractAmount,
currency: req.body.currency,
paymentTerms: req.body.paymentTerms,
retentionPercentage: req.body.retentionPercentage,
advancePercentage: req.body.advancePercentage,
notes: req.body.notes,
});
res.status(201).json({ success: true, data: contract });
} catch (error) {
next(error);
}
});
/**
* POST /api/contracts/:id/submit
* Submit contract for review
*/
router.post('/:id/submit', authMiddleware.authenticate, authMiddleware.authorize('admin', 'legal', 'contracts'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const contract = await service.submitForReview(getContext(req), req.params.id);
if (!contract) {
res.status(404).json({ error: 'Not Found', message: 'Contract not found' });
return;
}
res.status(200).json({ success: true, data: contract });
} catch (error) {
next(error);
}
});
/**
* POST /api/contracts/:id/approve-legal
* Legal approval
*/
router.post('/:id/approve-legal', authMiddleware.authenticate, authMiddleware.authorize('admin', 'legal'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const contract = await service.approveLegal(getContext(req), req.params.id);
if (!contract) {
res.status(404).json({ error: 'Not Found', message: 'Contract not found' });
return;
}
res.status(200).json({ success: true, data: contract });
} catch (error) {
next(error);
}
});
/**
* POST /api/contracts/:id/approve
* Final approval
*/
router.post('/:id/approve', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const contract = await service.approve(getContext(req), req.params.id);
if (!contract) {
res.status(404).json({ error: 'Not Found', message: 'Contract not found' });
return;
}
res.status(200).json({ success: true, data: contract });
} catch (error) {
next(error);
}
});
/**
* POST /api/contracts/:id/activate
* Activate signed contract
*/
router.post('/:id/activate', authMiddleware.authenticate, authMiddleware.authorize('admin', 'legal', 'contracts'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const contract = await service.activate(getContext(req), req.params.id, req.body.signedDocumentUrl);
if (!contract) {
res.status(404).json({ error: 'Not Found', message: 'Contract not found' });
return;
}
res.status(200).json({ success: true, data: contract });
} catch (error) {
next(error);
}
});
/**
* POST /api/contracts/:id/complete
* Mark contract as completed
*/
router.post('/:id/complete', authMiddleware.authenticate, authMiddleware.authorize('admin', 'contracts'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const contract = await service.complete(getContext(req), req.params.id);
if (!contract) {
res.status(404).json({ error: 'Not Found', message: 'Contract not found' });
return;
}
res.status(200).json({ success: true, data: contract });
} catch (error) {
next(error);
}
});
/**
* POST /api/contracts/:id/terminate
* Terminate contract
*/
router.post('/:id/terminate', authMiddleware.authenticate, authMiddleware.authorize('admin', 'legal', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const contract = await service.terminate(getContext(req), req.params.id, req.body.reason);
if (!contract) {
res.status(404).json({ error: 'Not Found', message: 'Contract not found' });
return;
}
res.status(200).json({ success: true, data: contract });
} catch (error) {
next(error);
}
});
/**
* PUT /api/contracts/:id/progress
* Update contract progress
*/
router.put('/:id/progress', authMiddleware.authenticate, authMiddleware.authorize('admin', 'contracts', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const contract = await service.updateProgress(
getContext(req),
req.params.id,
req.body.progressPercentage,
req.body.invoicedAmount,
req.body.paidAmount
);
if (!contract) {
res.status(404).json({ error: 'Not Found', message: 'Contract not found' });
return;
}
res.status(200).json({ success: true, data: contract });
} catch (error) {
next(error);
}
});
/**
* POST /api/contracts/:id/addendums
* Create contract addendum
*/
router.post('/:id/addendums', authMiddleware.authenticate, authMiddleware.authorize('admin', 'legal', 'contracts'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const addendum = await service.createAddendum(getContext(req), req.params.id, {
addendumType: req.body.addendumType,
title: req.body.title,
description: req.body.description,
effectiveDate: new Date(req.body.effectiveDate),
newEndDate: req.body.newEndDate ? new Date(req.body.newEndDate) : undefined,
amountChange: req.body.amountChange,
scopeChanges: req.body.scopeChanges,
notes: req.body.notes,
});
res.status(201).json({ success: true, data: addendum });
} catch (error) {
next(error);
}
});
/**
* POST /api/contracts/addendums/:addendumId/approve
* Approve addendum
*/
router.post('/addendums/:addendumId/approve', authMiddleware.authenticate, authMiddleware.authorize('admin', 'legal', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const addendum = await service.approveAddendum(getContext(req), req.params.addendumId);
if (!addendum) {
res.status(404).json({ error: 'Not Found', message: 'Addendum not found' });
return;
}
res.status(200).json({ success: true, data: addendum });
} catch (error) {
next(error);
}
});
/**
* DELETE /api/contracts/:id
* Soft delete contract
*/
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const deleted = await service.softDelete(getContext(req), req.params.id);
if (!deleted) {
res.status(404).json({ error: 'Not Found', message: 'Contract not found' });
return;
}
res.status(204).send();
} catch (error) {
next(error);
}
});
return router;
}

View File

@ -0,0 +1,7 @@
/**
* Contracts Controllers Index
* @module Contracts
*/
export * from './contract.controller';
export * from './subcontractor.controller';

View File

@ -0,0 +1,257 @@
/**
* SubcontractorController - REST API for subcontractors
*
* Endpoints para gestión de subcontratistas.
*
* @module Contracts
* @routes /api/subcontractors
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { SubcontractorService, SubcontractorFilters } from '../services/subcontractor.service';
import { Subcontractor } from '../entities/subcontractor.entity';
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
import { AuthService } from '../../auth/services/auth.service';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
export function createSubcontractorController(dataSource: DataSource): Router {
const router = Router();
// Repositories
const subcontractorRepo = dataSource.getRepository(Subcontractor);
const userRepository = dataSource.getRepository(User);
const tenantRepository = dataSource.getRepository(Tenant);
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
// Services
const service = new SubcontractorService(subcontractorRepo);
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
const authMiddleware = new AuthMiddleware(authService, dataSource);
// Helper for service context
const getContext = (req: Request): ServiceContext => {
if (!req.tenantId) {
throw new Error('Tenant ID is required');
}
return {
tenantId: req.tenantId,
userId: req.user?.sub,
};
};
/**
* GET /api/subcontractors
* List subcontractors with filters
*/
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const filters: SubcontractorFilters = {};
if (req.query.specialty) filters.specialty = req.query.specialty as SubcontractorFilters['specialty'];
if (req.query.status) filters.status = req.query.status as SubcontractorFilters['status'];
if (req.query.search) filters.search = req.query.search as string;
if (req.query.minRating) filters.minRating = parseFloat(req.query.minRating as string);
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const result = await service.findWithFilters(getContext(req), filters, page, limit);
res.status(200).json({ success: true, data: result.data, pagination: result.meta });
} catch (error) {
next(error);
}
});
/**
* GET /api/subcontractors/specialty/:specialty
* Get subcontractors by specialty
*/
router.get('/specialty/:specialty', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const subcontractors = await service.getBySpecialty(getContext(req), req.params.specialty as any);
res.status(200).json({ success: true, data: subcontractors });
} catch (error) {
next(error);
}
});
/**
* GET /api/subcontractors/:id
* Get subcontractor by ID
*/
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const subcontractor = await service.findById(getContext(req), req.params.id);
if (!subcontractor) {
res.status(404).json({ error: 'Not Found', message: 'Subcontractor not found' });
return;
}
res.status(200).json({ success: true, data: subcontractor });
} catch (error) {
next(error);
}
});
/**
* POST /api/subcontractors
* Create new subcontractor
*/
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'contracts'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const subcontractor = await service.create(getContext(req), req.body);
res.status(201).json({ success: true, data: subcontractor });
} catch (error) {
next(error);
}
});
/**
* PUT /api/subcontractors/:id
* Update subcontractor
*/
router.put('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'contracts'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const subcontractor = await service.update(getContext(req), req.params.id, req.body);
if (!subcontractor) {
res.status(404).json({ error: 'Not Found', message: 'Subcontractor not found' });
return;
}
res.status(200).json({ success: true, data: subcontractor });
} catch (error) {
next(error);
}
});
/**
* POST /api/subcontractors/:id/rate
* Rate subcontractor
*/
router.post('/:id/rate', authMiddleware.authenticate, authMiddleware.authorize('admin', 'contracts', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const subcontractor = await service.updateRating(getContext(req), req.params.id, req.body.rating);
if (!subcontractor) {
res.status(404).json({ error: 'Not Found', message: 'Subcontractor not found' });
return;
}
res.status(200).json({ success: true, data: subcontractor });
} catch (error) {
next(error);
}
});
/**
* POST /api/subcontractors/:id/deactivate
* Deactivate subcontractor
*/
router.post('/:id/deactivate', authMiddleware.authenticate, authMiddleware.authorize('admin', 'contracts'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const subcontractor = await service.deactivate(getContext(req), req.params.id);
if (!subcontractor) {
res.status(404).json({ error: 'Not Found', message: 'Subcontractor not found' });
return;
}
res.status(200).json({ success: true, data: subcontractor });
} catch (error) {
next(error);
}
});
/**
* POST /api/subcontractors/:id/blacklist
* Blacklist subcontractor
*/
router.post('/:id/blacklist', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const subcontractor = await service.blacklist(getContext(req), req.params.id, req.body.reason);
if (!subcontractor) {
res.status(404).json({ error: 'Not Found', message: 'Subcontractor not found' });
return;
}
res.status(200).json({ success: true, data: subcontractor });
} catch (error) {
next(error);
}
});
/**
* DELETE /api/subcontractors/:id
* Soft delete subcontractor
*/
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const deleted = await service.softDelete(getContext(req), req.params.id);
if (!deleted) {
res.status(404).json({ error: 'Not Found', message: 'Subcontractor not found' });
return;
}
res.status(204).send();
} catch (error) {
next(error);
}
});
return router;
}

View File

@ -0,0 +1,114 @@
/**
* ContractAddendum Entity
* Addendas y modificaciones a contratos
*
* @module Contracts
* @table contracts.contract_addendums
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Tenant } from '../../core/entities/tenant.entity';
import { User } from '../../core/entities/user.entity';
import { Contract } from './contract.entity';
export type AddendumType = 'extension' | 'amount_increase' | 'amount_decrease' | 'scope_change' | 'termination' | 'other';
export type AddendumStatus = 'draft' | 'review' | 'approved' | 'rejected';
@Entity({ schema: 'contracts', name: 'contract_addendums' })
@Index(['tenantId', 'addendumNumber'], { unique: true })
export class ContractAddendum {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'contract_id', type: 'uuid' })
contractId: string;
@Column({ name: 'addendum_number', type: 'varchar', length: 50 })
addendumNumber: string;
@Column({ name: 'addendum_type', type: 'varchar', length: 30 })
addendumType: AddendumType;
@Column({ type: 'varchar', length: 255 })
title: string;
@Column({ type: 'text' })
description: string;
@Column({ name: 'effective_date', type: 'date' })
effectiveDate: Date;
// Changes
@Column({ name: 'new_end_date', type: 'date', nullable: true })
newEndDate: Date;
@Column({ name: 'amount_change', type: 'decimal', precision: 16, scale: 2, default: 0 })
amountChange: number;
@Column({ name: 'new_contract_amount', type: 'decimal', precision: 16, scale: 2, nullable: true })
newContractAmount: number;
@Column({ name: 'scope_changes', type: 'text', nullable: true })
scopeChanges: string;
// Status
@Column({ type: 'varchar', length: 20, default: 'draft' })
status: AddendumStatus;
@Column({ name: 'approved_at', type: 'timestamptz', nullable: true })
approvedAt: Date;
@Column({ name: 'approved_by', type: 'uuid', nullable: true })
approvedById: string;
@Column({ name: 'rejection_reason', type: 'text', nullable: true })
rejectionReason: string;
// Document
@Column({ name: 'document_url', type: 'varchar', length: 500, nullable: true })
documentUrl: string;
@Column({ type: 'text', nullable: true })
notes: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdById: string;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedById: string;
// Relations
@ManyToOne(() => Tenant)
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@ManyToOne(() => Contract, (c) => c.addendums, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'contract_id' })
contract: Contract;
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by' })
createdBy: User;
@ManyToOne(() => User)
@JoinColumn({ name: 'approved_by' })
approvedBy: User;
}

View File

@ -0,0 +1,192 @@
/**
* Contract Entity
* Contratos con clientes y subcontratistas
*
* @module Contracts
* @table contracts.contracts
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
Index,
} from 'typeorm';
import { Tenant } from '../../core/entities/tenant.entity';
import { User } from '../../core/entities/user.entity';
import { ContractAddendum } from './contract-addendum.entity';
export type ContractType = 'client' | 'subcontractor';
export type ContractStatus = 'draft' | 'review' | 'approved' | 'active' | 'completed' | 'terminated';
export type ClientContractType = 'desarrollo' | 'llave_en_mano' | 'administracion';
@Entity({ schema: 'contracts', name: 'contracts' })
@Index(['tenantId', 'contractNumber'], { unique: true })
export class Contract {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'project_id', type: 'uuid', nullable: true })
projectId: string;
@Column({ name: 'fraccionamiento_id', type: 'uuid', nullable: true })
fraccionamientoId: string;
@Column({ name: 'contract_number', type: 'varchar', length: 50 })
contractNumber: string;
@Column({ name: 'contract_type', type: 'varchar', length: 20 })
contractType: ContractType;
@Column({ name: 'client_contract_type', type: 'varchar', length: 30, nullable: true })
clientContractType: ClientContractType;
@Column({ type: 'varchar', length: 255 })
name: string;
@Column({ type: 'text', nullable: true })
description: string;
// Client info (for client contracts)
@Column({ name: 'client_name', type: 'varchar', length: 255, nullable: true })
clientName: string;
@Column({ name: 'client_rfc', type: 'varchar', length: 13, nullable: true })
clientRfc: string;
@Column({ name: 'client_address', type: 'text', nullable: true })
clientAddress: string;
// Subcontractor info (for subcontractor contracts)
@Column({ name: 'subcontractor_id', type: 'uuid', nullable: true })
subcontractorId: string;
@Column({ name: 'specialty', type: 'varchar', length: 50, nullable: true })
specialty: string;
// Contract terms
@Column({ name: 'start_date', type: 'date' })
startDate: Date;
@Column({ name: 'end_date', type: 'date' })
endDate: Date;
@Column({ name: 'contract_amount', type: 'decimal', precision: 16, scale: 2 })
contractAmount: number;
@Column({ type: 'varchar', length: 3, default: 'MXN' })
currency: string;
@Column({ name: 'payment_terms', type: 'text', nullable: true })
paymentTerms: string;
@Column({ name: 'retention_percentage', type: 'decimal', precision: 5, scale: 2, default: 5 })
retentionPercentage: number;
@Column({ name: 'advance_percentage', type: 'decimal', precision: 5, scale: 2, default: 0 })
advancePercentage: number;
// Status and workflow
@Column({ type: 'varchar', length: 20, default: 'draft' })
status: ContractStatus;
@Column({ name: 'submitted_at', type: 'timestamptz', nullable: true })
submittedAt: Date;
@Column({ name: 'submitted_by', type: 'uuid', nullable: true })
submittedById: string;
@Column({ name: 'legal_approved_at', type: 'timestamptz', nullable: true })
legalApprovedAt: Date;
@Column({ name: 'legal_approved_by', type: 'uuid', nullable: true })
legalApprovedById: string;
@Column({ name: 'approved_at', type: 'timestamptz', nullable: true })
approvedAt: Date;
@Column({ name: 'approved_by', type: 'uuid', nullable: true })
approvedById: string;
@Column({ name: 'signed_at', type: 'timestamptz', nullable: true })
signedAt: Date;
@Column({ name: 'terminated_at', type: 'timestamptz', nullable: true })
terminatedAt: Date;
@Column({ name: 'termination_reason', type: 'text', nullable: true })
terminationReason: string;
// Documents
@Column({ name: 'document_url', type: 'varchar', length: 500, nullable: true })
documentUrl: string;
@Column({ name: 'signed_document_url', type: 'varchar', length: 500, nullable: true })
signedDocumentUrl: string;
// Progress tracking
@Column({ name: 'progress_percentage', type: 'decimal', precision: 5, scale: 2, default: 0 })
progressPercentage: number;
@Column({ name: 'invoiced_amount', type: 'decimal', precision: 16, scale: 2, default: 0 })
invoicedAmount: number;
@Column({ name: 'paid_amount', type: 'decimal', precision: 16, scale: 2, default: 0 })
paidAmount: number;
@Column({ type: 'text', nullable: true })
notes: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdById: string;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedById: string;
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date;
@Column({ name: 'deleted_by', type: 'uuid', nullable: true })
deletedById: string;
// Computed properties
get remainingAmount(): number {
return Number(this.contractAmount) - Number(this.invoicedAmount);
}
get isExpiring(): boolean {
const thirtyDaysFromNow = new Date();
thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30);
return this.endDate <= thirtyDaysFromNow && this.status === 'active';
}
// Relations
@ManyToOne(() => Tenant)
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by' })
createdBy: User;
@ManyToOne(() => User)
@JoinColumn({ name: 'approved_by' })
approvedBy: User;
@OneToMany(() => ContractAddendum, (a) => a.contract)
addendums: ContractAddendum[];
}

View File

@ -0,0 +1,10 @@
/**
* Contracts Entities Index
* @module Contracts
*
* Gestión de contratos y subcontratos (MAI-012)
*/
export * from './contract.entity';
export * from './subcontractor.entity';
export * from './contract-addendum.entity';

View File

@ -0,0 +1,123 @@
/**
* Subcontractor Entity
* Catálogo de subcontratistas
*
* @module Contracts
* @table contracts.subcontractors
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Tenant } from '../../core/entities/tenant.entity';
import { User } from '../../core/entities/user.entity';
export type SubcontractorSpecialty = 'cimentacion' | 'estructura' | 'instalaciones_electricas' | 'instalaciones_hidraulicas' | 'acabados' | 'urbanizacion' | 'carpinteria' | 'herreria' | 'otros';
export type SubcontractorStatus = 'active' | 'inactive' | 'blacklisted';
@Entity({ schema: 'contracts', name: 'subcontractors' })
@Index(['tenantId', 'code'], { unique: true })
@Index(['tenantId', 'rfc'], { unique: true })
export class Subcontractor {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ type: 'varchar', length: 30 })
code: string;
@Column({ name: 'business_name', type: 'varchar', length: 255 })
businessName: string;
@Column({ name: 'trade_name', type: 'varchar', length: 255, nullable: true })
tradeName: string;
@Column({ type: 'varchar', length: 13 })
rfc: string;
@Column({ type: 'text', nullable: true })
address: string;
@Column({ type: 'varchar', length: 20, nullable: true })
phone: string;
@Column({ type: 'varchar', length: 255, nullable: true })
email: string;
@Column({ name: 'contact_name', type: 'varchar', length: 200, nullable: true })
contactName: string;
@Column({ name: 'contact_phone', type: 'varchar', length: 20, nullable: true })
contactPhone: string;
@Column({ name: 'primary_specialty', type: 'varchar', length: 50 })
primarySpecialty: SubcontractorSpecialty;
@Column({ name: 'secondary_specialties', type: 'simple-array', nullable: true })
secondarySpecialties: string[];
@Column({ type: 'varchar', length: 20, default: 'active' })
status: SubcontractorStatus;
// Performance tracking
@Column({ name: 'total_contracts', type: 'integer', default: 0 })
totalContracts: number;
@Column({ name: 'completed_contracts', type: 'integer', default: 0 })
completedContracts: number;
@Column({ name: 'average_rating', type: 'decimal', precision: 3, scale: 2, default: 0 })
averageRating: number;
@Column({ name: 'total_incidents', type: 'integer', default: 0 })
totalIncidents: number;
// Financial info
@Column({ name: 'bank_name', type: 'varchar', length: 100, nullable: true })
bankName: string;
@Column({ name: 'bank_account', type: 'varchar', length: 30, nullable: true })
bankAccount: string;
@Column({ type: 'varchar', length: 18, nullable: true })
clabe: string;
@Column({ type: 'text', nullable: true })
notes: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdById: string;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedById: string;
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date;
@Column({ name: 'deleted_by', type: 'uuid', nullable: true })
deletedById: string;
// Relations
@ManyToOne(() => Tenant)
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by' })
createdBy: User;
}

View File

@ -0,0 +1,422 @@
/**
* ContractService - Servicio de gestión de contratos
*
* Gestión de contratos con workflow de aprobación.
*
* @module Contracts
*/
import { Repository, FindOptionsWhere, LessThan } from 'typeorm';
import { Contract, ContractStatus, ContractType } from '../entities/contract.entity';
import { ContractAddendum } from '../entities/contract-addendum.entity';
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
export interface CreateContractDto {
projectId?: string;
fraccionamientoId?: string;
contractType: ContractType;
clientContractType?: string;
name: string;
description?: string;
clientName?: string;
clientRfc?: string;
clientAddress?: string;
subcontractorId?: string;
specialty?: string;
startDate: Date;
endDate: Date;
contractAmount: number;
currency?: string;
paymentTerms?: string;
retentionPercentage?: number;
advancePercentage?: number;
notes?: string;
}
export interface CreateAddendumDto {
addendumType: string;
title: string;
description: string;
effectiveDate: Date;
newEndDate?: Date;
amountChange?: number;
scopeChanges?: string;
notes?: string;
}
export interface ContractFilters {
projectId?: string;
fraccionamientoId?: string;
contractType?: ContractType;
subcontractorId?: string;
status?: ContractStatus;
expiringInDays?: number;
}
export class ContractService {
constructor(
private readonly contractRepository: Repository<Contract>,
private readonly addendumRepository: Repository<ContractAddendum>
) {}
private generateContractNumber(type: ContractType): string {
const now = new Date();
const year = now.getFullYear().toString().slice(-2);
const month = (now.getMonth() + 1).toString().padStart(2, '0');
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
const prefix = type === 'client' ? 'CTR' : 'SUB';
return `${prefix}-${year}${month}-${random}`;
}
private generateAddendumNumber(contractNumber: string, sequence: number): string {
return `${contractNumber}-ADD${sequence.toString().padStart(2, '0')}`;
}
async findWithFilters(
ctx: ServiceContext,
filters: ContractFilters = {},
page: number = 1,
limit: number = 20
): Promise<PaginatedResult<Contract>> {
const skip = (page - 1) * limit;
const queryBuilder = this.contractRepository
.createQueryBuilder('c')
.leftJoinAndSelect('c.createdBy', 'createdBy')
.leftJoinAndSelect('c.approvedBy', 'approvedBy')
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('c.deleted_at IS NULL');
if (filters.projectId) {
queryBuilder.andWhere('c.project_id = :projectId', { projectId: filters.projectId });
}
if (filters.fraccionamientoId) {
queryBuilder.andWhere('c.fraccionamiento_id = :fraccionamientoId', {
fraccionamientoId: filters.fraccionamientoId,
});
}
if (filters.contractType) {
queryBuilder.andWhere('c.contract_type = :contractType', { contractType: filters.contractType });
}
if (filters.subcontractorId) {
queryBuilder.andWhere('c.subcontractor_id = :subcontractorId', {
subcontractorId: filters.subcontractorId,
});
}
if (filters.status) {
queryBuilder.andWhere('c.status = :status', { status: filters.status });
}
if (filters.expiringInDays) {
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + filters.expiringInDays);
queryBuilder.andWhere('c.end_date <= :futureDate', { futureDate });
queryBuilder.andWhere('c.end_date >= :today', { today: new Date() });
queryBuilder.andWhere('c.status = :activeStatus', { activeStatus: 'active' });
}
queryBuilder
.orderBy('c.created_at', 'DESC')
.skip(skip)
.take(limit);
const [data, total] = await queryBuilder.getManyAndCount();
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
async findById(ctx: ServiceContext, id: string): Promise<Contract | null> {
return this.contractRepository.findOne({
where: {
id,
tenantId: ctx.tenantId,
deletedAt: null,
} as unknown as FindOptionsWhere<Contract>,
});
}
async findWithDetails(ctx: ServiceContext, id: string): Promise<Contract | null> {
return this.contractRepository.findOne({
where: {
id,
tenantId: ctx.tenantId,
deletedAt: null,
} as unknown as FindOptionsWhere<Contract>,
relations: ['createdBy', 'approvedBy', 'addendums'],
});
}
async create(ctx: ServiceContext, dto: CreateContractDto): Promise<Contract> {
const contract = this.contractRepository.create({
tenantId: ctx.tenantId,
createdById: ctx.userId,
contractNumber: this.generateContractNumber(dto.contractType),
projectId: dto.projectId,
fraccionamientoId: dto.fraccionamientoId,
contractType: dto.contractType,
clientContractType: dto.clientContractType as any,
name: dto.name,
description: dto.description,
clientName: dto.clientName,
clientRfc: dto.clientRfc?.toUpperCase(),
clientAddress: dto.clientAddress,
subcontractorId: dto.subcontractorId,
specialty: dto.specialty,
startDate: dto.startDate,
endDate: dto.endDate,
contractAmount: dto.contractAmount,
currency: dto.currency || 'MXN',
paymentTerms: dto.paymentTerms,
retentionPercentage: dto.retentionPercentage || 5,
advancePercentage: dto.advancePercentage || 0,
notes: dto.notes,
status: 'draft',
});
return this.contractRepository.save(contract);
}
async submitForReview(ctx: ServiceContext, id: string): Promise<Contract | null> {
const contract = await this.findById(ctx, id);
if (!contract) {
return null;
}
if (contract.status !== 'draft') {
throw new Error('Can only submit draft contracts for review');
}
contract.status = 'review';
contract.submittedAt = new Date();
contract.submittedById = ctx.userId || '';
contract.updatedById = ctx.userId || '';
return this.contractRepository.save(contract);
}
async approveLegal(ctx: ServiceContext, id: string): Promise<Contract | null> {
const contract = await this.findById(ctx, id);
if (!contract) {
return null;
}
if (contract.status !== 'review') {
throw new Error('Can only approve contracts in review');
}
contract.legalApprovedAt = new Date();
contract.legalApprovedById = ctx.userId || '';
contract.updatedById = ctx.userId || '';
return this.contractRepository.save(contract);
}
async approve(ctx: ServiceContext, id: string): Promise<Contract | null> {
const contract = await this.findById(ctx, id);
if (!contract) {
return null;
}
if (contract.status !== 'review') {
throw new Error('Can only approve contracts in review');
}
contract.status = 'approved';
contract.approvedAt = new Date();
contract.approvedById = ctx.userId || '';
contract.updatedById = ctx.userId || '';
return this.contractRepository.save(contract);
}
async activate(ctx: ServiceContext, id: string, signedDocumentUrl?: string): Promise<Contract | null> {
const contract = await this.findById(ctx, id);
if (!contract) {
return null;
}
if (contract.status !== 'approved') {
throw new Error('Can only activate approved contracts');
}
contract.status = 'active';
contract.signedAt = new Date();
if (signedDocumentUrl) {
contract.signedDocumentUrl = signedDocumentUrl;
}
contract.updatedById = ctx.userId || '';
return this.contractRepository.save(contract);
}
async complete(ctx: ServiceContext, id: string): Promise<Contract | null> {
const contract = await this.findById(ctx, id);
if (!contract) {
return null;
}
if (contract.status !== 'active') {
throw new Error('Can only complete active contracts');
}
contract.status = 'completed';
contract.updatedById = ctx.userId || '';
return this.contractRepository.save(contract);
}
async terminate(ctx: ServiceContext, id: string, reason: string): Promise<Contract | null> {
const contract = await this.findById(ctx, id);
if (!contract) {
return null;
}
if (contract.status !== 'active') {
throw new Error('Can only terminate active contracts');
}
contract.status = 'terminated';
contract.terminatedAt = new Date();
contract.terminationReason = reason;
contract.updatedById = ctx.userId || '';
return this.contractRepository.save(contract);
}
async updateProgress(
ctx: ServiceContext,
id: string,
progressPercentage: number,
invoicedAmount?: number,
paidAmount?: number
): Promise<Contract | null> {
const contract = await this.findById(ctx, id);
if (!contract) {
return null;
}
contract.progressPercentage = progressPercentage;
if (invoicedAmount !== undefined) {
contract.invoicedAmount = invoicedAmount;
}
if (paidAmount !== undefined) {
contract.paidAmount = paidAmount;
}
contract.updatedById = ctx.userId || '';
return this.contractRepository.save(contract);
}
async createAddendum(ctx: ServiceContext, contractId: string, dto: CreateAddendumDto): Promise<ContractAddendum> {
const contract = await this.findWithDetails(ctx, contractId);
if (!contract) {
throw new Error('Contract not found');
}
if (contract.status !== 'active') {
throw new Error('Can only add addendums to active contracts');
}
const sequence = (contract.addendums?.length || 0) + 1;
const addendum = this.addendumRepository.create({
tenantId: ctx.tenantId,
createdById: ctx.userId,
contractId,
addendumNumber: this.generateAddendumNumber(contract.contractNumber, sequence),
addendumType: dto.addendumType as any,
title: dto.title,
description: dto.description,
effectiveDate: dto.effectiveDate,
newEndDate: dto.newEndDate,
amountChange: dto.amountChange || 0,
newContractAmount: dto.amountChange
? Number(contract.contractAmount) + Number(dto.amountChange)
: undefined,
scopeChanges: dto.scopeChanges,
notes: dto.notes,
status: 'draft',
});
return this.addendumRepository.save(addendum);
}
async approveAddendum(ctx: ServiceContext, addendumId: string): Promise<ContractAddendum | null> {
const addendum = await this.addendumRepository.findOne({
where: {
id: addendumId,
tenantId: ctx.tenantId,
} as FindOptionsWhere<ContractAddendum>,
relations: ['contract'],
});
if (!addendum) {
return null;
}
if (addendum.status !== 'draft' && addendum.status !== 'review') {
throw new Error('Can only approve draft or review addendums');
}
addendum.status = 'approved';
addendum.approvedAt = new Date();
addendum.approvedById = ctx.userId || '';
addendum.updatedById = ctx.userId || '';
// Apply changes to contract
if (addendum.newEndDate) {
addendum.contract.endDate = addendum.newEndDate;
}
if (addendum.newContractAmount) {
addendum.contract.contractAmount = addendum.newContractAmount;
}
await this.contractRepository.save(addendum.contract);
return this.addendumRepository.save(addendum);
}
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
const contract = await this.findById(ctx, id);
if (!contract) {
return false;
}
if (contract.status === 'active') {
throw new Error('Cannot delete active contracts');
}
await this.contractRepository.update(
{ id, tenantId: ctx.tenantId } as FindOptionsWhere<Contract>,
{ deletedAt: new Date(), deletedById: ctx.userId || '' }
);
return true;
}
async getExpiringContracts(ctx: ServiceContext, days: number = 30): Promise<Contract[]> {
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + days);
return this.contractRepository.find({
where: {
tenantId: ctx.tenantId,
status: 'active' as ContractStatus,
endDate: LessThan(futureDate),
deletedAt: null,
} as unknown as FindOptionsWhere<Contract>,
order: { endDate: 'ASC' },
});
}
}

View File

@ -0,0 +1,7 @@
/**
* Contracts Services Index
* @module Contracts
*/
export * from './contract.service';
export * from './subcontractor.service';

View File

@ -0,0 +1,270 @@
/**
* SubcontractorService - Servicio de gestión de subcontratistas
*
* Catálogo de subcontratistas con evaluaciones.
*
* @module Contracts
*/
import { Repository, FindOptionsWhere } from 'typeorm';
import { Subcontractor, SubcontractorStatus, SubcontractorSpecialty } from '../entities/subcontractor.entity';
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
export interface CreateSubcontractorDto {
businessName: string;
tradeName?: string;
rfc: string;
address?: string;
phone?: string;
email?: string;
contactName?: string;
contactPhone?: string;
primarySpecialty: SubcontractorSpecialty;
secondarySpecialties?: string[];
bankName?: string;
bankAccount?: string;
clabe?: string;
notes?: string;
}
export interface UpdateSubcontractorDto {
tradeName?: string;
address?: string;
phone?: string;
email?: string;
contactName?: string;
contactPhone?: string;
secondarySpecialties?: string[];
bankName?: string;
bankAccount?: string;
clabe?: string;
notes?: string;
}
export interface SubcontractorFilters {
specialty?: SubcontractorSpecialty;
status?: SubcontractorStatus;
search?: string;
minRating?: number;
}
export class SubcontractorService {
constructor(private readonly subcontractorRepository: Repository<Subcontractor>) {}
private generateCode(): string {
const now = new Date();
const year = now.getFullYear().toString().slice(-2);
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
return `SC-${year}-${random}`;
}
async findWithFilters(
ctx: ServiceContext,
filters: SubcontractorFilters = {},
page: number = 1,
limit: number = 20
): Promise<PaginatedResult<Subcontractor>> {
const skip = (page - 1) * limit;
const queryBuilder = this.subcontractorRepository
.createQueryBuilder('sc')
.leftJoinAndSelect('sc.createdBy', 'createdBy')
.where('sc.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('sc.deleted_at IS NULL');
if (filters.specialty) {
queryBuilder.andWhere('sc.primary_specialty = :specialty', { specialty: filters.specialty });
}
if (filters.status) {
queryBuilder.andWhere('sc.status = :status', { status: filters.status });
}
if (filters.search) {
queryBuilder.andWhere(
'(sc.business_name ILIKE :search OR sc.trade_name ILIKE :search OR sc.rfc ILIKE :search)',
{ search: `%${filters.search}%` }
);
}
if (filters.minRating !== undefined) {
queryBuilder.andWhere('sc.average_rating >= :minRating', { minRating: filters.minRating });
}
queryBuilder
.orderBy('sc.business_name', 'ASC')
.skip(skip)
.take(limit);
const [data, total] = await queryBuilder.getManyAndCount();
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
async findById(ctx: ServiceContext, id: string): Promise<Subcontractor | null> {
return this.subcontractorRepository.findOne({
where: {
id,
tenantId: ctx.tenantId,
deletedAt: null,
} as unknown as FindOptionsWhere<Subcontractor>,
});
}
async findByRfc(ctx: ServiceContext, rfc: string): Promise<Subcontractor | null> {
return this.subcontractorRepository.findOne({
where: {
rfc: rfc.toUpperCase(),
tenantId: ctx.tenantId,
deletedAt: null,
} as unknown as FindOptionsWhere<Subcontractor>,
});
}
async create(ctx: ServiceContext, dto: CreateSubcontractorDto): Promise<Subcontractor> {
// Check for existing RFC
const existing = await this.findByRfc(ctx, dto.rfc);
if (existing) {
throw new Error('A subcontractor with this RFC already exists');
}
const subcontractor = this.subcontractorRepository.create({
tenantId: ctx.tenantId,
createdById: ctx.userId,
code: this.generateCode(),
businessName: dto.businessName,
tradeName: dto.tradeName,
rfc: dto.rfc.toUpperCase(),
address: dto.address,
phone: dto.phone,
email: dto.email,
contactName: dto.contactName,
contactPhone: dto.contactPhone,
primarySpecialty: dto.primarySpecialty,
secondarySpecialties: dto.secondarySpecialties,
bankName: dto.bankName,
bankAccount: dto.bankAccount,
clabe: dto.clabe,
notes: dto.notes,
status: 'active',
});
return this.subcontractorRepository.save(subcontractor);
}
async update(ctx: ServiceContext, id: string, dto: UpdateSubcontractorDto): Promise<Subcontractor | null> {
const subcontractor = await this.findById(ctx, id);
if (!subcontractor) {
return null;
}
Object.assign(subcontractor, {
...dto,
updatedById: ctx.userId || '',
});
return this.subcontractorRepository.save(subcontractor);
}
async updateRating(ctx: ServiceContext, id: string, rating: number): Promise<Subcontractor | null> {
const subcontractor = await this.findById(ctx, id);
if (!subcontractor) {
return null;
}
// Calculate new average rating
const totalRatings = subcontractor.completedContracts;
const currentTotal = subcontractor.averageRating * totalRatings;
const newTotal = currentTotal + rating;
subcontractor.averageRating = newTotal / (totalRatings + 1);
subcontractor.updatedById = ctx.userId || '';
return this.subcontractorRepository.save(subcontractor);
}
async incrementContracts(ctx: ServiceContext, id: string, completed: boolean = false): Promise<Subcontractor | null> {
const subcontractor = await this.findById(ctx, id);
if (!subcontractor) {
return null;
}
subcontractor.totalContracts += 1;
if (completed) {
subcontractor.completedContracts += 1;
}
subcontractor.updatedById = ctx.userId || '';
return this.subcontractorRepository.save(subcontractor);
}
async incrementIncidents(ctx: ServiceContext, id: string): Promise<Subcontractor | null> {
const subcontractor = await this.findById(ctx, id);
if (!subcontractor) {
return null;
}
subcontractor.totalIncidents += 1;
subcontractor.updatedById = ctx.userId || '';
return this.subcontractorRepository.save(subcontractor);
}
async deactivate(ctx: ServiceContext, id: string): Promise<Subcontractor | null> {
const subcontractor = await this.findById(ctx, id);
if (!subcontractor) {
return null;
}
subcontractor.status = 'inactive';
subcontractor.updatedById = ctx.userId || '';
return this.subcontractorRepository.save(subcontractor);
}
async blacklist(ctx: ServiceContext, id: string, reason: string): Promise<Subcontractor | null> {
const subcontractor = await this.findById(ctx, id);
if (!subcontractor) {
return null;
}
subcontractor.status = 'blacklisted';
subcontractor.notes = `${subcontractor.notes || ''}\n[BLACKLISTED] ${reason}`;
subcontractor.updatedById = ctx.userId || '';
return this.subcontractorRepository.save(subcontractor);
}
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
const subcontractor = await this.findById(ctx, id);
if (!subcontractor) {
return false;
}
await this.subcontractorRepository.update(
{ id, tenantId: ctx.tenantId } as FindOptionsWhere<Subcontractor>,
{ deletedAt: new Date(), deletedById: ctx.userId || '' }
);
return true;
}
async getBySpecialty(ctx: ServiceContext, specialty: SubcontractorSpecialty): Promise<Subcontractor[]> {
return this.subcontractorRepository.find({
where: {
tenantId: ctx.tenantId,
primarySpecialty: specialty,
status: 'active' as SubcontractorStatus,
deletedAt: null,
} as unknown as FindOptionsWhere<Subcontractor>,
order: { averageRating: 'DESC' },
});
}
}

View File

@ -0,0 +1,6 @@
/**
* Core Entities Index
*/
export { Tenant } from './tenant.entity';
export { User } from './user.entity';

View File

@ -0,0 +1,50 @@
/**
* Tenant Entity
* Entidad para multi-tenancy
*
* @module Core
* @table core.tenants
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
Index,
} from 'typeorm';
import { User } from './user.entity';
@Entity({ schema: 'auth', name: 'tenants' })
export class Tenant {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 50, unique: true })
@Index()
code: string;
@Column({ type: 'varchar', length: 200 })
name: string;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@Column({ type: 'jsonb', default: {} })
settings: Record<string, unknown>;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date;
// Relations
@OneToMany(() => User, (user) => user.tenant)
users: User[];
}

View File

@ -0,0 +1,78 @@
/**
* User Entity
* Entidad de usuarios del sistema
*
* @module Core
* @table core.users
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Tenant } from './tenant.entity';
@Entity({ schema: 'auth', name: 'users' })
@Index(['tenantId', 'email'], { unique: true })
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid', nullable: true })
tenantId: string;
@Column({ type: 'varchar', length: 255 })
email: string;
@Column({ type: 'varchar', length: 100, nullable: true })
username: string;
@Column({ name: 'password_hash', type: 'varchar', length: 255, select: false })
passwordHash: string;
@Column({ name: 'first_name', type: 'varchar', length: 100, nullable: true })
firstName: string;
@Column({ name: 'last_name', type: 'varchar', length: 100, nullable: true })
lastName: string;
@Column({ type: 'varchar', array: true, default: ['viewer'] })
roles: string[];
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@Column({ name: 'last_login_at', type: 'timestamptz', nullable: true })
lastLoginAt: Date;
@Column({ name: 'default_tenant_id', type: 'uuid', nullable: true })
defaultTenantId: string;
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date;
// Placeholder para relación de roles (se implementará en ST-004)
userRoles?: { role: { code: string } }[];
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
// Relations
@ManyToOne(() => Tenant, (tenant) => tenant.users)
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
// Computed property
get fullName(): string {
return [this.firstName, this.lastName].filter(Boolean).join(' ') || this.email;
}
}

View File

@ -0,0 +1,324 @@
/**
* AnticipoController - Controller de anticipos de obra
*
* Endpoints REST para gestión de anticipos de contratos de construcción.
*
* @module Estimates
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { AnticipoService, CreateAnticipoDto, AnticipoFilters } from '../services/anticipo.service';
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
import { AuthService } from '../../auth/services/auth.service';
import { Anticipo } from '../entities/anticipo.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
export function createAnticipoController(dataSource: DataSource): Router {
const router = Router();
// Repositorios
const anticipoRepository = dataSource.getRepository(Anticipo);
const userRepository = dataSource.getRepository(User);
const tenantRepository = dataSource.getRepository(Tenant);
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
// Servicios
const anticipoService = new AnticipoService(anticipoRepository);
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
const authMiddleware = new AuthMiddleware(authService, dataSource);
// Helper para crear contexto de servicio
const getContext = (req: Request): ServiceContext => {
if (!req.tenantId) {
throw new Error('Tenant ID is required');
}
return {
tenantId: req.tenantId,
userId: req.user?.sub,
};
};
/**
* GET /anticipos
*/
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const filters: AnticipoFilters = {};
if (req.query.contratoId) filters.contratoId = req.query.contratoId as string;
if (req.query.advanceType) filters.advanceType = req.query.advanceType as any;
if (req.query.isFullyAmortized !== undefined) {
filters.isFullyAmortized = req.query.isFullyAmortized === 'true';
}
if (req.query.startDate) filters.startDate = new Date(req.query.startDate as string);
if (req.query.endDate) filters.endDate = new Date(req.query.endDate as string);
const result = await anticipoService.findWithFilters(getContext(req), filters, page, limit);
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
});
} catch (error) {
next(error);
}
});
/**
* GET /anticipos/stats
*/
router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const filters: AnticipoFilters = {};
if (req.query.contratoId) filters.contratoId = req.query.contratoId as string;
if (req.query.advanceType) filters.advanceType = req.query.advanceType as any;
const stats = await anticipoService.getStats(getContext(req), filters);
res.status(200).json({ success: true, data: stats });
} catch (error) {
next(error);
}
});
/**
* GET /anticipos/contrato/:contratoId
*/
router.get('/contrato/:contratoId', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const result = await anticipoService.findByContrato(
getContext(req),
req.params.contratoId,
page,
limit
);
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
});
} catch (error) {
next(error);
}
});
/**
* GET /anticipos/:id
*/
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const anticipo = await anticipoService.findById(getContext(req), req.params.id);
if (!anticipo) {
res.status(404).json({ error: 'Not Found', message: 'Anticipo not found' });
return;
}
res.status(200).json({ success: true, data: anticipo });
} catch (error) {
next(error);
}
});
/**
* POST /anticipos
*/
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const dto: CreateAnticipoDto = req.body;
if (!dto.contratoId || !dto.advanceType || !dto.advanceNumber) {
res.status(400).json({
error: 'Bad Request',
message: 'contratoId, advanceType, and advanceNumber are required',
});
return;
}
const anticipo = await anticipoService.createAnticipo(getContext(req), dto);
res.status(201).json({ success: true, data: anticipo });
} catch (error) {
if (error instanceof Error && error.message.includes('no coincide')) {
res.status(400).json({ error: 'Bad Request', message: error.message });
return;
}
next(error);
}
});
/**
* POST /anticipos/:id/approve
*/
router.post('/:id/approve', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const approvedById = req.user?.sub;
if (!approvedById) {
res.status(400).json({ error: 'Bad Request', message: 'User ID required' });
return;
}
const anticipo = await anticipoService.approveAnticipo(
getContext(req),
req.params.id,
approvedById,
req.body.notes
);
res.status(200).json({ success: true, data: anticipo });
} catch (error) {
if (error instanceof Error) {
if (error.message === 'Anticipo no encontrado') {
res.status(404).json({ error: 'Not Found', message: error.message });
return;
}
if (error.message.includes('aprobado')) {
res.status(400).json({ error: 'Bad Request', message: error.message });
return;
}
}
next(error);
}
});
/**
* POST /anticipos/:id/pay
*/
router.post('/:id/pay', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'accountant'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const { paymentReference, paidAt } = req.body;
if (!paymentReference) {
res.status(400).json({ error: 'Bad Request', message: 'paymentReference is required' });
return;
}
const anticipo = await anticipoService.markPaid(
getContext(req),
req.params.id,
paymentReference,
paidAt ? new Date(paidAt) : undefined
);
res.status(200).json({ success: true, data: anticipo });
} catch (error) {
if (error instanceof Error) {
if (error.message === 'Anticipo no encontrado') {
res.status(404).json({ error: 'Not Found', message: error.message });
return;
}
if (error.message.includes('aprobado') || error.message.includes('pagado')) {
res.status(400).json({ error: 'Bad Request', message: error.message });
return;
}
}
next(error);
}
});
/**
* POST /anticipos/:id/amortize
*/
router.post('/:id/amortize', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const { amount } = req.body;
if (amount === undefined || amount <= 0) {
res.status(400).json({ error: 'Bad Request', message: 'Valid amount is required' });
return;
}
const anticipo = await anticipoService.updateAmortization(
getContext(req),
req.params.id,
amount
);
res.status(200).json({ success: true, data: anticipo });
} catch (error) {
if (error instanceof Error) {
if (error.message === 'Anticipo no encontrado') {
res.status(404).json({ error: 'Not Found', message: error.message });
return;
}
if (error.message.includes('amortizado') || error.message.includes('excede')) {
res.status(400).json({ error: 'Bad Request', message: error.message });
return;
}
}
next(error);
}
});
/**
* DELETE /anticipos/:id
*/
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const deleted = await anticipoService.softDelete(getContext(req), req.params.id);
if (!deleted) {
res.status(404).json({ error: 'Not Found', message: 'Anticipo not found' });
return;
}
res.status(200).json({ success: true, message: 'Anticipo deleted' });
} catch (error) {
next(error);
}
});
return router;
}
export default createAnticipoController;

View File

@ -0,0 +1,408 @@
/**
* EstimacionController - Controller de estimaciones de obra
*
* Endpoints REST para gestión de estimaciones periódicas.
* Incluye workflow de aprobación: draft -> submitted -> reviewed -> approved -> invoiced -> paid
*
* @module Estimates
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import {
EstimacionService,
CreateEstimacionDto,
AddConceptoDto,
AddGeneradorDto,
EstimacionFilters,
} from '../services/estimacion.service';
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
import { AuthService } from '../../auth/services/auth.service';
import { Estimacion } from '../entities/estimacion.entity';
import { EstimacionConcepto } from '../entities/estimacion-concepto.entity';
import { Generador } from '../entities/generador.entity';
import { EstimacionWorkflow } from '../entities/estimacion-workflow.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
/**
* Crear router de estimaciones
*/
export function createEstimacionController(dataSource: DataSource): Router {
const router = Router();
// Repositorios
const estimacionRepository = dataSource.getRepository(Estimacion);
const conceptoRepository = dataSource.getRepository(EstimacionConcepto);
const generadorRepository = dataSource.getRepository(Generador);
const workflowRepository = dataSource.getRepository(EstimacionWorkflow);
const userRepository = dataSource.getRepository(User);
const tenantRepository = dataSource.getRepository(Tenant);
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
// Servicios
const estimacionService = new EstimacionService(
estimacionRepository,
conceptoRepository,
generadorRepository,
workflowRepository,
dataSource
);
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
const authMiddleware = new AuthMiddleware(authService, dataSource);
// Helper para crear contexto de servicio
const getContext = (req: Request): ServiceContext => {
if (!req.tenantId) {
throw new Error('Tenant ID is required');
}
return {
tenantId: req.tenantId,
userId: req.user?.sub,
};
};
/**
* GET /estimaciones
* Listar estimaciones con filtros
*/
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const filters: EstimacionFilters = {
contratoId: req.query.contratoId as string,
fraccionamientoId: req.query.fraccionamientoId as string,
status: req.query.status as any,
periodFrom: req.query.periodFrom ? new Date(req.query.periodFrom as string) : undefined,
periodTo: req.query.periodTo ? new Date(req.query.periodTo as string) : undefined,
};
const result = await estimacionService.findWithFilters(getContext(req), filters, page, limit);
res.status(200).json({
success: true,
data: result.data,
pagination: result.meta,
});
} catch (error) {
next(error);
}
});
/**
* GET /estimaciones/summary/:contratoId
* Obtener resumen de estimaciones por contrato
*/
router.get('/summary/:contratoId', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const summary = await estimacionService.getContractSummary(getContext(req), req.params.contratoId);
res.status(200).json({ success: true, data: summary });
} catch (error) {
next(error);
}
});
/**
* GET /estimaciones/:id
* Obtener estimación con detalles completos
*/
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const estimacion = await estimacionService.findWithDetails(getContext(req), req.params.id);
if (!estimacion) {
res.status(404).json({ error: 'Not Found', message: 'Estimate not found' });
return;
}
res.status(200).json({ success: true, data: estimacion });
} catch (error) {
next(error);
}
});
/**
* POST /estimaciones
* Crear estimación
*/
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const dto: CreateEstimacionDto = req.body;
if (!dto.contratoId || !dto.fraccionamientoId || !dto.periodStart || !dto.periodEnd) {
res.status(400).json({ error: 'Bad Request', message: 'contratoId, fraccionamientoId, periodStart and periodEnd are required' });
return;
}
const estimacion = await estimacionService.createEstimacion(getContext(req), dto);
res.status(201).json({ success: true, data: estimacion });
} catch (error) {
next(error);
}
});
/**
* POST /estimaciones/:id/conceptos
* Agregar concepto a estimación
*/
router.post('/:id/conceptos', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const dto: AddConceptoDto = req.body;
if (!dto.conceptoId || dto.quantityCurrent === undefined || dto.unitPrice === undefined) {
res.status(400).json({ error: 'Bad Request', message: 'conceptoId, quantityCurrent and unitPrice are required' });
return;
}
const concepto = await estimacionService.addConcepto(getContext(req), req.params.id, dto);
res.status(201).json({ success: true, data: concepto });
} catch (error) {
if (error instanceof Error && error.message.includes('non-draft')) {
res.status(400).json({ error: 'Bad Request', message: error.message });
return;
}
next(error);
}
});
/**
* POST /estimaciones/conceptos/:conceptoId/generadores
* Agregar generador a concepto de estimación
*/
router.post('/conceptos/:conceptoId/generadores', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const dto: AddGeneradorDto = req.body;
if (!dto.generatorNumber || dto.quantity === undefined) {
res.status(400).json({ error: 'Bad Request', message: 'generatorNumber and quantity are required' });
return;
}
const generador = await estimacionService.addGenerador(getContext(req), req.params.conceptoId, dto);
res.status(201).json({ success: true, data: generador });
} catch (error) {
if (error instanceof Error && error.message === 'Concepto not found') {
res.status(404).json({ error: 'Not Found', message: error.message });
return;
}
next(error);
}
});
/**
* POST /estimaciones/:id/submit
* Enviar estimación para revisión
*/
router.post('/:id/submit', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const estimacion = await estimacionService.submit(getContext(req), req.params.id);
if (!estimacion) {
res.status(400).json({ error: 'Bad Request', message: 'Cannot submit this estimate' });
return;
}
res.status(200).json({ success: true, data: estimacion, message: 'Estimate submitted for review' });
} catch (error) {
if (error instanceof Error && error.message.includes('Invalid status')) {
res.status(400).json({ error: 'Bad Request', message: error.message });
return;
}
next(error);
}
});
/**
* POST /estimaciones/:id/review
* Revisar estimación
*/
router.post('/:id/review', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const estimacion = await estimacionService.review(getContext(req), req.params.id);
if (!estimacion) {
res.status(400).json({ error: 'Bad Request', message: 'Cannot review this estimate' });
return;
}
res.status(200).json({ success: true, data: estimacion, message: 'Estimate reviewed' });
} catch (error) {
if (error instanceof Error && error.message.includes('Invalid status')) {
res.status(400).json({ error: 'Bad Request', message: error.message });
return;
}
next(error);
}
});
/**
* POST /estimaciones/:id/approve
* Aprobar estimación
*/
router.post('/:id/approve', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const estimacion = await estimacionService.approve(getContext(req), req.params.id);
if (!estimacion) {
res.status(400).json({ error: 'Bad Request', message: 'Cannot approve this estimate' });
return;
}
res.status(200).json({ success: true, data: estimacion, message: 'Estimate approved' });
} catch (error) {
if (error instanceof Error && error.message.includes('Invalid status')) {
res.status(400).json({ error: 'Bad Request', message: error.message });
return;
}
next(error);
}
});
/**
* POST /estimaciones/:id/reject
* Rechazar estimación
*/
router.post('/:id/reject', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const { reason } = req.body;
if (!reason) {
res.status(400).json({ error: 'Bad Request', message: 'reason is required' });
return;
}
const estimacion = await estimacionService.reject(getContext(req), req.params.id, reason);
if (!estimacion) {
res.status(400).json({ error: 'Bad Request', message: 'Cannot reject this estimate' });
return;
}
res.status(200).json({ success: true, data: estimacion, message: 'Estimate rejected' });
} catch (error) {
if (error instanceof Error && error.message.includes('Invalid status')) {
res.status(400).json({ error: 'Bad Request', message: error.message });
return;
}
next(error);
}
});
/**
* POST /estimaciones/:id/recalculate
* Recalcular totales de estimación
*/
router.post('/:id/recalculate', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
await estimacionService.recalculateTotals(getContext(req), req.params.id);
const estimacion = await estimacionService.findWithDetails(getContext(req), req.params.id);
res.status(200).json({ success: true, data: estimacion, message: 'Totals recalculated' });
} catch (error) {
next(error);
}
});
/**
* DELETE /estimaciones/:id
* Eliminar estimación (solo draft)
*/
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
return;
}
const estimacion = await estimacionService.findById(getContext(req), req.params.id);
if (!estimacion) {
res.status(404).json({ error: 'Not Found', message: 'Estimate not found' });
return;
}
if (estimacion.status !== 'draft') {
res.status(400).json({ error: 'Bad Request', message: 'Only draft estimates can be deleted' });
return;
}
const deleted = await estimacionService.softDelete(getContext(req), req.params.id);
if (!deleted) {
res.status(404).json({ error: 'Not Found', message: 'Estimate not found' });
return;
}
res.status(200).json({ success: true, message: 'Estimate deleted' });
} catch (error) {
next(error);
}
});
return router;
}
export default createEstimacionController;

Some files were not shown because too many files have changed in this diff Show More