Initial commit for deploy

This commit is contained in:
rckrdmrd 2025-12-12 14:33:04 -06:00
commit 97e5dc4f73
72 changed files with 16139 additions and 0 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

78
.env.production Normal file
View File

@ -0,0 +1,78 @@
# =============================================================================
# ERP-SUITE: CONSTRUCCION Backend - Production Environment
# =============================================================================
# Servidor: 72.60.226.4
# Dominio: api.construccion.erp.isem.dev
# Schema BD: construccion (7 sub-schemas, 110 tablas)
# =============================================================================
# Application
NODE_ENV=production
PORT=3021
API_PREFIX=api
API_VERSION=v1
# URLs
SERVER_URL=https://api.construccion.erp.isem.dev
FRONTEND_URL=https://construccion.erp.isem.dev
# Database (BD compartida con erp-core)
DB_HOST=${DB_HOST:-localhost}
DB_PORT=5432
DB_NAME=erp_generic
DB_USER=erp_admin
DB_PASSWORD=${DB_PASSWORD}
DB_SCHEMA=construccion
DB_SSL=true
DB_SYNCHRONIZE=false
DB_LOGGING=false
DB_POOL_MAX=20
# Schemas que este vertical usa (read-only de erp-core)
DB_CORE_SCHEMAS=auth,core,inventory
# Redis (instancia separada para construccion)
REDIS_HOST=${REDIS_HOST:-redis}
REDIS_PORT=6379
REDIS_PASSWORD=${REDIS_PASSWORD}
REDIS_DB=0
# JWT (compartido con erp-core para SSO)
JWT_SECRET=${JWT_SECRET}
JWT_EXPIRES_IN=15m
JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
JWT_REFRESH_EXPIRES_IN=7d
# Multi-tenant
TENANT_HEADER=x-tenant-id
DEFAULT_TENANT_ID=
# CORS
CORS_ORIGIN=https://construccion.erp.isem.dev,https://erp.isem.dev
# Storage (MinIO para archivos de proyecto)
STORAGE_ENDPOINT=${STORAGE_ENDPOINT:-localhost}
STORAGE_PORT=9100
STORAGE_ACCESS_KEY=${MINIO_ACCESS_KEY}
STORAGE_SECRET_KEY=${MINIO_SECRET_KEY}
STORAGE_BUCKET=construccion-files
STORAGE_USE_SSL=false
# PostGIS (para geocercas HSE)
POSTGIS_ENABLED=true
# Security
ENABLE_SWAGGER=false
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_MAX=100
# Logging
LOG_LEVEL=warn
LOG_TO_FILE=true
LOG_FILE_PATH=/var/log/construccion/app.log
# Features específicas de construccion
FEATURE_HSE_MODULE=true
FEATURE_INFONAVIT_MODULE=true
FEATURE_ESTIMATES_MODULE=true
FEATURE_GEOCERCAS=true

33
.gitignore vendored Normal file
View File

@ -0,0 +1,33 @@
# Dependencies
node_modules/
# Build output
dist/
build/
# Environment files (local)
.env
.env.local
.env.*.local
# IDE
.idea/
.vscode/
*.swp
*.swo
# Logs
*.log
npm-debug.log*
# Coverage
coverage/
.nyc_output/
# OS
.DS_Store
Thumbs.db
# Cache
.cache/
.parcel-cache/

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"]

461
README.md Normal file
View File

@ -0,0 +1,461 @@
# Backend - ERP Construccion
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

7816
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();

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;
}

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

@ -0,0 +1,12 @@
/**
* 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 * from './services/auth.service';
export * from './middleware/auth.middleware';

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,
});
}
/**
* 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,
});
// 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,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,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,7 @@
/**
* Construction Controllers Index
* @module Construction
*/
export { default as proyectoController } from './proyecto.controller';
export { default as fraccionamientoController } from './fraccionamiento.controller';

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,90 @@
/**
* 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,
JoinColumn,
Index,
} from 'typeorm';
import { Tenant } from '../../core/entities/tenant.entity';
import { User } from '../../core/entities/user.entity';
import { Proyecto } from './proyecto.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;
}

View File

@ -0,0 +1,7 @@
/**
* Construction Entities Index
* @module Construction
*/
export * from './proyecto.entity';
export * from './fraccionamiento.entity';

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,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,7 @@
/**
* Construction Services Index
* @module Construction
*/
export * from './proyecto.service';
export * from './fraccionamiento.service';

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,6 @@
/**
* Core Entities Index
*/
export { Tenant } from './tenant.entity';
export { User } from './user.entity';

View File

@ -0,0 +1,47 @@
/**
* 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: 'core', 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;
// Relations
@OneToMany(() => User, (user) => user.tenant)
users: User[];
}

View File

@ -0,0 +1,69 @@
/**
* 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: 'core', 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', type: 'timestamptz', nullable: true })
lastLogin: Date;
@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,86 @@
/**
* Amortizacion Entity
* Amortizaciones de anticipos por estimacion
*
* @module Estimates
* @table estimates.amortizaciones
* @ddl schemas/04-estimates-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 { Anticipo } from './anticipo.entity';
import { Estimacion } from './estimacion.entity';
@Entity({ schema: 'estimates', name: 'amortizaciones' })
@Index(['anticipoId', 'estimacionId'], { unique: true })
@Index(['tenantId'])
@Index(['anticipoId'])
@Index(['estimacionId'])
export class Amortizacion {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'anticipo_id', type: 'uuid' })
anticipoId: string;
@Column({ name: 'estimacion_id', type: 'uuid' })
estimacionId: string;
@Column({ type: 'decimal', precision: 16, scale: 2 })
amount: number;
@Column({ name: 'amortization_date', type: 'date' })
amortizationDate: Date;
@Column({ type: 'text', nullable: true })
notes: 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(() => Anticipo, (a) => a.amortizaciones)
@JoinColumn({ name: 'anticipo_id' })
anticipo: Anticipo;
@ManyToOne(() => Estimacion, (e) => e.amortizaciones)
@JoinColumn({ name: 'estimacion_id' })
estimacion: Estimacion;
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by' })
createdBy: User | null;
}

View File

@ -0,0 +1,134 @@
/**
* Anticipo Entity
* Anticipos otorgados a subcontratistas
*
* @module Estimates
* @table estimates.anticipos
* @ddl schemas/04-estimates-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 { Amortizacion } from './amortizacion.entity';
export type AdvanceType = 'initial' | 'progress' | 'materials';
@Entity({ schema: 'estimates', name: 'anticipos' })
@Index(['tenantId', 'advanceNumber'], { unique: true })
@Index(['tenantId'])
@Index(['contratoId'])
@Index(['advanceType'])
export class Anticipo {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'contrato_id', type: 'uuid' })
contratoId: string;
@Column({
name: 'advance_type',
type: 'enum',
enum: ['initial', 'progress', 'materials'],
enumName: 'estimates.advance_type',
default: 'initial',
})
advanceType: AdvanceType;
@Column({ name: 'advance_number', type: 'varchar', length: 30 })
advanceNumber: string;
@Column({ name: 'advance_date', type: 'date' })
advanceDate: Date;
@Column({ name: 'gross_amount', type: 'decimal', precision: 16, scale: 2 })
grossAmount: number;
@Column({ name: 'tax_amount', type: 'decimal', precision: 16, scale: 2, default: 0 })
taxAmount: number;
@Column({ name: 'net_amount', type: 'decimal', precision: 16, scale: 2 })
netAmount: number;
@Column({ name: 'amortization_percentage', type: 'decimal', precision: 5, scale: 2, default: 0 })
amortizationPercentage: number;
@Column({ name: 'amortized_amount', type: 'decimal', precision: 16, scale: 2, default: 0 })
amortizedAmount: number;
// Columna calculada (GENERATED ALWAYS AS) - solo lectura
@Column({
name: 'pending_amount',
type: 'decimal',
precision: 16,
scale: 2,
insert: false,
update: false,
})
pendingAmount: number;
@Column({ name: 'is_fully_amortized', type: 'boolean', default: false })
isFullyAmortized: boolean;
@Column({ name: 'approved_at', type: 'timestamptz', nullable: true })
approvedAt: Date | null;
@Column({ name: 'approved_by', type: 'uuid', nullable: true })
approvedById: string | null;
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
paidAt: Date | null;
@Column({ name: 'payment_reference', type: 'varchar', length: 100, nullable: true })
paymentReference: string | null;
@Column({ type: 'text', nullable: true })
notes: 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(() => User)
@JoinColumn({ name: 'approved_by' })
approvedBy: User | null;
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by' })
createdBy: User | null;
@OneToMany(() => Amortizacion, (a) => a.anticipo)
amortizaciones: Amortizacion[];
}

View File

@ -0,0 +1,122 @@
/**
* EstimacionConcepto Entity
* Lineas de concepto por estimacion
*
* @module Estimates
* @table estimates.estimacion_conceptos
* @ddl schemas/04-estimates-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 { Concepto } from '../../budgets/entities/concepto.entity';
import { Estimacion } from './estimacion.entity';
import { Generador } from './generador.entity';
@Entity({ schema: 'estimates', name: 'estimacion_conceptos' })
@Index(['estimacionId', 'conceptoId'], { unique: true })
@Index(['tenantId'])
@Index(['estimacionId'])
@Index(['conceptoId'])
export class EstimacionConcepto {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'estimacion_id', type: 'uuid' })
estimacionId: string;
@Column({ name: 'concepto_id', type: 'uuid' })
conceptoId: string;
@Column({ name: 'contrato_partida_id', type: 'uuid', nullable: true })
contratoPartidaId: string | null;
@Column({ name: 'quantity_contract', type: 'decimal', precision: 12, scale: 4, default: 0 })
quantityContract: number;
@Column({ name: 'quantity_previous', type: 'decimal', precision: 12, scale: 4, default: 0 })
quantityPrevious: number;
@Column({ name: 'quantity_current', type: 'decimal', precision: 12, scale: 4, default: 0 })
quantityCurrent: number;
// Columna calculada (GENERATED ALWAYS AS) - solo lectura
@Column({
name: 'quantity_accumulated',
type: 'decimal',
precision: 12,
scale: 4,
insert: false,
update: false,
})
quantityAccumulated: number;
@Column({ name: 'unit_price', type: 'decimal', precision: 12, scale: 4, default: 0 })
unitPrice: number;
// Columna calculada (GENERATED ALWAYS AS) - solo lectura
@Column({
name: 'amount_current',
type: 'decimal',
precision: 14,
scale: 2,
insert: false,
update: false,
})
amountCurrent: number;
@Column({ type: 'text', nullable: true })
notes: 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(() => Estimacion, (e) => e.conceptos)
@JoinColumn({ name: 'estimacion_id' })
estimacion: Estimacion;
@ManyToOne(() => Concepto)
@JoinColumn({ name: 'concepto_id' })
concepto: Concepto;
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by' })
createdBy: User | null;
@OneToMany(() => Generador, (g) => g.estimacionConcepto)
generadores: Generador[];
}

View File

@ -0,0 +1,87 @@
/**
* EstimacionWorkflow Entity
* Historial de workflow de estimaciones
*
* @module Estimates
* @table estimates.estimacion_workflow
* @ddl schemas/04-estimates-schema-ddl.sql
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Tenant } from '../../core/entities/tenant.entity';
import { User } from '../../core/entities/user.entity';
import { Estimacion, EstimateStatus } from './estimacion.entity';
@Entity({ schema: 'estimates', name: 'estimacion_workflow' })
@Index(['tenantId'])
@Index(['estimacionId'])
export class EstimacionWorkflow {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'estimacion_id', type: 'uuid' })
estimacionId: string;
@Column({
name: 'from_status',
type: 'enum',
enum: ['draft', 'submitted', 'reviewed', 'approved', 'invoiced', 'paid', 'rejected', 'cancelled'],
enumName: 'estimates.estimate_status',
nullable: true,
})
fromStatus: EstimateStatus | null;
@Column({
name: 'to_status',
type: 'enum',
enum: ['draft', 'submitted', 'reviewed', 'approved', 'invoiced', 'paid', 'rejected', 'cancelled'],
enumName: 'estimates.estimate_status',
})
toStatus: EstimateStatus;
@Column({ type: 'varchar', length: 50 })
action: string;
@Column({ type: 'text', nullable: true })
comments: string | null;
@Column({ name: 'performed_by', type: 'uuid' })
performedById: string;
@Column({ name: 'performed_at', type: 'timestamptz', default: () => 'NOW()' })
performedAt: Date;
@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(() => Estimacion, (e) => e.workflow)
@JoinColumn({ name: 'estimacion_id' })
estimacion: Estimacion;
@ManyToOne(() => User)
@JoinColumn({ name: 'performed_by' })
performedBy: User;
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by' })
createdBy: User | null;
}

View File

@ -0,0 +1,173 @@
/**
* Estimacion Entity
* Estimaciones de obra periodicas para subcontratistas
*
* @module Estimates
* @table estimates.estimaciones
* @ddl schemas/04-estimates-schema-ddl.sql
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
Index,
Check,
} from 'typeorm';
import { Tenant } from '../../core/entities/tenant.entity';
import { User } from '../../core/entities/user.entity';
import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity';
import { EstimacionConcepto } from './estimacion-concepto.entity';
import { Amortizacion } from './amortizacion.entity';
import { Retencion } from './retencion.entity';
import { EstimacionWorkflow } from './estimacion-workflow.entity';
export type EstimateStatus = 'draft' | 'submitted' | 'reviewed' | 'approved' | 'invoiced' | 'paid' | 'rejected' | 'cancelled';
@Entity({ schema: 'estimates', name: 'estimaciones' })
@Index(['tenantId', 'estimateNumber'], { unique: true })
@Index(['contratoId', 'sequenceNumber'], { unique: true })
@Index(['tenantId'])
@Index(['contratoId'])
@Index(['fraccionamientoId'])
@Index(['status'])
@Index(['periodStart', 'periodEnd'])
@Check(`"period_end" >= "period_start"`)
export class Estimacion {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'contrato_id', type: 'uuid' })
contratoId: string;
@Column({ name: 'fraccionamiento_id', type: 'uuid' })
fraccionamientoId: string;
@Column({ name: 'estimate_number', type: 'varchar', length: 30 })
estimateNumber: string;
@Column({ name: 'period_start', type: 'date' })
periodStart: Date;
@Column({ name: 'period_end', type: 'date' })
periodEnd: Date;
@Column({ name: 'sequence_number', type: 'integer' })
sequenceNumber: number;
@Column({
type: 'enum',
enum: ['draft', 'submitted', 'reviewed', 'approved', 'invoiced', 'paid', 'rejected', 'cancelled'],
enumName: 'estimates.estimate_status',
default: 'draft',
})
status: EstimateStatus;
@Column({ type: 'decimal', precision: 16, scale: 2, default: 0 })
subtotal: number;
@Column({ name: 'advance_amount', type: 'decimal', precision: 16, scale: 2, default: 0 })
advanceAmount: number;
@Column({ name: 'retention_amount', type: 'decimal', precision: 16, scale: 2, default: 0 })
retentionAmount: number;
@Column({ name: 'tax_amount', type: 'decimal', precision: 16, scale: 2, default: 0 })
taxAmount: number;
@Column({ name: 'total_amount', type: 'decimal', precision: 16, scale: 2, default: 0 })
totalAmount: number;
@Column({ name: 'submitted_at', type: 'timestamptz', nullable: true })
submittedAt: Date | null;
@Column({ name: 'submitted_by', type: 'uuid', nullable: true })
submittedById: string | null;
@Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true })
reviewedAt: Date | null;
@Column({ name: 'reviewed_by', type: 'uuid', nullable: true })
reviewedById: string | null;
@Column({ name: 'approved_at', type: 'timestamptz', nullable: true })
approvedAt: Date | null;
@Column({ name: 'approved_by', type: 'uuid', nullable: true })
approvedById: string | null;
@Column({ name: 'invoice_id', type: 'uuid', nullable: true })
invoiceId: string | null;
@Column({ name: 'invoiced_at', type: 'timestamptz', nullable: true })
invoicedAt: Date | null;
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
paidAt: Date | null;
@Column({ type: 'text', nullable: true })
notes: 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)
@JoinColumn({ name: 'fraccionamiento_id' })
fraccionamiento: Fraccionamiento;
@ManyToOne(() => User)
@JoinColumn({ name: 'submitted_by' })
submittedBy: User | null;
@ManyToOne(() => User)
@JoinColumn({ name: 'reviewed_by' })
reviewedBy: User | null;
@ManyToOne(() => User)
@JoinColumn({ name: 'approved_by' })
approvedBy: User | null;
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by' })
createdBy: User | null;
@OneToMany(() => EstimacionConcepto, (c) => c.estimacion)
conceptos: EstimacionConcepto[];
@OneToMany(() => Amortizacion, (a) => a.estimacion)
amortizaciones: Amortizacion[];
@OneToMany(() => Retencion, (r) => r.estimacion)
retenciones: Retencion[];
@OneToMany(() => EstimacionWorkflow, (w) => w.estimacion)
workflow: EstimacionWorkflow[];
}

View File

@ -0,0 +1,92 @@
/**
* FondoGarantia Entity
* Fondo de garantia acumulado por contrato
*
* @module Estimates
* @table estimates.fondo_garantia
* @ddl schemas/04-estimates-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';
@Entity({ schema: 'estimates', name: 'fondo_garantia' })
@Index(['contratoId'], { unique: true })
@Index(['tenantId'])
export class FondoGarantia {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'contrato_id', type: 'uuid' })
contratoId: string;
@Column({ name: 'accumulated_amount', type: 'decimal', precision: 16, scale: 2, default: 0 })
accumulatedAmount: number;
@Column({ name: 'released_amount', type: 'decimal', precision: 16, scale: 2, default: 0 })
releasedAmount: number;
// Columna calculada (GENERATED ALWAYS AS) - solo lectura
@Column({
name: 'pending_amount',
type: 'decimal',
precision: 16,
scale: 2,
insert: false,
update: false,
})
pendingAmount: number;
@Column({ name: 'release_date', type: 'date', nullable: true })
releaseDate: Date | null;
@Column({ name: 'released_at', type: 'timestamptz', nullable: true })
releasedAt: Date | null;
@Column({ name: 'released_by', type: 'uuid', nullable: true })
releasedById: 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(() => User)
@JoinColumn({ name: 'released_by' })
releasedBy: User | null;
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by' })
createdBy: User | null;
}

View File

@ -0,0 +1,125 @@
/**
* Generador Entity
* Numeros generadores (soporte de cantidades para estimaciones)
*
* @module Estimates
* @table estimates.generadores
* @ddl schemas/04-estimates-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 { EstimacionConcepto } from './estimacion-concepto.entity';
export type GeneratorStatus = 'draft' | 'in_progress' | 'completed' | 'approved';
@Entity({ schema: 'estimates', name: 'generadores' })
@Index(['tenantId'])
@Index(['estimacionConceptoId'])
@Index(['status'])
export class Generador {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'estimacion_concepto_id', type: 'uuid' })
estimacionConceptoId: string;
@Column({ name: 'generator_number', type: 'varchar', length: 30 })
generatorNumber: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({
type: 'enum',
enum: ['draft', 'in_progress', 'completed', 'approved'],
enumName: 'estimates.generator_status',
default: 'draft',
})
status: GeneratorStatus;
@Column({ name: 'lote_id', type: 'uuid', nullable: true })
loteId: string | null;
@Column({ name: 'departamento_id', type: 'uuid', nullable: true })
departamentoId: string | null;
@Column({ name: 'location_description', type: 'varchar', length: 255, nullable: true })
locationDescription: string | null;
@Column({ type: 'decimal', precision: 12, scale: 4, default: 0 })
quantity: number;
@Column({ type: 'text', nullable: true })
formula: string | null;
@Column({ name: 'photo_url', type: 'varchar', length: 500, nullable: true })
photoUrl: string | null;
@Column({ name: 'sketch_url', type: 'varchar', length: 500, nullable: true })
sketchUrl: string | null;
@Column({ name: 'captured_by', type: 'uuid' })
capturedById: string;
@Column({ name: 'captured_at', type: 'timestamptz', default: () => 'NOW()' })
capturedAt: Date;
@Column({ name: 'approved_by', type: 'uuid', nullable: true })
approvedById: string | null;
@Column({ name: 'approved_at', type: 'timestamptz', nullable: true })
approvedAt: Date | 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(() => EstimacionConcepto, (ec) => ec.generadores)
@JoinColumn({ name: 'estimacion_concepto_id' })
estimacionConcepto: EstimacionConcepto;
@ManyToOne(() => User)
@JoinColumn({ name: 'captured_by' })
capturedBy: User;
@ManyToOne(() => User)
@JoinColumn({ name: 'approved_by' })
approvedBy: User | null;
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by' })
createdBy: User | null;
}

View File

@ -0,0 +1,13 @@
/**
* Estimates Module - Entity Exports
* MAI-008: Estimaciones y Facturacion
*/
export * from './estimacion.entity';
export * from './estimacion-concepto.entity';
export * from './generador.entity';
export * from './anticipo.entity';
export * from './amortizacion.entity';
export * from './retencion.entity';
export * from './fondo-garantia.entity';
export * from './estimacion-workflow.entity';

View File

@ -0,0 +1,99 @@
/**
* Retencion Entity
* Retenciones aplicadas a estimaciones
*
* @module Estimates
* @table estimates.retenciones
* @ddl schemas/04-estimates-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 { Estimacion } from './estimacion.entity';
export type RetentionType = 'guarantee' | 'tax' | 'penalty' | 'other';
@Entity({ schema: 'estimates', name: 'retenciones' })
@Index(['tenantId'])
@Index(['estimacionId'])
@Index(['retentionType'])
export class Retencion {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'estimacion_id', type: 'uuid' })
estimacionId: string;
@Column({
name: 'retention_type',
type: 'enum',
enum: ['guarantee', 'tax', 'penalty', 'other'],
enumName: 'estimates.retention_type',
})
retentionType: RetentionType;
@Column({ type: 'varchar', length: 255 })
description: string;
@Column({ type: 'decimal', precision: 5, scale: 2, nullable: true })
percentage: number | null;
@Column({ type: 'decimal', precision: 16, scale: 2 })
amount: number;
@Column({ name: 'release_date', type: 'date', nullable: true })
releaseDate: Date | null;
@Column({ name: 'released_at', type: 'timestamptz', nullable: true })
releasedAt: Date | null;
@Column({ name: 'released_amount', type: 'decimal', precision: 16, scale: 2, nullable: true })
releasedAmount: number | null;
@Column({ type: 'text', nullable: true })
notes: 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(() => Estimacion, (e) => e.retenciones)
@JoinColumn({ name: 'estimacion_id' })
estimacion: Estimacion;
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by' })
createdBy: User | null;
}

View File

@ -0,0 +1,424 @@
/**
* EstimacionService - Gestión de Estimaciones de Obra
*
* Gestiona estimaciones periódicas con workflow de aprobación.
* Incluye cálculo de anticipos, retenciones e IVA.
*
* @module Estimates
*/
import { Repository, DataSource } from 'typeorm';
import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
import { Estimacion, EstimateStatus } from '../entities/estimacion.entity';
import { EstimacionConcepto } from '../entities/estimacion-concepto.entity';
import { Generador } from '../entities/generador.entity';
import { EstimacionWorkflow } from '../entities/estimacion-workflow.entity';
export interface CreateEstimacionDto {
contratoId: string;
fraccionamientoId: string;
periodStart: Date;
periodEnd: Date;
notes?: string;
}
export interface AddConceptoDto {
conceptoId: string;
contratoPartidaId?: string;
quantityContract?: number;
quantityPrevious?: number;
quantityCurrent: number;
unitPrice: number;
notes?: string;
}
export interface AddGeneradorDto {
generatorNumber: string;
description?: string;
loteId?: string;
departamentoId?: string;
locationDescription?: string;
quantity: number;
formula?: string;
photoUrl?: string;
sketchUrl?: string;
}
export interface EstimacionFilters {
contratoId?: string;
fraccionamientoId?: string;
status?: EstimateStatus;
periodFrom?: Date;
periodTo?: Date;
}
export class EstimacionService extends BaseService<Estimacion> {
constructor(
repository: Repository<Estimacion>,
private readonly conceptoRepository: Repository<EstimacionConcepto>,
private readonly generadorRepository: Repository<Generador>,
private readonly workflowRepository: Repository<EstimacionWorkflow>,
private readonly dataSource: DataSource
) {
super(repository);
}
/**
* Crear nueva estimación
*/
async createEstimacion(
ctx: ServiceContext,
data: CreateEstimacionDto
): Promise<Estimacion> {
const sequenceNumber = await this.getNextSequenceNumber(ctx, data.contratoId);
const estimateNumber = await this.generateEstimateNumber(ctx, data.contratoId, sequenceNumber);
const estimacion = await this.create(ctx, {
...data,
estimateNumber,
sequenceNumber,
status: 'draft',
});
// Registrar en workflow
await this.addWorkflowEntry(ctx, estimacion.id, null, 'draft', 'create', 'Estimación creada');
return estimacion;
}
/**
* Obtener siguiente número de secuencia
*/
private async getNextSequenceNumber(
ctx: ServiceContext,
contratoId: string
): Promise<number> {
const result = await this.repository
.createQueryBuilder('e')
.select('MAX(e.sequence_number)', 'maxNumber')
.where('e.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('e.contrato_id = :contratoId', { contratoId })
.getRawOne();
return (result?.maxNumber || 0) + 1;
}
/**
* Generar número de estimación
*/
private async generateEstimateNumber(
ctx: ServiceContext,
contratoId: string,
sequenceNumber: number
): Promise<string> {
const year = new Date().getFullYear();
return `EST-${year}-${contratoId.substring(0, 8).toUpperCase()}-${sequenceNumber.toString().padStart(3, '0')}`;
}
/**
* Obtener estimaciones por contrato
*/
async findByContrato(
ctx: ServiceContext,
contratoId: string,
page = 1,
limit = 20
): Promise<PaginatedResult<Estimacion>> {
return this.findAll(ctx, {
page,
limit,
where: { contratoId } as any,
});
}
/**
* Obtener estimaciones con filtros
*/
async findWithFilters(
ctx: ServiceContext,
filters: EstimacionFilters,
page = 1,
limit = 20
): Promise<PaginatedResult<Estimacion>> {
const qb = this.repository
.createQueryBuilder('e')
.where('e.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('e.deleted_at IS NULL');
if (filters.contratoId) {
qb.andWhere('e.contrato_id = :contratoId', { contratoId: filters.contratoId });
}
if (filters.fraccionamientoId) {
qb.andWhere('e.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId: filters.fraccionamientoId });
}
if (filters.status) {
qb.andWhere('e.status = :status', { status: filters.status });
}
if (filters.periodFrom) {
qb.andWhere('e.period_start >= :periodFrom', { periodFrom: filters.periodFrom });
}
if (filters.periodTo) {
qb.andWhere('e.period_end <= :periodTo', { periodTo: filters.periodTo });
}
const skip = (page - 1) * limit;
qb.orderBy('e.sequence_number', 'DESC').skip(skip).take(limit);
const [data, total] = await qb.getManyAndCount();
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Obtener estimación con detalles completos
*/
async findWithDetails(ctx: ServiceContext, id: string): Promise<Estimacion | null> {
return this.repository.findOne({
where: {
id,
tenantId: ctx.tenantId,
deletedAt: null,
} as any,
relations: [
'conceptos',
'conceptos.concepto',
'conceptos.generadores',
'amortizaciones',
'retenciones',
'workflow',
],
});
}
/**
* Agregar concepto a estimación
*/
async addConcepto(
ctx: ServiceContext,
estimacionId: string,
data: AddConceptoDto
): Promise<EstimacionConcepto> {
const estimacion = await this.findById(ctx, estimacionId);
if (!estimacion || estimacion.status !== 'draft') {
throw new Error('Cannot modify non-draft estimation');
}
const concepto = this.conceptoRepository.create({
tenantId: ctx.tenantId,
estimacionId,
conceptoId: data.conceptoId,
contratoPartidaId: data.contratoPartidaId,
quantityContract: data.quantityContract || 0,
quantityPrevious: data.quantityPrevious || 0,
quantityCurrent: data.quantityCurrent,
unitPrice: data.unitPrice,
notes: data.notes,
createdById: ctx.userId,
});
const savedConcepto = await this.conceptoRepository.save(concepto);
await this.recalculateTotals(ctx, estimacionId);
return savedConcepto;
}
/**
* Agregar generador a concepto de estimación
*/
async addGenerador(
ctx: ServiceContext,
estimacionConceptoId: string,
data: AddGeneradorDto
): Promise<Generador> {
const concepto = await this.conceptoRepository.findOne({
where: { id: estimacionConceptoId, tenantId: ctx.tenantId } as any,
relations: ['estimacion'],
});
if (!concepto) {
throw new Error('Concepto not found');
}
const generador = this.generadorRepository.create({
tenantId: ctx.tenantId,
estimacionConceptoId,
generatorNumber: data.generatorNumber,
description: data.description,
loteId: data.loteId,
departamentoId: data.departamentoId,
locationDescription: data.locationDescription,
quantity: data.quantity,
formula: data.formula,
photoUrl: data.photoUrl,
sketchUrl: data.sketchUrl,
status: 'draft',
capturedById: ctx.userId,
createdById: ctx.userId,
});
return this.generadorRepository.save(generador);
}
/**
* Recalcular totales de estimación
*/
async recalculateTotals(ctx: ServiceContext, estimacionId: string): Promise<void> {
// Ejecutar función de PostgreSQL
await this.dataSource.query('SELECT estimates.calculate_estimate_totals($1)', [estimacionId]);
}
/**
* Cambiar estado de estimación
*/
async changeStatus(
ctx: ServiceContext,
estimacionId: string,
newStatus: EstimateStatus,
action: string,
comments?: string
): Promise<Estimacion | null> {
const estimacion = await this.findById(ctx, estimacionId);
if (!estimacion) {
return null;
}
const validTransitions: Record<EstimateStatus, EstimateStatus[]> = {
draft: ['submitted'],
submitted: ['reviewed', 'rejected'],
reviewed: ['approved', 'rejected'],
approved: ['invoiced'],
invoiced: ['paid'],
paid: [],
rejected: ['draft'],
cancelled: [],
};
if (!validTransitions[estimacion.status]?.includes(newStatus)) {
throw new Error(`Invalid status transition from ${estimacion.status} to ${newStatus}`);
}
const updateData: Partial<Estimacion> = { status: newStatus };
switch (newStatus) {
case 'submitted':
updateData.submittedAt = new Date();
updateData.submittedById = ctx.userId;
break;
case 'reviewed':
updateData.reviewedAt = new Date();
updateData.reviewedById = ctx.userId;
break;
case 'approved':
updateData.approvedAt = new Date();
updateData.approvedById = ctx.userId;
break;
case 'invoiced':
updateData.invoicedAt = new Date();
break;
case 'paid':
updateData.paidAt = new Date();
break;
}
const updated = await this.update(ctx, estimacionId, updateData);
// Registrar en workflow
await this.addWorkflowEntry(ctx, estimacionId, estimacion.status, newStatus, action, comments);
return updated;
}
/**
* Agregar entrada al workflow
*/
private async addWorkflowEntry(
ctx: ServiceContext,
estimacionId: string,
fromStatus: EstimateStatus | null,
toStatus: EstimateStatus,
action: string,
comments?: string
): Promise<void> {
await this.workflowRepository.save(
this.workflowRepository.create({
tenantId: ctx.tenantId,
estimacionId,
fromStatus,
toStatus,
action,
comments,
performedById: ctx.userId,
createdById: ctx.userId,
})
);
}
/**
* Enviar estimación para revisión
*/
async submit(ctx: ServiceContext, estimacionId: string): Promise<Estimacion | null> {
return this.changeStatus(ctx, estimacionId, 'submitted', 'submit', 'Enviada para revisión');
}
/**
* Revisar estimación
*/
async review(ctx: ServiceContext, estimacionId: string): Promise<Estimacion | null> {
return this.changeStatus(ctx, estimacionId, 'reviewed', 'review', 'Revisión completada');
}
/**
* Aprobar estimación
*/
async approve(ctx: ServiceContext, estimacionId: string): Promise<Estimacion | null> {
return this.changeStatus(ctx, estimacionId, 'approved', 'approve', 'Aprobada');
}
/**
* Rechazar estimación
*/
async reject(
ctx: ServiceContext,
estimacionId: string,
reason: string
): Promise<Estimacion | null> {
return this.changeStatus(ctx, estimacionId, 'rejected', 'reject', reason);
}
/**
* Obtener resumen de estimaciones por contrato
*/
async getContractSummary(ctx: ServiceContext, contratoId: string): Promise<ContractEstimateSummary> {
const result = await this.repository
.createQueryBuilder('e')
.select([
'COUNT(*) as total_estimates',
'SUM(CASE WHEN e.status = \'approved\' THEN e.total_amount ELSE 0 END) as total_approved',
'SUM(CASE WHEN e.status = \'paid\' THEN e.total_amount ELSE 0 END) as total_paid',
])
.where('e.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('e.contrato_id = :contratoId', { contratoId })
.andWhere('e.deleted_at IS NULL')
.getRawOne();
return {
totalEstimates: parseInt(result?.total_estimates || '0'),
totalApproved: parseFloat(result?.total_approved || '0'),
totalPaid: parseFloat(result?.total_paid || '0'),
};
}
}
interface ContractEstimateSummary {
totalEstimates: number;
totalApproved: number;
totalPaid: number;
}

View File

@ -0,0 +1,6 @@
/**
* Estimates Module - Service Exports
* MAI-008: Estimaciones y Facturación
*/
export * from './estimacion.service';

View File

@ -0,0 +1,65 @@
/**
* EmployeeFraccionamiento Entity
* Asignación de empleados a obras/fraccionamientos
*
* @module HR
* @table hr.employee_fraccionamientos
* @ddl schemas/02-hr-schema-ddl.sql
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Tenant } from '../../core/entities/tenant.entity';
import { Employee } from './employee.entity';
import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity';
@Entity({ schema: 'hr', name: 'employee_fraccionamientos' })
@Index(['employeeId', 'fraccionamientoId', 'fechaInicio'], { unique: true })
export class EmployeeFraccionamiento {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'employee_id', type: 'uuid' })
employeeId: string;
@Column({ name: 'fraccionamiento_id', type: 'uuid' })
fraccionamientoId: string;
@Column({ name: 'fecha_inicio', type: 'date' })
fechaInicio: Date;
@Column({ name: 'fecha_fin', type: 'date', nullable: true })
fechaFin: Date;
@Column({ type: 'varchar', length: 50, nullable: true })
rol: string;
@Column({ type: 'boolean', default: true })
activo: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
// Relations
@ManyToOne(() => Tenant)
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@ManyToOne(() => Employee, (e) => e.asignaciones)
@JoinColumn({ name: 'employee_id' })
employee: Employee;
@ManyToOne(() => Fraccionamiento)
@JoinColumn({ name: 'fraccionamiento_id' })
fraccionamiento: Fraccionamiento;
}

View File

@ -0,0 +1,136 @@
/**
* Employee Entity
* Empleados de la empresa
*
* @module HR
* @table hr.employees
* @ddl schemas/02-hr-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 { Puesto } from './puesto.entity';
import { EmployeeFraccionamiento } from './employee-fraccionamiento.entity';
export type EstadoEmpleado = 'activo' | 'inactivo' | 'baja';
export type Genero = 'M' | 'F';
@Entity({ schema: 'hr', name: 'employees' })
@Index(['tenantId', 'codigo'], { unique: true })
@Index(['tenantId', 'curp'], { unique: true })
export class Employee {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ type: 'varchar', length: 20 })
codigo: string;
@Column({ type: 'varchar', length: 100 })
nombre: string;
@Column({ name: 'apellido_paterno', type: 'varchar', length: 100 })
apellidoPaterno: string;
@Column({ name: 'apellido_materno', type: 'varchar', length: 100, nullable: true })
apellidoMaterno: string;
@Column({ type: 'varchar', length: 18, nullable: true })
curp: string;
@Column({ type: 'varchar', length: 13, nullable: true })
rfc: string;
@Column({ type: 'varchar', length: 11, nullable: true })
nss: string;
@Column({ name: 'fecha_nacimiento', type: 'date', nullable: true })
fechaNacimiento: Date;
@Column({ type: 'varchar', length: 1, nullable: true })
genero: Genero;
@Column({ type: 'varchar', length: 255, nullable: true })
email: string;
@Column({ type: 'varchar', length: 20, nullable: true })
telefono: string;
@Column({ type: 'text', nullable: true })
direccion: string;
@Column({ name: 'fecha_ingreso', type: 'date' })
fechaIngreso: Date;
@Column({ name: 'fecha_baja', type: 'date', nullable: true })
fechaBaja: Date;
@Column({ name: 'puesto_id', type: 'uuid', nullable: true })
puestoId: string;
@Column({ type: 'varchar', length: 100, nullable: true })
departamento: string;
@Column({ name: 'tipo_contrato', type: 'varchar', length: 50, nullable: true })
tipoContrato: string;
@Column({
name: 'salario_diario',
type: 'decimal',
precision: 10,
scale: 2,
nullable: true
})
salarioDiario: number;
@Column({ type: 'varchar', length: 20, default: 'activo' })
estado: EstadoEmpleado;
@Column({ name: 'foto_url', type: 'varchar', length: 500, nullable: true })
fotoUrl: string;
@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(() => Puesto, (p) => p.empleados)
@JoinColumn({ name: 'puesto_id' })
puesto: Puesto;
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by' })
createdBy: User;
@OneToMany(() => EmployeeFraccionamiento, (ef) => ef.employee)
asignaciones: EmployeeFraccionamiento[];
// Computed property
get nombreCompleto(): string {
return [this.nombre, this.apellidoPaterno, this.apellidoMaterno]
.filter(Boolean)
.join(' ');
}
}

View File

@ -0,0 +1,8 @@
/**
* HR Entities Index
* @module HR
*/
export * from './puesto.entity';
export * from './employee.entity';
export * from './employee-fraccionamiento.entity';

View File

@ -0,0 +1,68 @@
/**
* Puesto Entity
* Catálogo de puestos de trabajo
*
* @module HR
* @table hr.puestos
* @ddl schemas/02-hr-schema-ddl.sql
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
Index,
} from 'typeorm';
import { Tenant } from '../../core/entities/tenant.entity';
import { Employee } from './employee.entity';
@Entity({ schema: 'hr', name: 'puestos' })
@Index(['tenantId', 'codigo'], { unique: true })
export class Puesto {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ type: 'varchar', length: 20 })
codigo: string;
@Column({ type: 'varchar', length: 100 })
nombre: string;
@Column({ type: 'text', nullable: true })
descripcion: string;
@Column({ name: 'nivel_riesgo', type: 'varchar', length: 20, nullable: true })
nivelRiesgo: string;
@Column({
name: 'requiere_capacitacion_especial',
type: 'boolean',
default: false
})
requiereCapacitacionEspecial: boolean;
@Column({ type: 'boolean', default: true })
activo: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
// Relations
@ManyToOne(() => Tenant)
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@OneToMany(() => Employee, (e) => e.puesto)
empleados: Employee[];
}

View File

@ -0,0 +1,74 @@
/**
* Capacitacion Entity
* Catálogo de capacitaciones HSE
*
* @module HSE
* @table hse.capacitaciones
* @ddl schemas/03-hse-schema-ddl.sql
* @rf RF-MAA017-002
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Tenant } from '../../core/entities/tenant.entity';
export type TipoCapacitacion = 'induccion' | 'especifica' | 'certificacion' | 'reentrenamiento';
@Entity({ schema: 'hse', name: 'capacitaciones' })
@Index(['tenantId', 'codigo'], { unique: true })
export class Capacitacion {
@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: 'enum',
enum: ['induccion', 'especifica', 'certificacion', 'reentrenamiento']
})
tipo: TipoCapacitacion;
@Column({ name: 'duracion_horas', type: 'integer', default: 1 })
duracionHoras: number;
@Column({ name: 'vigencia_meses', type: 'integer', nullable: true })
vigenciaMeses: number;
@Column({ name: 'requiere_evaluacion', type: 'boolean', default: false })
requiereEvaluacion: boolean;
@Column({ name: 'calificacion_minima', type: 'integer', nullable: true })
calificacionMinima: number;
@Column({ type: 'boolean', default: true })
activo: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
// Relations
@ManyToOne(() => Tenant)
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
}

View File

@ -0,0 +1,71 @@
/**
* IncidenteAccion Entity
* Acciones correctivas de incidentes
*
* @module HSE
* @table hse.incidente_acciones
* @ddl schemas/03-hse-schema-ddl.sql
* @rf RF-MAA017-001
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Incidente } from './incidente.entity';
import { Employee } from '../../hr/entities/employee.entity';
export type EstadoAccion = 'pendiente' | 'en_progreso' | 'completada' | 'verificada';
@Entity({ schema: 'hse', name: 'incidente_acciones' })
export class IncidenteAccion {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'incidente_id', type: 'uuid' })
incidenteId: string;
@Column({ type: 'text' })
descripcion: string;
@Column({ type: 'varchar', length: 50 })
tipo: string;
@Column({ name: 'responsable_id', type: 'uuid', nullable: true })
responsableId: string;
@Column({ name: 'fecha_compromiso', type: 'date' })
fechaCompromiso: Date;
@Column({ name: 'fecha_cierre', type: 'date', nullable: true })
fechaCierre: Date;
@Column({ type: 'varchar', length: 20, default: 'pendiente' })
estado: EstadoAccion;
@Column({ name: 'evidencia_url', type: 'varchar', length: 500, nullable: true })
evidenciaUrl: string;
@Column({ type: 'text', nullable: true })
observaciones: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
// Relations
@ManyToOne(() => Incidente, (i) => i.acciones, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'incidente_id' })
incidente: Incidente;
@ManyToOne(() => Employee)
@JoinColumn({ name: 'responsable_id' })
responsable: Employee;
}

View File

@ -0,0 +1,58 @@
/**
* IncidenteInvolucrado Entity
* Personas involucradas en incidentes
*
* @module HSE
* @table hse.incidente_involucrados
* @ddl schemas/03-hse-schema-ddl.sql
* @rf RF-MAA017-001
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Incidente } from './incidente.entity';
import { Employee } from '../../hr/entities/employee.entity';
export type RolInvolucrado = 'lesionado' | 'testigo' | 'responsable';
@Entity({ schema: 'hse', name: 'incidente_involucrados' })
export class IncidenteInvolucrado {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'incidente_id', type: 'uuid' })
incidenteId: string;
@Column({ name: 'employee_id', type: 'uuid' })
employeeId: string;
@Column({ type: 'enum', enum: ['lesionado', 'testigo', 'responsable'] })
rol: RolInvolucrado;
@Column({ name: 'descripcion_lesion', type: 'text', nullable: true })
descripcionLesion: string;
@Column({ name: 'parte_cuerpo', type: 'varchar', length: 100, nullable: true })
parteCuerpo: string;
@Column({ name: 'dias_incapacidad', type: 'integer', default: 0 })
diasIncapacidad: number;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
// Relations
@ManyToOne(() => Incidente, (i) => i.involucrados, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'incidente_id' })
incidente: Incidente;
@ManyToOne(() => Employee)
@JoinColumn({ name: 'employee_id' })
employee: Employee;
}

View File

@ -0,0 +1,111 @@
/**
* Incidente Entity
* Gestión de incidentes de seguridad
*
* @module HSE
* @table hse.incidentes
* @ddl schemas/03-hse-schema-ddl.sql
* @rf RF-MAA017-001
*/
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 { IncidenteInvolucrado } from './incidente-involucrado.entity';
import { IncidenteAccion } from './incidente-accion.entity';
export type TipoIncidente = 'accidente' | 'incidente' | 'casi_accidente';
export type GravedadIncidente = 'leve' | 'moderado' | 'grave' | 'fatal';
export type EstadoIncidente = 'abierto' | 'en_investigacion' | 'cerrado';
@Entity({ schema: 'hse', name: 'incidentes' })
@Index(['tenantId', 'folio'], { unique: true })
export class Incidente {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ type: 'varchar', length: 20 })
folio: string;
@Column({ name: 'fecha_hora', type: 'timestamptz' })
fechaHora: Date;
@Column({ name: 'fraccionamiento_id', type: 'uuid' })
fraccionamientoId: string;
@Column({ name: 'ubicacion_descripcion', type: 'text', nullable: true })
ubicacionDescripcion: string;
@Column({
name: 'ubicacion_geo',
type: 'geometry',
spatialFeatureType: 'Point',
srid: 4326,
nullable: true
})
ubicacionGeo: string;
@Column({ type: 'enum', enum: ['accidente', 'incidente', 'casi_accidente'] })
tipo: TipoIncidente;
@Column({ type: 'enum', enum: ['leve', 'moderado', 'grave', 'fatal'] })
gravedad: GravedadIncidente;
@Column({ type: 'text' })
descripcion: string;
@Column({ name: 'causa_inmediata', type: 'text', nullable: true })
causaInmediata: string;
@Column({ name: 'causa_basica', type: 'text', nullable: true })
causaBasica: string;
@Column({
type: 'enum',
enum: ['abierto', 'en_investigacion', 'cerrado'],
default: 'abierto'
})
estado: EstadoIncidente;
@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(() => Fraccionamiento)
@JoinColumn({ name: 'fraccionamiento_id' })
fraccionamiento: Fraccionamiento;
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by' })
createdBy: User;
@OneToMany(() => IncidenteInvolucrado, (ii) => ii.incidente)
involucrados: IncidenteInvolucrado[];
@OneToMany(() => IncidenteAccion, (ia) => ia.incidente)
acciones: IncidenteAccion[];
}

View File

@ -0,0 +1,23 @@
/**
* HSE Entities Index
* @module HSE
*
* Entities for Health, Safety & Environment module
* Based on RF-MAA017-001 to RF-MAA017-008
*/
// RF-MAA017-001: Gestión de Incidentes
export * from './incidente.entity';
export * from './incidente-involucrado.entity';
export * from './incidente-accion.entity';
// RF-MAA017-002: Control de Capacitaciones
export * from './capacitacion.entity';
// TODO: Implementar entities adicionales según se necesiten:
// - RF-MAA017-003: Inspecciones de Seguridad
// - RF-MAA017-004: Control de EPP
// - RF-MAA017-005: Cumplimiento STPS
// - RF-MAA017-006: Gestión Ambiental
// - RF-MAA017-007: Permisos de Trabajo
// - RF-MAA017-008: Indicadores HSE

View File

@ -0,0 +1,127 @@
/**
* AvanceObra Entity
* Registro de avances fisicos de obra por lote/departamento
*
* @module Progress
* @table construction.avances_obra
* @ddl schemas/01-construction-schema-ddl.sql
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
Index,
Check,
} from 'typeorm';
import { Tenant } from '../../core/entities/tenant.entity';
import { User } from '../../core/entities/user.entity';
import { Concepto } from '../../budgets/entities/concepto.entity';
import { FotoAvance } from './foto-avance.entity';
export type AdvanceStatus = 'pending' | 'captured' | 'reviewed' | 'approved' | 'rejected';
@Entity({ schema: 'construction', name: 'avances_obra' })
@Index(['tenantId'])
@Index(['loteId'])
@Index(['conceptoId'])
@Index(['captureDate'])
@Check(`"lote_id" IS NOT NULL AND "departamento_id" IS NULL OR "lote_id" IS NULL AND "departamento_id" IS NOT NULL`)
export class AvanceObra {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'lote_id', type: 'uuid', nullable: true })
loteId: string | null;
@Column({ name: 'departamento_id', type: 'uuid', nullable: true })
departamentoId: string | null;
@Column({ name: 'concepto_id', type: 'uuid' })
conceptoId: string;
@Column({ name: 'capture_date', type: 'date' })
captureDate: Date;
@Column({ name: 'quantity_executed', type: 'decimal', precision: 12, scale: 4, default: 0 })
quantityExecuted: number;
@Column({ name: 'percentage_executed', type: 'decimal', precision: 5, scale: 2, default: 0 })
percentageExecuted: number;
@Column({
type: 'enum',
enum: ['pending', 'captured', 'reviewed', 'approved', 'rejected'],
enumName: 'construction.advance_status',
default: 'pending',
})
status: AdvanceStatus;
@Column({ type: 'text', nullable: true })
notes: string | null;
@Column({ name: 'captured_by', type: 'uuid' })
capturedById: string;
@Column({ name: 'reviewed_by', type: 'uuid', nullable: true })
reviewedById: string | null;
@Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true })
reviewedAt: Date | null;
@Column({ name: 'approved_by', type: 'uuid', nullable: true })
approvedById: string | null;
@Column({ name: 'approved_at', type: 'timestamptz', nullable: true })
approvedAt: Date | 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)
@JoinColumn({ name: 'concepto_id' })
concepto: Concepto;
@ManyToOne(() => User)
@JoinColumn({ name: 'captured_by' })
capturedBy: User;
@ManyToOne(() => User)
@JoinColumn({ name: 'reviewed_by' })
reviewedBy: User | null;
@ManyToOne(() => User)
@JoinColumn({ name: 'approved_by' })
approvedBy: User | null;
@OneToMany(() => FotoAvance, (f) => f.avance)
fotos: FotoAvance[];
}

View File

@ -0,0 +1,102 @@
/**
* BitacoraObra Entity
* Registro diario de bitacora de obra
*
* @module Progress
* @table construction.bitacora_obra
* @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 { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity';
@Entity({ schema: 'construction', name: 'bitacora_obra' })
@Index(['fraccionamientoId', 'entryNumber'], { unique: true })
@Index(['tenantId'])
@Index(['fraccionamientoId'])
export class BitacoraObra {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'fraccionamiento_id', type: 'uuid' })
fraccionamientoId: string;
@Column({ name: 'entry_date', type: 'date' })
entryDate: Date;
@Column({ name: 'entry_number', type: 'integer' })
entryNumber: number;
@Column({ type: 'varchar', length: 50, nullable: true })
weather: string | null;
@Column({ name: 'temperature_max', type: 'decimal', precision: 4, scale: 1, nullable: true })
temperatureMax: number | null;
@Column({ name: 'temperature_min', type: 'decimal', precision: 4, scale: 1, nullable: true })
temperatureMin: number | null;
@Column({ name: 'workers_count', type: 'integer', default: 0 })
workersCount: number;
@Column({ type: 'text' })
description: string;
@Column({ type: 'text', nullable: true })
observations: string | null;
@Column({ type: 'text', nullable: true })
incidents: string | null;
@Column({ name: 'registered_by', type: 'uuid' })
registeredById: string;
@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)
@JoinColumn({ name: 'fraccionamiento_id' })
fraccionamiento: Fraccionamiento;
@ManyToOne(() => User)
@JoinColumn({ name: 'registered_by' })
registeredBy: User;
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by' })
createdBy: User | null;
}

View File

@ -0,0 +1,87 @@
/**
* FotoAvance Entity
* Evidencia fotografica de avances de obra
*
* @module Progress
* @table construction.fotos_avance
* @ddl schemas/01-construction-schema-ddl.sql
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Tenant } from '../../core/entities/tenant.entity';
import { User } from '../../core/entities/user.entity';
import { AvanceObra } from './avance-obra.entity';
@Entity({ schema: 'construction', name: 'fotos_avance' })
@Index(['tenantId'])
@Index(['avanceId'])
export class FotoAvance {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'avance_id', type: 'uuid' })
avanceId: string;
@Column({ name: 'file_url', type: 'varchar', length: 500 })
fileUrl: string;
@Column({ name: 'file_name', type: 'varchar', length: 255, nullable: true })
fileName: string | null;
@Column({ name: 'file_size', type: 'integer', nullable: true })
fileSize: number | null;
@Column({ name: 'mime_type', type: 'varchar', length: 50, nullable: true })
mimeType: string | null;
@Column({ type: 'text', nullable: true })
description: string | null;
// PostGIS Point para ubicacion GPS
@Column({
type: 'geometry',
spatialFeatureType: 'Point',
srid: 4326,
nullable: true,
})
location: string | null;
@Column({ name: 'captured_at', type: 'timestamptz', default: () => 'NOW()' })
capturedAt: Date;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdById: 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(() => AvanceObra, (a) => a.fotos)
@JoinColumn({ name: 'avance_id' })
avance: AvanceObra;
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by' })
createdBy: User | null;
}

View File

@ -0,0 +1,10 @@
/**
* Progress Module - Entity Exports
* MAI-005: Control de Obra
*/
export * from './avance-obra.entity';
export * from './foto-avance.entity';
export * from './bitacora-obra.entity';
export * from './programa-obra.entity';
export * from './programa-actividad.entity';

View File

@ -0,0 +1,107 @@
/**
* ProgramaActividad Entity
* Actividades del programa de obra (WBS)
*
* @module Progress
* @table construction.programa_actividades
* @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 { Concepto } from '../../budgets/entities/concepto.entity';
import { ProgramaObra } from './programa-obra.entity';
@Entity({ schema: 'construction', name: 'programa_actividades' })
@Index(['tenantId'])
@Index(['programaId'])
export class ProgramaActividad {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'programa_id', type: 'uuid' })
programaId: string;
@Column({ name: 'concepto_id', type: 'uuid', nullable: true })
conceptoId: string | null;
@Column({ name: 'parent_id', type: 'uuid', nullable: true })
parentId: string | null;
@Column({ type: 'varchar', length: 255 })
name: string;
@Column({ type: 'integer', default: 0 })
sequence: number;
@Column({ name: 'planned_start', type: 'date', nullable: true })
plannedStart: Date | null;
@Column({ name: 'planned_end', type: 'date', nullable: true })
plannedEnd: Date | null;
@Column({ name: 'planned_quantity', type: 'decimal', precision: 12, scale: 4, default: 0 })
plannedQuantity: number;
@Column({ name: 'planned_weight', type: 'decimal', precision: 8, scale: 4, default: 0 })
plannedWeight: number;
@Column({ name: 'wbs_code', type: 'varchar', length: 50, nullable: true })
wbsCode: 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(() => ProgramaObra, (p) => p.actividades)
@JoinColumn({ name: 'programa_id' })
programa: ProgramaObra;
@ManyToOne(() => Concepto, { nullable: true })
@JoinColumn({ name: 'concepto_id' })
concepto: Concepto | null;
@ManyToOne(() => ProgramaActividad, (a) => a.children, { nullable: true })
@JoinColumn({ name: 'parent_id' })
parent: ProgramaActividad | null;
@OneToMany(() => ProgramaActividad, (a) => a.parent)
children: ProgramaActividad[];
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by' })
createdBy: User | null;
}

View File

@ -0,0 +1,91 @@
/**
* ProgramaObra Entity
* Programa maestro de obra (planificacion)
*
* @module Progress
* @table construction.programa_obra
* @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 { ProgramaActividad } from './programa-actividad.entity';
@Entity({ schema: 'construction', name: 'programa_obra' })
@Index(['tenantId', 'code', 'version'], { unique: true })
@Index(['tenantId'])
@Index(['fraccionamientoId'])
export class ProgramaObra {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'fraccionamiento_id', type: 'uuid' })
fraccionamientoId: string;
@Column({ type: 'varchar', length: 30 })
code: string;
@Column({ type: 'varchar', length: 255 })
name: string;
@Column({ type: 'integer', default: 1 })
version: number;
@Column({ name: 'start_date', type: 'date' })
startDate: Date;
@Column({ name: 'end_date', type: 'date' })
endDate: Date;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@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)
@JoinColumn({ name: 'fraccionamiento_id' })
fraccionamiento: Fraccionamiento;
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by' })
createdBy: User | null;
@OneToMany(() => ProgramaActividad, (a) => a.programa)
actividades: ProgramaActividad[];
}

View File

@ -0,0 +1,284 @@
/**
* AvanceObraService - Gestión de Avances de Obra
*
* Gestiona el registro y aprobación de avances físicos de obra.
* Incluye workflow de captura -> revisión -> aprobación.
*
* @module Progress
*/
import { Repository, Between, MoreThanOrEqual, LessThanOrEqual } from 'typeorm';
import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
import { AvanceObra, AdvanceStatus } from '../entities/avance-obra.entity';
import { FotoAvance } from '../entities/foto-avance.entity';
export interface CreateAvanceDto {
loteId?: string;
departamentoId?: string;
conceptoId: string;
captureDate: Date;
quantityExecuted: number;
percentageExecuted?: number;
notes?: string;
}
export interface AddFotoDto {
fileUrl: string;
fileName?: string;
fileSize?: number;
mimeType?: string;
description?: string;
location?: { lat: number; lng: number };
}
export interface AvanceFilters {
loteId?: string;
departamentoId?: string;
conceptoId?: string;
status?: AdvanceStatus;
dateFrom?: Date;
dateTo?: Date;
}
export class AvanceObraService extends BaseService<AvanceObra> {
constructor(
repository: Repository<AvanceObra>,
private readonly fotoRepository: Repository<FotoAvance>
) {
super(repository);
}
/**
* Crear nuevo avance (captura)
*/
async createAvance(
ctx: ServiceContext,
data: CreateAvanceDto
): Promise<AvanceObra> {
if (!data.loteId && !data.departamentoId) {
throw new Error('Either loteId or departamentoId is required');
}
if (data.loteId && data.departamentoId) {
throw new Error('Cannot specify both loteId and departamentoId');
}
return this.create(ctx, {
...data,
status: 'captured',
capturedById: ctx.userId,
});
}
/**
* Obtener avances por lote
*/
async findByLote(
ctx: ServiceContext,
loteId: string,
page = 1,
limit = 20
): Promise<PaginatedResult<AvanceObra>> {
return this.findAll(ctx, {
page,
limit,
where: { loteId } as any,
});
}
/**
* Obtener avances por departamento
*/
async findByDepartamento(
ctx: ServiceContext,
departamentoId: string,
page = 1,
limit = 20
): Promise<PaginatedResult<AvanceObra>> {
return this.findAll(ctx, {
page,
limit,
where: { departamentoId } as any,
});
}
/**
* Obtener avances con filtros
*/
async findWithFilters(
ctx: ServiceContext,
filters: AvanceFilters,
page = 1,
limit = 20
): Promise<PaginatedResult<AvanceObra>> {
const qb = this.repository
.createQueryBuilder('a')
.where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('a.deleted_at IS NULL');
if (filters.loteId) {
qb.andWhere('a.lote_id = :loteId', { loteId: filters.loteId });
}
if (filters.departamentoId) {
qb.andWhere('a.departamento_id = :departamentoId', { departamentoId: filters.departamentoId });
}
if (filters.conceptoId) {
qb.andWhere('a.concepto_id = :conceptoId', { conceptoId: filters.conceptoId });
}
if (filters.status) {
qb.andWhere('a.status = :status', { status: filters.status });
}
if (filters.dateFrom) {
qb.andWhere('a.capture_date >= :dateFrom', { dateFrom: filters.dateFrom });
}
if (filters.dateTo) {
qb.andWhere('a.capture_date <= :dateTo', { dateTo: filters.dateTo });
}
const skip = (page - 1) * limit;
qb.orderBy('a.capture_date', 'DESC').skip(skip).take(limit);
const [data, total] = await qb.getManyAndCount();
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Obtener avance con fotos
*/
async findWithFotos(ctx: ServiceContext, id: string): Promise<AvanceObra | null> {
return this.repository.findOne({
where: {
id,
tenantId: ctx.tenantId,
deletedAt: null,
} as any,
relations: ['fotos', 'concepto', 'capturedBy'],
});
}
/**
* Agregar foto al avance
*/
async addFoto(
ctx: ServiceContext,
avanceId: string,
data: AddFotoDto
): Promise<FotoAvance> {
const avance = await this.findById(ctx, avanceId);
if (!avance) {
throw new Error('Avance not found');
}
const location = data.location
? `POINT(${data.location.lng} ${data.location.lat})`
: null;
const foto = this.fotoRepository.create({
tenantId: ctx.tenantId,
avanceId,
fileUrl: data.fileUrl,
fileName: data.fileName,
fileSize: data.fileSize,
mimeType: data.mimeType,
description: data.description,
location,
createdById: ctx.userId,
});
return this.fotoRepository.save(foto);
}
/**
* Revisar avance
*/
async review(ctx: ServiceContext, avanceId: string): Promise<AvanceObra | null> {
const avance = await this.findById(ctx, avanceId);
if (!avance || avance.status !== 'captured') {
return null;
}
return this.update(ctx, avanceId, {
status: 'reviewed',
reviewedById: ctx.userId,
reviewedAt: new Date(),
});
}
/**
* Aprobar avance
*/
async approve(ctx: ServiceContext, avanceId: string): Promise<AvanceObra | null> {
const avance = await this.findById(ctx, avanceId);
if (!avance || avance.status !== 'reviewed') {
return null;
}
return this.update(ctx, avanceId, {
status: 'approved',
approvedById: ctx.userId,
approvedAt: new Date(),
});
}
/**
* Rechazar avance
*/
async reject(
ctx: ServiceContext,
avanceId: string,
reason: string
): Promise<AvanceObra | null> {
const avance = await this.findById(ctx, avanceId);
if (!avance || !['captured', 'reviewed'].includes(avance.status)) {
return null;
}
return this.update(ctx, avanceId, {
status: 'rejected',
notes: reason,
});
}
/**
* Calcular avance acumulado por concepto
*/
async getAccumulatedProgress(
ctx: ServiceContext,
loteId?: string,
departamentoId?: string
): Promise<ConceptProgress[]> {
const qb = this.repository
.createQueryBuilder('a')
.select('a.concepto_id', 'conceptoId')
.addSelect('SUM(a.quantity_executed)', 'totalQuantity')
.addSelect('AVG(a.percentage_executed)', 'avgPercentage')
.where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('a.deleted_at IS NULL')
.andWhere('a.status = :status', { status: 'approved' })
.groupBy('a.concepto_id');
if (loteId) {
qb.andWhere('a.lote_id = :loteId', { loteId });
}
if (departamentoId) {
qb.andWhere('a.departamento_id = :departamentoId', { departamentoId });
}
return qb.getRawMany();
}
}
interface ConceptProgress {
conceptoId: string;
totalQuantity: number;
avgPercentage: number;
}

View File

@ -0,0 +1,209 @@
/**
* BitacoraObraService - Bitácora de Obra
*
* Gestiona el registro diario de bitácora de obra.
* Genera automáticamente el número de entrada secuencial.
*
* @module Progress
*/
import { Repository } from 'typeorm';
import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
import { BitacoraObra } from '../entities/bitacora-obra.entity';
export interface CreateBitacoraDto {
fraccionamientoId: string;
entryDate: Date;
weather?: string;
temperatureMax?: number;
temperatureMin?: number;
workersCount?: number;
description: string;
observations?: string;
incidents?: string;
}
export interface UpdateBitacoraDto {
weather?: string;
temperatureMax?: number;
temperatureMin?: number;
workersCount?: number;
description?: string;
observations?: string;
incidents?: string;
}
export interface BitacoraFilters {
dateFrom?: Date;
dateTo?: Date;
hasIncidents?: boolean;
}
export class BitacoraObraService extends BaseService<BitacoraObra> {
constructor(repository: Repository<BitacoraObra>) {
super(repository);
}
/**
* Crear nueva entrada de bitácora
*/
async createEntry(
ctx: ServiceContext,
data: CreateBitacoraDto
): Promise<BitacoraObra> {
const entryNumber = await this.getNextEntryNumber(ctx, data.fraccionamientoId);
return this.create(ctx, {
...data,
entryNumber,
registeredById: ctx.userId,
});
}
/**
* Obtener siguiente número de entrada
*/
private async getNextEntryNumber(
ctx: ServiceContext,
fraccionamientoId: string
): Promise<number> {
const result = await this.repository
.createQueryBuilder('b')
.select('MAX(b.entry_number)', 'maxNumber')
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('b.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId })
.getRawOne();
return (result?.maxNumber || 0) + 1;
}
/**
* Obtener bitácora por fraccionamiento
*/
async findByFraccionamiento(
ctx: ServiceContext,
fraccionamientoId: string,
page = 1,
limit = 20
): Promise<PaginatedResult<BitacoraObra>> {
return this.findAll(ctx, {
page,
limit,
where: { fraccionamientoId } as any,
});
}
/**
* Obtener bitácora con filtros
*/
async findWithFilters(
ctx: ServiceContext,
fraccionamientoId: string,
filters: BitacoraFilters,
page = 1,
limit = 20
): Promise<PaginatedResult<BitacoraObra>> {
const qb = this.repository
.createQueryBuilder('b')
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('b.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId })
.andWhere('b.deleted_at IS NULL');
if (filters.dateFrom) {
qb.andWhere('b.entry_date >= :dateFrom', { dateFrom: filters.dateFrom });
}
if (filters.dateTo) {
qb.andWhere('b.entry_date <= :dateTo', { dateTo: filters.dateTo });
}
if (filters.hasIncidents !== undefined) {
if (filters.hasIncidents) {
qb.andWhere('b.incidents IS NOT NULL');
} else {
qb.andWhere('b.incidents IS NULL');
}
}
const skip = (page - 1) * limit;
qb.orderBy('b.entry_date', 'DESC').skip(skip).take(limit);
const [data, total] = await qb.getManyAndCount();
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Obtener entrada por fecha
*/
async findByDate(
ctx: ServiceContext,
fraccionamientoId: string,
date: Date
): Promise<BitacoraObra | null> {
return this.findOne(ctx, {
fraccionamientoId,
entryDate: date,
} as any);
}
/**
* Obtener última entrada
*/
async findLatest(
ctx: ServiceContext,
fraccionamientoId: string
): Promise<BitacoraObra | null> {
const entries = await this.find(ctx, {
where: { fraccionamientoId } as any,
order: { entryNumber: 'DESC' },
take: 1,
});
return entries[0] || null;
}
/**
* Obtener estadísticas de bitácora
*/
async getStats(
ctx: ServiceContext,
fraccionamientoId: string
): Promise<BitacoraStats> {
const totalEntries = await this.count(ctx, { fraccionamientoId } as any);
const incidentsCount = await this.repository
.createQueryBuilder('b')
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('b.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId })
.andWhere('b.deleted_at IS NULL')
.andWhere('b.incidents IS NOT NULL')
.getCount();
const avgWorkers = await this.repository
.createQueryBuilder('b')
.select('AVG(b.workers_count)', 'avg')
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('b.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId })
.andWhere('b.deleted_at IS NULL')
.getRawOne();
return {
totalEntries,
entriesWithIncidents: incidentsCount,
avgWorkersCount: parseFloat(avgWorkers?.avg || '0'),
};
}
}
interface BitacoraStats {
totalEntries: number;
entriesWithIncidents: number;
avgWorkersCount: number;
}

View File

@ -0,0 +1,7 @@
/**
* Progress Module - Service Exports
* MAI-005: Control de Obra
*/
export * from './avance-obra.service';
export * from './bitacora-obra.service';

120
src/server.ts Normal file
View File

@ -0,0 +1,120 @@
/**
* Server Entry Point
* MVP Sistema Administración de Obra e INFONAVIT
*
* @author Backend-Agent
* @date 2025-11-20
*/
import 'reflect-metadata';
import express, { Application } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import dotenv from 'dotenv';
import { AppDataSource } from './shared/database/typeorm.config';
// Cargar variables de entorno
dotenv.config();
const app: Application = express();
const PORT = process.env.APP_PORT || 3000;
const API_VERSION = process.env.API_VERSION || 'v1';
/**
* Middlewares
*/
app.use(helmet()); // Seguridad HTTP headers
app.use(cors({
origin: process.env.CORS_ORIGIN?.split(',') || '*',
credentials: process.env.CORS_CREDENTIALS === 'true',
}));
app.use(morgan(process.env.LOG_FORMAT || 'dev')); // Logging
app.use(express.json()); // Parse JSON bodies
app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies
/**
* Health Check
*/
app.get('/health', (_req, res) => {
res.status(200).json({
status: 'ok',
timestamp: new Date().toISOString(),
environment: process.env.NODE_ENV || 'development',
version: API_VERSION,
});
});
/**
* API Routes
*/
import { proyectoController, fraccionamientoController } from './modules/construction/controllers';
// Root API info
app.get(`/api/${API_VERSION}`, (_req, res) => {
res.status(200).json({
message: 'API MVP Sistema Administración de Obra',
version: API_VERSION,
endpoints: {
health: '/health',
docs: `/api/${API_VERSION}/docs`,
auth: `/api/${API_VERSION}/auth`,
proyectos: `/api/${API_VERSION}/proyectos`,
fraccionamientos: `/api/${API_VERSION}/fraccionamientos`,
},
});
});
// Construction Module Routes
app.use(`/api/${API_VERSION}/proyectos`, proyectoController);
app.use(`/api/${API_VERSION}/fraccionamientos`, fraccionamientoController);
/**
* 404 Handler
*/
app.use((req, res) => {
res.status(404).json({
error: 'Not Found',
message: `Cannot ${req.method} ${req.path}`,
});
});
/**
* Error Handler
*/
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error('Error:', err);
res.status(500).json({
error: 'Internal Server Error',
message: process.env.NODE_ENV === 'development' ? err.message : 'Something went wrong',
});
});
/**
* Inicializar Base de Datos y Servidor
*/
async function bootstrap() {
try {
// Conectar a base de datos
console.log('🔌 Conectando a base de datos...');
await AppDataSource.initialize();
console.log('✅ Base de datos conectada');
// Iniciar servidor
app.listen(PORT, () => {
console.log('🚀 Servidor iniciado');
console.log(`📍 URL: http://localhost:${PORT}`);
console.log(`📍 API: http://localhost:${PORT}/api/${API_VERSION}`);
console.log(`📍 Health: http://localhost:${PORT}/health`);
console.log(`🌍 Entorno: ${process.env.NODE_ENV || 'development'}`);
});
} catch (error) {
console.error('❌ Error al iniciar servidor:', error);
process.exit(1);
}
}
// Iniciar aplicación
bootstrap();
export default app;

View File

@ -0,0 +1,249 @@
/**
* API Constants - SSOT (Single Source of Truth)
*
* Todas las rutas de API, versiones y endpoints.
* NO hardcodear rutas en controllers o frontend.
*
* @module @shared/constants/api
*/
/**
* API Version
*/
export const API_VERSION = 'v1';
export const API_PREFIX = `/api/${API_VERSION}`;
/**
* API Routes organized by Module
*/
export const API_ROUTES = {
// Base
ROOT: '/',
HEALTH: '/health',
DOCS: `${API_PREFIX}/docs`,
// Auth Module
AUTH: {
BASE: `${API_PREFIX}/auth`,
LOGIN: `${API_PREFIX}/auth/login`,
LOGOUT: `${API_PREFIX}/auth/logout`,
REFRESH: `${API_PREFIX}/auth/refresh`,
REGISTER: `${API_PREFIX}/auth/register`,
FORGOT_PASSWORD: `${API_PREFIX}/auth/forgot-password`,
RESET_PASSWORD: `${API_PREFIX}/auth/reset-password`,
ME: `${API_PREFIX}/auth/me`,
CHANGE_PASSWORD: `${API_PREFIX}/auth/change-password`,
},
// Users Module
USERS: {
BASE: `${API_PREFIX}/users`,
BY_ID: (id: string) => `${API_PREFIX}/users/${id}`,
ROLES: (id: string) => `${API_PREFIX}/users/${id}/roles`,
},
// Tenants Module
TENANTS: {
BASE: `${API_PREFIX}/tenants`,
BY_ID: (id: string) => `${API_PREFIX}/tenants/${id}`,
CURRENT: `${API_PREFIX}/tenants/current`,
},
// Construction Module
PROYECTOS: {
BASE: `${API_PREFIX}/proyectos`,
BY_ID: (id: string) => `${API_PREFIX}/proyectos/${id}`,
FRACCIONAMIENTOS: (id: string) => `${API_PREFIX}/proyectos/${id}/fraccionamientos`,
DASHBOARD: (id: string) => `${API_PREFIX}/proyectos/${id}/dashboard`,
PROGRESS: (id: string) => `${API_PREFIX}/proyectos/${id}/progress`,
},
FRACCIONAMIENTOS: {
BASE: `${API_PREFIX}/fraccionamientos`,
BY_ID: (id: string) => `${API_PREFIX}/fraccionamientos/${id}`,
ETAPAS: (id: string) => `${API_PREFIX}/fraccionamientos/${id}/etapas`,
MANZANAS: (id: string) => `${API_PREFIX}/fraccionamientos/${id}/manzanas`,
LOTES: (id: string) => `${API_PREFIX}/fraccionamientos/${id}/lotes`,
},
PRESUPUESTOS: {
BASE: `${API_PREFIX}/presupuestos`,
BY_ID: (id: string) => `${API_PREFIX}/presupuestos/${id}`,
PARTIDAS: (id: string) => `${API_PREFIX}/presupuestos/${id}/partidas`,
COMPARE: (id: string) => `${API_PREFIX}/presupuestos/${id}/compare`,
VERSIONS: (id: string) => `${API_PREFIX}/presupuestos/${id}/versions`,
},
AVANCES: {
BASE: `${API_PREFIX}/avances`,
BY_ID: (id: string) => `${API_PREFIX}/avances/${id}`,
BY_PROYECTO: (proyectoId: string) => `${API_PREFIX}/proyectos/${proyectoId}/avances`,
CURVA_S: (proyectoId: string) => `${API_PREFIX}/proyectos/${proyectoId}/curva-s`,
FOTOS: (id: string) => `${API_PREFIX}/avances/${id}/fotos`,
},
// HR Module
EMPLOYEES: {
BASE: `${API_PREFIX}/employees`,
BY_ID: (id: string) => `${API_PREFIX}/employees/${id}`,
ASISTENCIAS: (id: string) => `${API_PREFIX}/employees/${id}/asistencias`,
CAPACITACIONES: (id: string) => `${API_PREFIX}/employees/${id}/capacitaciones`,
},
ASISTENCIAS: {
BASE: `${API_PREFIX}/asistencias`,
BY_ID: (id: string) => `${API_PREFIX}/asistencias/${id}`,
CHECK_IN: `${API_PREFIX}/asistencias/check-in`,
CHECK_OUT: `${API_PREFIX}/asistencias/check-out`,
BY_PROYECTO: (proyectoId: string) => `${API_PREFIX}/proyectos/${proyectoId}/asistencias`,
},
CUADRILLAS: {
BASE: `${API_PREFIX}/cuadrillas`,
BY_ID: (id: string) => `${API_PREFIX}/cuadrillas/${id}`,
MIEMBROS: (id: string) => `${API_PREFIX}/cuadrillas/${id}/miembros`,
},
// HSE Module
INCIDENTES: {
BASE: `${API_PREFIX}/incidentes`,
BY_ID: (id: string) => `${API_PREFIX}/incidentes/${id}`,
INVOLUCRADOS: (id: string) => `${API_PREFIX}/incidentes/${id}/involucrados`,
ACCIONES: (id: string) => `${API_PREFIX}/incidentes/${id}/acciones`,
BY_PROYECTO: (proyectoId: string) => `${API_PREFIX}/proyectos/${proyectoId}/incidentes`,
},
CAPACITACIONES: {
BASE: `${API_PREFIX}/capacitaciones`,
BY_ID: (id: string) => `${API_PREFIX}/capacitaciones/${id}`,
PARTICIPANTES: (id: string) => `${API_PREFIX}/capacitaciones/${id}/participantes`,
CERTIFICADOS: (id: string) => `${API_PREFIX}/capacitaciones/${id}/certificados`,
},
INSPECCIONES: {
BASE: `${API_PREFIX}/inspecciones`,
BY_ID: (id: string) => `${API_PREFIX}/inspecciones/${id}`,
HALLAZGOS: (id: string) => `${API_PREFIX}/inspecciones/${id}/hallazgos`,
},
EPP: {
BASE: `${API_PREFIX}/epp`,
BY_ID: (id: string) => `${API_PREFIX}/epp/${id}`,
ASIGNACIONES: `${API_PREFIX}/epp/asignaciones`,
ENTREGAS: `${API_PREFIX}/epp/entregas`,
STOCK: `${API_PREFIX}/epp/stock`,
},
// Estimates Module
ESTIMACIONES: {
BASE: `${API_PREFIX}/estimaciones`,
BY_ID: (id: string) => `${API_PREFIX}/estimaciones/${id}`,
CONCEPTOS: (id: string) => `${API_PREFIX}/estimaciones/${id}/conceptos`,
GENERADORES: (id: string) => `${API_PREFIX}/estimaciones/${id}/generadores`,
WORKFLOW: (id: string) => `${API_PREFIX}/estimaciones/${id}/workflow`,
SUBMIT: (id: string) => `${API_PREFIX}/estimaciones/${id}/submit`,
APPROVE: (id: string) => `${API_PREFIX}/estimaciones/${id}/approve`,
REJECT: (id: string) => `${API_PREFIX}/estimaciones/${id}/reject`,
},
// INFONAVIT Module
INFONAVIT: {
BASE: `${API_PREFIX}/infonavit`,
REGISTRO: `${API_PREFIX}/infonavit/registro`,
OFERTA: `${API_PREFIX}/infonavit/oferta`,
DERECHOHABIENTES: `${API_PREFIX}/infonavit/derechohabientes`,
ASIGNACIONES: `${API_PREFIX}/infonavit/asignaciones`,
ACTAS: `${API_PREFIX}/infonavit/actas`,
REPORTES: `${API_PREFIX}/infonavit/reportes`,
},
// Inventory Module
ALMACENES: {
BASE: `${API_PREFIX}/almacenes`,
BY_ID: (id: string) => `${API_PREFIX}/almacenes/${id}`,
BY_PROYECTO: (proyectoId: string) => `${API_PREFIX}/proyectos/${proyectoId}/almacenes`,
STOCK: (id: string) => `${API_PREFIX}/almacenes/${id}/stock`,
},
REQUISICIONES: {
BASE: `${API_PREFIX}/requisiciones`,
BY_ID: (id: string) => `${API_PREFIX}/requisiciones/${id}`,
LINEAS: (id: string) => `${API_PREFIX}/requisiciones/${id}/lineas`,
SUBMIT: (id: string) => `${API_PREFIX}/requisiciones/${id}/submit`,
APPROVE: (id: string) => `${API_PREFIX}/requisiciones/${id}/approve`,
},
// Purchase Module
COMPRAS: {
BASE: `${API_PREFIX}/compras`,
BY_ID: (id: string) => `${API_PREFIX}/compras/${id}`,
LINEAS: (id: string) => `${API_PREFIX}/compras/${id}/lineas`,
COMPARATIVO: `${API_PREFIX}/compras/comparativo`,
RECEPCIONES: (id: string) => `${API_PREFIX}/compras/${id}/recepciones`,
},
PROVEEDORES: {
BASE: `${API_PREFIX}/proveedores`,
BY_ID: (id: string) => `${API_PREFIX}/proveedores/${id}`,
COTIZACIONES: (id: string) => `${API_PREFIX}/proveedores/${id}/cotizaciones`,
},
// Contracts Module
CONTRATOS: {
BASE: `${API_PREFIX}/contratos`,
BY_ID: (id: string) => `${API_PREFIX}/contratos/${id}`,
PARTIDAS: (id: string) => `${API_PREFIX}/contratos/${id}/partidas`,
ESTIMACIONES: (id: string) => `${API_PREFIX}/contratos/${id}/estimaciones`,
},
// Reports Module
REPORTS: {
BASE: `${API_PREFIX}/reports`,
DASHBOARD: `${API_PREFIX}/reports/dashboard`,
AVANCE_FISICO: `${API_PREFIX}/reports/avance-fisico`,
AVANCE_FINANCIERO: `${API_PREFIX}/reports/avance-financiero`,
CURVA_S: `${API_PREFIX}/reports/curva-s`,
PRESUPUESTO_VS_REAL: `${API_PREFIX}/reports/presupuesto-vs-real`,
KPI_HSE: `${API_PREFIX}/reports/kpi-hse`,
EXPORT: `${API_PREFIX}/reports/export`,
},
} as const;
/**
* HTTP Methods
*/
export const HTTP_METHODS = {
GET: 'GET',
POST: 'POST',
PUT: 'PUT',
PATCH: 'PATCH',
DELETE: 'DELETE',
} as const;
/**
* HTTP Status Codes
*/
export const HTTP_STATUS = {
OK: 200,
CREATED: 201,
NO_CONTENT: 204,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
CONFLICT: 409,
UNPROCESSABLE_ENTITY: 422,
INTERNAL_SERVER_ERROR: 500,
SERVICE_UNAVAILABLE: 503,
} as const;
/**
* Content Types
*/
export const CONTENT_TYPES = {
JSON: 'application/json',
FORM_URLENCODED: 'application/x-www-form-urlencoded',
MULTIPART: 'multipart/form-data',
PDF: 'application/pdf',
EXCEL: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
} as const;

View File

@ -0,0 +1,315 @@
/**
* Database Constants - SSOT (Single Source of Truth)
*
* IMPORTANTE: Este archivo es la UNICA fuente de verdad para nombres de
* schemas, tablas y columnas. Cualquier hardcoding sera detectado por
* el script validate-constants-usage.ts
*
* @module @shared/constants/database
*/
/**
* Database Schemas
* Todos los schemas de la base de datos PostgreSQL
*/
export const DB_SCHEMAS = {
// Auth & Core
AUTH: 'auth',
CORE: 'core',
// Domain Schemas
CONSTRUCTION: 'construction',
HR: 'hr',
HSE: 'hse',
ESTIMATES: 'estimates',
INFONAVIT: 'infonavit',
INVENTORY: 'inventory',
PURCHASE: 'purchase',
// System Schemas
FINANCIAL: 'financial',
ANALYTICS: 'analytics',
AUDIT: 'audit',
SYSTEM: 'system',
} as const;
export type DBSchema = typeof DB_SCHEMAS[keyof typeof DB_SCHEMAS];
/**
* Database Tables organized by Schema
*/
export const DB_TABLES = {
// Auth Schema
[DB_SCHEMAS.AUTH]: {
USERS: 'users',
ROLES: 'roles',
PERMISSIONS: 'permissions',
ROLE_PERMISSIONS: 'role_permissions',
USER_ROLES: 'user_roles',
SESSIONS: 'sessions',
REFRESH_TOKENS: 'refresh_tokens',
TENANTS: 'tenants',
TENANT_USERS: 'tenant_users',
PASSWORD_RESETS: 'password_resets',
},
// Core Schema
[DB_SCHEMAS.CORE]: {
COMPANIES: 'companies',
PARTNERS: 'partners',
CURRENCIES: 'currencies',
COUNTRIES: 'countries',
STATES: 'states',
CITIES: 'cities',
UOM: 'units_of_measure',
UOM_CATEGORIES: 'uom_categories',
SEQUENCES: 'sequences',
ATTACHMENTS: 'attachments',
},
// Construction Schema (24 tables)
[DB_SCHEMAS.CONSTRUCTION]: {
// Project Structure (8)
PROYECTOS: 'proyectos',
FRACCIONAMIENTOS: 'fraccionamientos',
ETAPAS: 'etapas',
MANZANAS: 'manzanas',
LOTES: 'lotes',
TORRES: 'torres',
NIVELES: 'niveles',
DEPARTAMENTOS: 'departamentos',
PROTOTIPOS: 'prototipos',
// Budget & Concepts (3)
CONCEPTOS: 'conceptos',
PRESUPUESTOS: 'presupuestos',
PRESUPUESTO_PARTIDAS: 'presupuesto_partidas',
// Schedule & Progress (5)
PROGRAMA_OBRA: 'programa_obra',
PROGRAMA_ACTIVIDADES: 'programa_actividades',
AVANCES_OBRA: 'avances_obra',
FOTOS_AVANCE: 'fotos_avance',
BITACORA_OBRA: 'bitacora_obra',
// Quality (5)
CHECKLISTS: 'checklists',
CHECKLIST_ITEMS: 'checklist_items',
INSPECCIONES: 'inspecciones',
INSPECCION_RESULTADOS: 'inspeccion_resultados',
TICKETS_POSTVENTA: 'tickets_postventa',
// Contracts (3)
SUBCONTRATISTAS: 'subcontratistas',
CONTRATOS: 'contratos',
CONTRATO_PARTIDAS: 'contrato_partidas',
},
// HR Schema (8 tables)
[DB_SCHEMAS.HR]: {
EMPLOYEES: 'employees',
EMPLOYEE_CONSTRUCTION: 'employee_construction',
PUESTOS: 'puestos',
ASISTENCIAS: 'asistencias',
ASISTENCIA_BIOMETRICO: 'asistencia_biometrico',
GEOCERCAS: 'geocercas',
DESTAJO: 'destajo',
DESTAJO_DETALLE: 'destajo_detalle',
CUADRILLAS: 'cuadrillas',
CUADRILLA_MIEMBROS: 'cuadrilla_miembros',
EMPLOYEE_FRACCIONAMIENTOS: 'employee_fraccionamientos',
},
// HSE Schema (58 tables - main groups)
[DB_SCHEMAS.HSE]: {
// Incidents (5)
INCIDENTES: 'incidentes',
INCIDENTE_INVOLUCRADOS: 'incidente_involucrados',
INCIDENTE_ACCIONES: 'incidente_acciones',
INCIDENTE_EVIDENCIAS: 'incidente_evidencias',
INCIDENTE_CAUSAS: 'incidente_causas',
// Training (6)
CAPACITACIONES: 'capacitaciones',
CAPACITACION_PARTICIPANTES: 'capacitacion_participantes',
CAPACITACION_MATERIALES: 'capacitacion_materiales',
CERTIFICACIONES: 'certificaciones',
CERTIFICACION_EMPLEADOS: 'certificacion_empleados',
PLAN_CAPACITACION: 'plan_capacitacion',
// Inspections (7)
INSPECCIONES_SEGURIDAD: 'inspecciones_seguridad',
INSPECCION_HALLAZGOS: 'inspeccion_hallazgos',
CHECKLIST_SEGURIDAD: 'checklist_seguridad',
CHECKLIST_SEGURIDAD_ITEMS: 'checklist_seguridad_items',
AREAS_RIESGO: 'areas_riesgo',
RONDAS_SEGURIDAD: 'rondas_seguridad',
RONDA_PUNTOS: 'ronda_puntos',
// EPP (7)
EPP_CATALOGO: 'epp_catalogo',
EPP_ASIGNACIONES: 'epp_asignaciones',
EPP_ENTREGAS: 'epp_entregas',
EPP_DEVOLUCIONES: 'epp_devoluciones',
EPP_INSPECCIONES: 'epp_inspecciones',
EPP_VIDA_UTIL: 'epp_vida_util',
EPP_STOCK: 'epp_stock',
// STPS Compliance (11)
NORMAS_STPS: 'normas_stps',
REQUISITOS_NORMA: 'requisitos_norma',
CUMPLIMIENTO_NORMA: 'cumplimiento_norma',
AUDITORIAS_STPS: 'auditorias_stps',
AUDITORIA_HALLAZGOS: 'auditoria_hallazgos',
PLANES_ACCION: 'planes_accion',
ACCIONES_CORRECTIVAS: 'acciones_correctivas',
COMISION_SEGURIDAD: 'comision_seguridad',
COMISION_MIEMBROS: 'comision_miembros',
RECORRIDOS_COMISION: 'recorridos_comision',
ACTAS_COMISION: 'actas_comision',
// Environmental (9)
IMPACTOS_AMBIENTALES: 'impactos_ambientales',
RESIDUOS: 'residuos',
RESIDUO_MOVIMIENTOS: 'residuo_movimientos',
MANIFIESTOS_RESIDUOS: 'manifiestos_residuos',
MONITOREO_AMBIENTAL: 'monitoreo_ambiental',
PERMISOS_AMBIENTALES: 'permisos_ambientales',
PROGRAMAS_AMBIENTALES: 'programas_ambientales',
INDICADORES_AMBIENTALES: 'indicadores_ambientales',
EVENTOS_AMBIENTALES: 'eventos_ambientales',
// Work Permits (8)
PERMISOS_TRABAJO: 'permisos_trabajo',
PERMISO_RIESGOS: 'permiso_riesgos',
PERMISO_AUTORIZACIONES: 'permiso_autorizaciones',
PERMISOS_ALTURA: 'permisos_altura',
PERMISOS_CALIENTE: 'permisos_caliente',
PERMISOS_CONFINADO: 'permisos_confinado',
PERMISOS_ELECTRICO: 'permisos_electrico',
PERMISOS_EXCAVACION: 'permisos_excavacion',
// KPIs (7)
KPI_CONFIGURACION: 'kpi_configuracion',
KPI_VALORES: 'kpi_valores',
KPI_METAS: 'kpi_metas',
DASHBOARDS_HSE: 'dashboards_hse',
ALERTAS_HSE: 'alertas_hse',
REPORTES_HSE: 'reportes_hse',
ESTADISTICAS_PERIODO: 'estadisticas_periodo',
},
// Estimates Schema (8 tables)
[DB_SCHEMAS.ESTIMATES]: {
ESTIMACIONES: 'estimaciones',
ESTIMACION_CONCEPTOS: 'estimacion_conceptos',
GENERADORES: 'generadores',
ANTICIPOS: 'anticipos',
AMORTIZACIONES: 'amortizaciones',
RETENCIONES: 'retenciones',
FONDO_GARANTIA: 'fondo_garantia',
ESTIMACION_WORKFLOW: 'estimacion_workflow',
},
// INFONAVIT Schema (8 tables)
[DB_SCHEMAS.INFONAVIT]: {
REGISTRO_INFONAVIT: 'registro_infonavit',
OFERTA_VIVIENDA: 'oferta_vivienda',
DERECHOHABIENTES: 'derechohabientes',
ASIGNACION_VIVIENDA: 'asignacion_vivienda',
ACTAS: 'actas',
ACTA_VIVIENDAS: 'acta_viviendas',
REPORTES_INFONAVIT: 'reportes_infonavit',
HISTORICO_PUNTOS: 'historico_puntos',
},
// Inventory Extension Schema (4 tables)
[DB_SCHEMAS.INVENTORY]: {
ALMACENES_PROYECTO: 'almacenes_proyecto',
REQUISICIONES_OBRA: 'requisiciones_obra',
REQUISICION_LINEAS: 'requisicion_lineas',
CONSUMOS_OBRA: 'consumos_obra',
// Base tables (reference)
PRODUCTS: 'products',
LOCATIONS: 'locations',
STOCK_MOVES: 'stock_moves',
STOCK_QUANTS: 'stock_quants',
},
// Purchase Extension Schema (5 tables)
[DB_SCHEMAS.PURCHASE]: {
PURCHASE_ORDER_CONSTRUCTION: 'purchase_order_construction',
SUPPLIER_CONSTRUCTION: 'supplier_construction',
COMPARATIVO_COTIZACIONES: 'comparativo_cotizaciones',
COMPARATIVO_PROVEEDORES: 'comparativo_proveedores',
COMPARATIVO_PRODUCTOS: 'comparativo_productos',
// Base tables (reference)
PURCHASE_ORDERS: 'purchase_orders',
PURCHASE_ORDER_LINES: 'purchase_order_lines',
SUPPLIERS: 'suppliers',
},
// Audit Schema
[DB_SCHEMAS.AUDIT]: {
AUDIT_LOG: 'audit_log',
CHANGE_HISTORY: 'change_history',
USER_ACTIVITY: 'user_activity',
},
} as const;
/**
* Common Column Names (to avoid hardcoding)
*/
export const DB_COLUMNS = {
// Audit columns
ID: 'id',
CREATED_AT: 'created_at',
UPDATED_AT: 'updated_at',
CREATED_BY: 'created_by',
UPDATED_BY: 'updated_by',
DELETED_AT: 'deleted_at',
// Multi-tenant columns
TENANT_ID: 'tenant_id',
// Common FK columns
USER_ID: 'user_id',
PROJECT_ID: 'proyecto_id',
FRACCIONAMIENTO_ID: 'fraccionamiento_id',
EMPLOYEE_ID: 'employee_id',
// Status columns
STATUS: 'status',
IS_ACTIVE: 'is_active',
// Analytic columns (Odoo pattern)
ANALYTIC_ACCOUNT_ID: 'analytic_account_id',
} as const;
/**
* Helper function to get full table name
*/
export function getFullTableName(schema: DBSchema, table: string): string {
return `${schema}.${table}`;
}
/**
* Get schema.table reference
*/
export const TABLE_REFS = {
// Auth
USERS: getFullTableName(DB_SCHEMAS.AUTH, DB_TABLES[DB_SCHEMAS.AUTH].USERS),
TENANTS: getFullTableName(DB_SCHEMAS.AUTH, DB_TABLES[DB_SCHEMAS.AUTH].TENANTS),
ROLES: getFullTableName(DB_SCHEMAS.AUTH, DB_TABLES[DB_SCHEMAS.AUTH].ROLES),
// Construction
PROYECTOS: getFullTableName(DB_SCHEMAS.CONSTRUCTION, DB_TABLES[DB_SCHEMAS.CONSTRUCTION].PROYECTOS),
FRACCIONAMIENTOS: getFullTableName(DB_SCHEMAS.CONSTRUCTION, DB_TABLES[DB_SCHEMAS.CONSTRUCTION].FRACCIONAMIENTOS),
// HR
EMPLOYEES: getFullTableName(DB_SCHEMAS.HR, DB_TABLES[DB_SCHEMAS.HR].EMPLOYEES),
// HSE
INCIDENTES: getFullTableName(DB_SCHEMAS.HSE, DB_TABLES[DB_SCHEMAS.HSE].INCIDENTES),
CAPACITACIONES: getFullTableName(DB_SCHEMAS.HSE, DB_TABLES[DB_SCHEMAS.HSE].CAPACITACIONES),
} as const;

View File

@ -0,0 +1,494 @@
/**
* Enums Constants - SSOT (Single Source of Truth)
*
* Todos los enums del sistema. Estos se sincronizan automaticamente
* al frontend usando el script sync-enums.ts
*
* @module @shared/constants/enums
*/
// ============================================================================
// AUTH & USERS
// ============================================================================
/**
* Roles del sistema de construccion
*/
export const ROLES = {
// Super Admin (Plataforma)
SUPER_ADMIN: 'super_admin',
// Tenant Admin
ADMIN: 'admin',
// Direccion
DIRECTOR_GENERAL: 'director_general',
DIRECTOR_PROYECTOS: 'director_proyectos',
DIRECTOR_CONSTRUCCION: 'director_construccion',
// Gerencias
GERENTE_ADMINISTRATIVO: 'gerente_administrativo',
GERENTE_OPERACIONES: 'gerente_operaciones',
// Ingenieria y Control
INGENIERO_RESIDENTE: 'ingeniero_residente',
INGENIERO_COSTOS: 'ingeniero_costos',
CONTROL_OBRA: 'control_obra',
PLANEADOR: 'planeador',
// Supervisores
SUPERVISOR_OBRA: 'supervisor_obra',
SUPERVISOR_HSE: 'supervisor_hse',
SUPERVISOR_CALIDAD: 'supervisor_calidad',
// Compras y Almacen
COMPRAS: 'compras',
ALMACENISTA: 'almacenista',
// RRHH
RRHH: 'rrhh',
NOMINA: 'nomina',
// Finanzas
CONTADOR: 'contador',
TESORERO: 'tesorero',
// Postventa
POSTVENTA: 'postventa',
// Externos
SUBCONTRATISTA: 'subcontratista',
PROVEEDOR: 'proveedor',
DERECHOHABIENTE: 'derechohabiente',
// Solo lectura
VIEWER: 'viewer',
} as const;
export type Role = typeof ROLES[keyof typeof ROLES];
/**
* Estados de cuenta de usuario
*/
export const USER_STATUS = {
ACTIVE: 'active',
INACTIVE: 'inactive',
PENDING: 'pending',
SUSPENDED: 'suspended',
BLOCKED: 'blocked',
} as const;
export type UserStatus = typeof USER_STATUS[keyof typeof USER_STATUS];
// ============================================================================
// PROYECTOS Y ESTRUCTURA
// ============================================================================
/**
* Estados de proyecto
*/
export const PROJECT_STATUS = {
DRAFT: 'draft',
PLANNING: 'planning',
BIDDING: 'bidding',
AWARDED: 'awarded',
ACTIVE: 'active',
PAUSED: 'paused',
COMPLETED: 'completed',
CANCELLED: 'cancelled',
} as const;
export type ProjectStatus = typeof PROJECT_STATUS[keyof typeof PROJECT_STATUS];
/**
* Tipos de proyecto
*/
export const PROJECT_TYPE = {
HORIZONTAL: 'horizontal', // Casas individuales
VERTICAL: 'vertical', // Edificios/Torres
MIXED: 'mixed', // Combinado
INFRASTRUCTURE: 'infrastructure', // Infraestructura
} as const;
export type ProjectType = typeof PROJECT_TYPE[keyof typeof PROJECT_TYPE];
/**
* Estados de fraccionamiento
*/
export const FRACCIONAMIENTO_STATUS = {
ACTIVE: 'activo',
PAUSED: 'pausado',
COMPLETED: 'completado',
CANCELLED: 'cancelado',
} as const;
export type FraccionamientoStatus = typeof FRACCIONAMIENTO_STATUS[keyof typeof FRACCIONAMIENTO_STATUS];
/**
* Estados de lote
*/
export const LOT_STATUS = {
AVAILABLE: 'disponible',
RESERVED: 'apartado',
SOLD: 'vendido',
IN_CONSTRUCTION: 'en_construccion',
DELIVERED: 'entregado',
WARRANTY: 'en_garantia',
} as const;
export type LotStatus = typeof LOT_STATUS[keyof typeof LOT_STATUS];
// ============================================================================
// PRESUPUESTOS Y COSTOS
// ============================================================================
/**
* Tipos de concepto de obra
*/
export const CONCEPT_TYPE = {
MATERIAL: 'material',
LABOR: 'mano_obra',
EQUIPMENT: 'equipo',
SUBCONTRACT: 'subcontrato',
INDIRECT: 'indirecto',
OVERHEAD: 'overhead',
UTILITY: 'utilidad',
} as const;
export type ConceptType = typeof CONCEPT_TYPE[keyof typeof CONCEPT_TYPE];
/**
* Estados de presupuesto
*/
export const BUDGET_STATUS = {
DRAFT: 'borrador',
SUBMITTED: 'enviado',
APPROVED: 'aprobado',
CONTRACTED: 'contratado',
CLOSED: 'cerrado',
} as const;
export type BudgetStatus = typeof BUDGET_STATUS[keyof typeof BUDGET_STATUS];
// ============================================================================
// COMPRAS E INVENTARIOS
// ============================================================================
/**
* Estados de orden de compra
*/
export const PURCHASE_ORDER_STATUS = {
DRAFT: 'borrador',
SUBMITTED: 'enviado',
APPROVED: 'aprobado',
CONFIRMED: 'confirmado',
PARTIAL: 'parcial',
RECEIVED: 'recibido',
CANCELLED: 'cancelado',
} as const;
export type PurchaseOrderStatus = typeof PURCHASE_ORDER_STATUS[keyof typeof PURCHASE_ORDER_STATUS];
/**
* Estados de requisicion
*/
export const REQUISITION_STATUS = {
DRAFT: 'borrador',
SUBMITTED: 'enviado',
APPROVED: 'aprobado',
REJECTED: 'rechazado',
ORDERED: 'ordenado',
CLOSED: 'cerrado',
} as const;
export type RequisitionStatus = typeof REQUISITION_STATUS[keyof typeof REQUISITION_STATUS];
/**
* Tipos de movimiento de inventario
*/
export const STOCK_MOVE_TYPE = {
INCOMING: 'entrada',
OUTGOING: 'salida',
TRANSFER: 'traspaso',
ADJUSTMENT: 'ajuste',
RETURN: 'devolucion',
CONSUMPTION: 'consumo',
} as const;
export type StockMoveType = typeof STOCK_MOVE_TYPE[keyof typeof STOCK_MOVE_TYPE];
// ============================================================================
// ESTIMACIONES
// ============================================================================
/**
* Estados de estimacion
*/
export const ESTIMATION_STATUS = {
DRAFT: 'borrador',
IN_REVIEW: 'en_revision',
SUBMITTED: 'enviado',
CLIENT_REVIEW: 'revision_cliente',
APPROVED: 'aprobado',
REJECTED: 'rechazado',
PAID: 'pagado',
CANCELLED: 'cancelado',
} as const;
export type EstimationStatus = typeof ESTIMATION_STATUS[keyof typeof ESTIMATION_STATUS];
/**
* Tipos de retencion
*/
export const RETENTION_TYPE = {
WARRANTY: 'garantia',
ADVANCE_AMORTIZATION: 'amortizacion_anticipo',
IMSS: 'imss',
ISR: 'isr',
OTHER: 'otro',
} as const;
export type RetentionType = typeof RETENTION_TYPE[keyof typeof RETENTION_TYPE];
// ============================================================================
// HSE (Health, Safety & Environment)
// ============================================================================
/**
* Severidad de incidente
*/
export const INCIDENT_SEVERITY = {
LOW: 'bajo',
MEDIUM: 'medio',
HIGH: 'alto',
CRITICAL: 'critico',
FATAL: 'fatal',
} as const;
export type IncidentSeverity = typeof INCIDENT_SEVERITY[keyof typeof INCIDENT_SEVERITY];
/**
* Tipos de incidente
*/
export const INCIDENT_TYPE = {
ACCIDENT: 'accidente',
NEAR_MISS: 'casi_accidente',
UNSAFE_CONDITION: 'condicion_insegura',
UNSAFE_ACT: 'acto_inseguro',
FIRST_AID: 'primeros_auxilios',
ENVIRONMENTAL: 'ambiental',
} as const;
export type IncidentType = typeof INCIDENT_TYPE[keyof typeof INCIDENT_TYPE];
/**
* Estados de incidente
*/
export const INCIDENT_STATUS = {
REPORTED: 'reportado',
UNDER_INVESTIGATION: 'en_investigacion',
PENDING_ACTIONS: 'pendiente_acciones',
ACTIONS_IN_PROGRESS: 'acciones_en_progreso',
CLOSED: 'cerrado',
} as const;
export type IncidentStatus = typeof INCIDENT_STATUS[keyof typeof INCIDENT_STATUS];
/**
* Tipos de capacitacion
*/
export const TRAINING_TYPE = {
INDUCTION: 'induccion',
SAFETY: 'seguridad',
TECHNICAL: 'tecnico',
REGULATORY: 'normativo',
REFRESHER: 'actualizacion',
CERTIFICATION: 'certificacion',
} as const;
export type TrainingType = typeof TRAINING_TYPE[keyof typeof TRAINING_TYPE];
/**
* Tipos de permiso de trabajo
*/
export const WORK_PERMIT_TYPE = {
HOT_WORK: 'trabajo_caliente',
CONFINED_SPACE: 'espacio_confinado',
HEIGHT_WORK: 'trabajo_altura',
ELECTRICAL: 'electrico',
EXCAVATION: 'excavacion',
LIFTING: 'izaje',
} as const;
export type WorkPermitType = typeof WORK_PERMIT_TYPE[keyof typeof WORK_PERMIT_TYPE];
// ============================================================================
// RRHH
// ============================================================================
/**
* Tipos de empleado
*/
export const EMPLOYEE_TYPE = {
PERMANENT: 'planta',
TEMPORARY: 'temporal',
CONTRACTOR: 'contratista',
INTERN: 'practicante',
} as const;
export type EmployeeType = typeof EMPLOYEE_TYPE[keyof typeof EMPLOYEE_TYPE];
/**
* Tipos de asistencia
*/
export const ATTENDANCE_TYPE = {
CHECK_IN: 'entrada',
CHECK_OUT: 'salida',
BREAK_START: 'inicio_descanso',
BREAK_END: 'fin_descanso',
} as const;
export type AttendanceType = typeof ATTENDANCE_TYPE[keyof typeof ATTENDANCE_TYPE];
/**
* Metodos de validacion de asistencia
*/
export const ATTENDANCE_VALIDATION = {
GPS: 'gps',
BIOMETRIC: 'biometrico',
QR: 'qr',
MANUAL: 'manual',
NFC: 'nfc',
} as const;
export type AttendanceValidation = typeof ATTENDANCE_VALIDATION[keyof typeof ATTENDANCE_VALIDATION];
// ============================================================================
// INFONAVIT
// ============================================================================
/**
* Estados de asignacion INFONAVIT
*/
export const INFONAVIT_ASSIGNMENT_STATUS = {
AVAILABLE: 'disponible',
IN_PROCESS: 'en_proceso',
ASSIGNED: 'asignado',
DOCUMENTED: 'documentado',
REGISTERED: 'registrado',
DELIVERED: 'entregado',
} as const;
export type InfonavitAssignmentStatus = typeof INFONAVIT_ASSIGNMENT_STATUS[keyof typeof INFONAVIT_ASSIGNMENT_STATUS];
/**
* Programas INFONAVIT
*/
export const INFONAVIT_PROGRAM = {
TRADICIONAL: 'tradicional',
COFINAVIT: 'cofinavit',
APOYO_INFONAVIT: 'apoyo_infonavit',
UNAMOS_CREDITOS: 'unamos_creditos',
MEJORAVIT: 'mejoravit',
} as const;
export type InfonavitProgram = typeof INFONAVIT_PROGRAM[keyof typeof INFONAVIT_PROGRAM];
// ============================================================================
// CALIDAD Y POSTVENTA
// ============================================================================
/**
* Estados de ticket postventa
*/
export const TICKET_STATUS = {
OPEN: 'abierto',
IN_PROGRESS: 'en_proceso',
PENDING_CUSTOMER: 'pendiente_cliente',
PENDING_PARTS: 'pendiente_refacciones',
RESOLVED: 'resuelto',
CLOSED: 'cerrado',
} as const;
export type TicketStatus = typeof TICKET_STATUS[keyof typeof TICKET_STATUS];
/**
* Prioridad de ticket
*/
export const TICKET_PRIORITY = {
LOW: 'baja',
MEDIUM: 'media',
HIGH: 'alta',
URGENT: 'urgente',
} as const;
export type TicketPriority = typeof TICKET_PRIORITY[keyof typeof TICKET_PRIORITY];
// ============================================================================
// DOCUMENTOS
// ============================================================================
/**
* Estados de documento
*/
export const DOCUMENT_STATUS = {
DRAFT: 'borrador',
PENDING_REVIEW: 'pendiente_revision',
APPROVED: 'aprobado',
REJECTED: 'rechazado',
OBSOLETE: 'obsoleto',
} as const;
export type DocumentStatus = typeof DOCUMENT_STATUS[keyof typeof DOCUMENT_STATUS];
/**
* Tipos de documento
*/
export const DOCUMENT_TYPE = {
PLAN: 'plano',
CONTRACT: 'contrato',
PERMIT: 'permiso',
CERTIFICATE: 'certificado',
REPORT: 'reporte',
PHOTO: 'fotografia',
OTHER: 'otro',
} as const;
export type DocumentType = typeof DOCUMENT_TYPE[keyof typeof DOCUMENT_TYPE];
// ============================================================================
// WORKFLOW
// ============================================================================
/**
* Acciones de workflow
*/
export const WORKFLOW_ACTION = {
SUBMIT: 'submit',
APPROVE: 'approve',
REJECT: 'reject',
RETURN: 'return',
CANCEL: 'cancel',
REOPEN: 'reopen',
} as const;
export type WorkflowAction = typeof WORKFLOW_ACTION[keyof typeof WORKFLOW_ACTION];
// ============================================================================
// AUDIT
// ============================================================================
/**
* Tipos de accion de auditoria
*/
export const AUDIT_ACTION = {
CREATE: 'create',
UPDATE: 'update',
DELETE: 'delete',
VIEW: 'view',
EXPORT: 'export',
LOGIN: 'login',
LOGOUT: 'logout',
} as const;
export type AuditAction = typeof AUDIT_ACTION[keyof typeof AUDIT_ACTION];

View File

@ -0,0 +1,194 @@
/**
* Constants - SSOT Entry Point
*
* Este archivo es el punto de entrada para todas las constantes del sistema.
* Exporta desde los modulos especializados para mantener SSOT.
*
* @module @shared/constants
*/
// ============================================================================
// DATABASE CONSTANTS
// ============================================================================
export {
DB_SCHEMAS,
DB_TABLES,
DB_COLUMNS,
TABLE_REFS,
getFullTableName,
type DBSchema,
} from './database.constants';
// ============================================================================
// API CONSTANTS
// ============================================================================
export {
API_VERSION,
API_PREFIX,
API_ROUTES,
HTTP_METHODS,
HTTP_STATUS,
CONTENT_TYPES,
} from './api.constants';
// ============================================================================
// ENUMS
// ============================================================================
export {
// Auth
ROLES,
USER_STATUS,
type Role,
type UserStatus,
// Projects
PROJECT_STATUS,
PROJECT_TYPE,
FRACCIONAMIENTO_STATUS,
LOT_STATUS,
type ProjectStatus,
type ProjectType,
type FraccionamientoStatus,
type LotStatus,
// Budget
CONCEPT_TYPE,
BUDGET_STATUS,
type ConceptType,
type BudgetStatus,
// Purchases
PURCHASE_ORDER_STATUS,
REQUISITION_STATUS,
STOCK_MOVE_TYPE,
type PurchaseOrderStatus,
type RequisitionStatus,
type StockMoveType,
// Estimates
ESTIMATION_STATUS,
RETENTION_TYPE,
type EstimationStatus,
type RetentionType,
// HSE
INCIDENT_SEVERITY,
INCIDENT_TYPE,
INCIDENT_STATUS,
TRAINING_TYPE,
WORK_PERMIT_TYPE,
type IncidentSeverity,
type IncidentType,
type IncidentStatus,
type TrainingType,
type WorkPermitType,
// HR
EMPLOYEE_TYPE,
ATTENDANCE_TYPE,
ATTENDANCE_VALIDATION,
type EmployeeType,
type AttendanceType,
type AttendanceValidation,
// INFONAVIT
INFONAVIT_ASSIGNMENT_STATUS,
INFONAVIT_PROGRAM,
type InfonavitAssignmentStatus,
type InfonavitProgram,
// Quality
TICKET_STATUS,
TICKET_PRIORITY,
type TicketStatus,
type TicketPriority,
// Documents
DOCUMENT_STATUS,
DOCUMENT_TYPE,
type DocumentStatus,
type DocumentType,
// Workflow
WORKFLOW_ACTION,
type WorkflowAction,
// Audit
AUDIT_ACTION,
type AuditAction,
} from './enums.constants';
// ============================================================================
// APP CONFIG CONSTANTS
// ============================================================================
/**
* Application Context for PostgreSQL RLS
*/
export const APP_CONTEXT = {
TENANT_ID: 'app.current_tenant',
USER_ID: 'app.current_user_id',
} as const;
/**
* Custom HTTP Headers
*/
export const CUSTOM_HEADERS = {
TENANT_ID: 'x-tenant-id',
CORRELATION_ID: 'x-correlation-id',
API_KEY: 'x-api-key',
} as const;
/**
* Pagination Defaults
*/
export const PAGINATION = {
DEFAULT_PAGE: 1,
DEFAULT_LIMIT: 20,
MAX_LIMIT: 100,
} as const;
/**
* Regex Patterns for Validation
*/
export const PATTERNS = {
UUID: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
RFC: /^[A-Z&Ñ]{3,4}[0-9]{6}[A-Z0-9]{3}$/,
CURP: /^[A-Z]{4}[0-9]{6}[HM][A-Z]{5}[0-9A-Z][0-9]$/,
NSS: /^[0-9]{11}$/,
EMAIL: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
PHONE_MX: /^(\+52)?[0-9]{10}$/,
POSTAL_CODE_MX: /^[0-9]{5}$/,
} as const;
/**
* File Upload Limits
*/
export const FILE_LIMITS = {
MAX_FILE_SIZE: 10 * 1024 * 1024, // 10MB
MAX_FILES: 10,
ALLOWED_IMAGE_TYPES: ['image/jpeg', 'image/png', 'image/webp'],
ALLOWED_DOC_TYPES: ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
ALLOWED_SPREADSHEET_TYPES: ['application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
} as const;
/**
* Cache TTL (Time To Live) in seconds
*/
export const CACHE_TTL = {
SHORT: 60, // 1 minute
MEDIUM: 300, // 5 minutes
LONG: 3600, // 1 hour
DAY: 86400, // 24 hours
} as const;
/**
* Date Formats
*/
export const DATE_FORMATS = {
ISO: 'YYYY-MM-DDTHH:mm:ss.sssZ',
DATE_ONLY: 'YYYY-MM-DD',
TIME_ONLY: 'HH:mm:ss',
DISPLAY_MX: 'DD/MM/YYYY',
DISPLAY_FULL_MX: 'DD/MM/YYYY HH:mm',
} as const;

View File

@ -0,0 +1,62 @@
/**
* TypeORM Configuration
* Configuración de conexión a PostgreSQL
*
* @see https://typeorm.io/data-source-options
*/
import { DataSource } from 'typeorm';
import dotenv from 'dotenv';
dotenv.config();
export const AppDataSource = new DataSource({
type: 'postgres',
url: process.env.DATABASE_URL,
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432'),
username: process.env.DB_USER || 'erp_user',
password: process.env.DB_PASSWORD || 'erp_dev_password',
database: process.env.DB_NAME || 'erp_construccion',
synchronize: process.env.DB_SYNCHRONIZE === 'true', // ⚠️ NUNCA en producción
logging: process.env.DB_LOGGING === 'true',
entities: [
__dirname + '/../../modules/**/entities/*.entity{.ts,.js}',
],
migrations: [
__dirname + '/../../../migrations/*{.ts,.js}',
],
subscribers: [],
// Configuración de pool
extra: {
max: 20, // Máximo de conexiones
min: 5, // Mínimo de conexiones
idleTimeoutMillis: 30000,
},
});
/**
* Inicializar conexión
*/
export async function initializeDatabase(): Promise<void> {
try {
await AppDataSource.initialize();
console.log('✅ TypeORM conectado a PostgreSQL');
} catch (error) {
console.error('❌ Error al conectar TypeORM:', error);
throw error;
}
}
/**
* Cerrar conexión
*/
export async function closeDatabase(): Promise<void> {
try {
await AppDataSource.destroy();
console.log('✅ Conexión TypeORM cerrada');
} catch (error) {
console.error('❌ Error al cerrar TypeORM:', error);
throw error;
}
}

View File

@ -0,0 +1,77 @@
/**
* Base Interfaces
* Interfaces compartidas para todo el proyecto
*/
import { Request } from 'express';
/**
* Entidad base con campos de auditoria
*/
export interface BaseEntity {
id: string;
createdAt: Date;
updatedAt: Date;
createdBy?: string;
}
/**
* Entidad con tenant
*/
export interface TenantEntity extends BaseEntity {
tenantId: string;
}
/**
* Request con contexto de tenant y usuario
*/
export interface AuthenticatedRequest extends Request {
user?: {
id: string;
email: string;
tenantId: string;
roles: string[];
};
tenantId?: string;
}
/**
* Respuesta paginada
*/
export interface PaginatedResponse<T> {
data: T[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
};
}
/**
* Query params para paginacion
*/
export interface PaginationQuery {
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
search?: string;
}
/**
* Respuesta API estandar
*/
export interface ApiResponse<T = unknown> {
success: boolean;
data?: T;
message?: string;
errors?: Array<{
field?: string;
message: string;
code?: string;
}>;
meta?: Record<string, unknown>;
}

View File

@ -0,0 +1,217 @@
/**
* BaseService - Abstract Service with Common CRUD Operations
*
* Provides multi-tenant aware CRUD operations using TypeORM.
* All domain services should extend this base class.
*
* @module @shared/services
*/
import {
Repository,
FindOptionsWhere,
FindManyOptions,
DeepPartial,
ObjectLiteral,
} from 'typeorm';
export interface PaginationOptions {
page?: number;
limit?: number;
}
export interface PaginatedResult<T> {
data: T[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
export interface ServiceContext {
tenantId: string;
userId: string;
}
export abstract class BaseService<T extends ObjectLiteral> {
constructor(protected readonly repository: Repository<T>) {}
/**
* Find all records for a tenant with optional pagination
*/
async findAll(
ctx: ServiceContext,
options?: PaginationOptions & { where?: FindOptionsWhere<T> }
): Promise<PaginatedResult<T>> {
const page = options?.page || 1;
const limit = options?.limit || 20;
const skip = (page - 1) * limit;
const where = {
tenantId: ctx.tenantId,
deletedAt: null,
...options?.where,
} as FindOptionsWhere<T>;
const [data, total] = await this.repository.findAndCount({
where,
take: limit,
skip,
order: { createdAt: 'DESC' } as any,
});
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Find one record by ID for a tenant
*/
async findById(ctx: ServiceContext, id: string): Promise<T | null> {
return this.repository.findOne({
where: {
id,
tenantId: ctx.tenantId,
deletedAt: null,
} as FindOptionsWhere<T>,
});
}
/**
* Find one record by criteria
*/
async findOne(
ctx: ServiceContext,
where: FindOptionsWhere<T>
): Promise<T | null> {
return this.repository.findOne({
where: {
tenantId: ctx.tenantId,
deletedAt: null,
...where,
} as FindOptionsWhere<T>,
});
}
/**
* Find records by custom options
*/
async find(
ctx: ServiceContext,
options: FindManyOptions<T>
): Promise<T[]> {
return this.repository.find({
...options,
where: {
tenantId: ctx.tenantId,
deletedAt: null,
...(options.where || {}),
} as FindOptionsWhere<T>,
});
}
/**
* Create a new record
*/
async create(
ctx: ServiceContext,
data: DeepPartial<T>
): Promise<T> {
const entity = this.repository.create({
...data,
tenantId: ctx.tenantId,
createdById: ctx.userId,
} as DeepPartial<T>);
return this.repository.save(entity);
}
/**
* Update an existing record
*/
async update(
ctx: ServiceContext,
id: string,
data: DeepPartial<T>
): Promise<T | null> {
const existing = await this.findById(ctx, id);
if (!existing) {
return null;
}
const updated = this.repository.merge(existing, {
...data,
updatedById: ctx.userId,
} as DeepPartial<T>);
return this.repository.save(updated);
}
/**
* Soft delete a record
*/
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
const existing = await this.findById(ctx, id);
if (!existing) {
return false;
}
await this.repository.update(
{ id, tenantId: ctx.tenantId } as FindOptionsWhere<T>,
{
deletedAt: new Date(),
deletedById: ctx.userId,
} as any
);
return true;
}
/**
* Hard delete a record (use with caution)
*/
async hardDelete(ctx: ServiceContext, id: string): Promise<boolean> {
const result = await this.repository.delete({
id,
tenantId: ctx.tenantId,
} as FindOptionsWhere<T>);
return (result.affected ?? 0) > 0;
}
/**
* Count records
*/
async count(
ctx: ServiceContext,
where?: FindOptionsWhere<T>
): Promise<number> {
return this.repository.count({
where: {
tenantId: ctx.tenantId,
deletedAt: null,
...where,
} as FindOptionsWhere<T>,
});
}
/**
* Check if a record exists
*/
async exists(
ctx: ServiceContext,
where: FindOptionsWhere<T>
): Promise<boolean> {
const count = await this.count(ctx, where);
return count > 0;
}
}

View File

@ -0,0 +1,5 @@
/**
* Shared Services - Exports
*/
export * from './base.service';

36
tsconfig.json Normal file
View File

@ -0,0 +1,36 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strictPropertyInitialization": false,
"noImplicitAny": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": "./src",
"paths": {
"@shared/*": ["shared/*"],
"@modules/*": ["modules/*"],
"@config/*": ["shared/config/*"],
"@types/*": ["shared/types/*"],
"@utils/*": ["shared/utils/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"]
}