Initial commit for deploy
This commit is contained in:
commit
97e5dc4f73
71
.env.example
Normal file
71
.env.example
Normal 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
78
.env.production
Normal 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
33
.gitignore
vendored
Normal 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
84
Dockerfile
Normal 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
461
README.md
Normal 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
7816
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
73
package.json
Normal file
73
package.json
Normal 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
120
scripts/sync-enums.ts
Normal 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();
|
||||
385
scripts/validate-constants-usage.ts
Normal file
385
scripts/validate-constants-usage.ts
Normal 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();
|
||||
70
src/modules/auth/dto/auth.dto.ts
Normal file
70
src/modules/auth/dto/auth.dto.ts
Normal 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
12
src/modules/auth/index.ts
Normal 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';
|
||||
178
src/modules/auth/middleware/auth.middleware.ts
Normal file
178
src/modules/auth/middleware/auth.middleware.ts
Normal 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);
|
||||
}
|
||||
370
src/modules/auth/services/auth.service.ts
Normal file
370
src/modules/auth/services/auth.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
5
src/modules/auth/services/index.ts
Normal file
5
src/modules/auth/services/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Auth Module - Service Exports
|
||||
*/
|
||||
|
||||
export * from './auth.service';
|
||||
100
src/modules/budgets/entities/concepto.entity.ts
Normal file
100
src/modules/budgets/entities/concepto.entity.ts
Normal 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;
|
||||
}
|
||||
8
src/modules/budgets/entities/index.ts
Normal file
8
src/modules/budgets/entities/index.ts
Normal 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';
|
||||
95
src/modules/budgets/entities/presupuesto-partida.entity.ts
Normal file
95
src/modules/budgets/entities/presupuesto-partida.entity.ts
Normal 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;
|
||||
}
|
||||
107
src/modules/budgets/entities/presupuesto.entity.ts
Normal file
107
src/modules/budgets/entities/presupuesto.entity.ts
Normal 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[];
|
||||
}
|
||||
160
src/modules/budgets/services/concepto.service.ts
Normal file
160
src/modules/budgets/services/concepto.service.ts
Normal 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[];
|
||||
}
|
||||
6
src/modules/budgets/services/index.ts
Normal file
6
src/modules/budgets/services/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Budgets Module - Service Exports
|
||||
*/
|
||||
|
||||
export * from './concepto.service';
|
||||
export * from './presupuesto.service';
|
||||
262
src/modules/budgets/services/presupuesto.service.ts
Normal file
262
src/modules/budgets/services/presupuesto.service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
7
src/modules/construction/controllers/index.ts
Normal file
7
src/modules/construction/controllers/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Construction Controllers Index
|
||||
* @module Construction
|
||||
*/
|
||||
|
||||
export { default as proyectoController } from './proyecto.controller';
|
||||
export { default as fraccionamientoController } from './fraccionamiento.controller';
|
||||
165
src/modules/construction/controllers/proyecto.controller.ts
Normal file
165
src/modules/construction/controllers/proyecto.controller.ts
Normal 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;
|
||||
90
src/modules/construction/entities/fraccionamiento.entity.ts
Normal file
90
src/modules/construction/entities/fraccionamiento.entity.ts
Normal 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;
|
||||
}
|
||||
7
src/modules/construction/entities/index.ts
Normal file
7
src/modules/construction/entities/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Construction Entities Index
|
||||
* @module Construction
|
||||
*/
|
||||
|
||||
export * from './proyecto.entity';
|
||||
export * from './fraccionamiento.entity';
|
||||
88
src/modules/construction/entities/proyecto.entity.ts
Normal file
88
src/modules/construction/entities/proyecto.entity.ts
Normal 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[];
|
||||
}
|
||||
117
src/modules/construction/services/fraccionamiento.service.ts
Normal file
117
src/modules/construction/services/fraccionamiento.service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
7
src/modules/construction/services/index.ts
Normal file
7
src/modules/construction/services/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Construction Services Index
|
||||
* @module Construction
|
||||
*/
|
||||
|
||||
export * from './proyecto.service';
|
||||
export * from './fraccionamiento.service';
|
||||
117
src/modules/construction/services/proyecto.service.ts
Normal file
117
src/modules/construction/services/proyecto.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
6
src/modules/core/entities/index.ts
Normal file
6
src/modules/core/entities/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Core Entities Index
|
||||
*/
|
||||
|
||||
export { Tenant } from './tenant.entity';
|
||||
export { User } from './user.entity';
|
||||
47
src/modules/core/entities/tenant.entity.ts
Normal file
47
src/modules/core/entities/tenant.entity.ts
Normal 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[];
|
||||
}
|
||||
69
src/modules/core/entities/user.entity.ts
Normal file
69
src/modules/core/entities/user.entity.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
86
src/modules/estimates/entities/amortizacion.entity.ts
Normal file
86
src/modules/estimates/entities/amortizacion.entity.ts
Normal 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;
|
||||
}
|
||||
134
src/modules/estimates/entities/anticipo.entity.ts
Normal file
134
src/modules/estimates/entities/anticipo.entity.ts
Normal 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[];
|
||||
}
|
||||
122
src/modules/estimates/entities/estimacion-concepto.entity.ts
Normal file
122
src/modules/estimates/entities/estimacion-concepto.entity.ts
Normal 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[];
|
||||
}
|
||||
87
src/modules/estimates/entities/estimacion-workflow.entity.ts
Normal file
87
src/modules/estimates/entities/estimacion-workflow.entity.ts
Normal 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;
|
||||
}
|
||||
173
src/modules/estimates/entities/estimacion.entity.ts
Normal file
173
src/modules/estimates/entities/estimacion.entity.ts
Normal 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[];
|
||||
}
|
||||
92
src/modules/estimates/entities/fondo-garantia.entity.ts
Normal file
92
src/modules/estimates/entities/fondo-garantia.entity.ts
Normal 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;
|
||||
}
|
||||
125
src/modules/estimates/entities/generador.entity.ts
Normal file
125
src/modules/estimates/entities/generador.entity.ts
Normal 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;
|
||||
}
|
||||
13
src/modules/estimates/entities/index.ts
Normal file
13
src/modules/estimates/entities/index.ts
Normal 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';
|
||||
99
src/modules/estimates/entities/retencion.entity.ts
Normal file
99
src/modules/estimates/entities/retencion.entity.ts
Normal 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;
|
||||
}
|
||||
424
src/modules/estimates/services/estimacion.service.ts
Normal file
424
src/modules/estimates/services/estimacion.service.ts
Normal 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;
|
||||
}
|
||||
6
src/modules/estimates/services/index.ts
Normal file
6
src/modules/estimates/services/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Estimates Module - Service Exports
|
||||
* MAI-008: Estimaciones y Facturación
|
||||
*/
|
||||
|
||||
export * from './estimacion.service';
|
||||
65
src/modules/hr/entities/employee-fraccionamiento.entity.ts
Normal file
65
src/modules/hr/entities/employee-fraccionamiento.entity.ts
Normal 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;
|
||||
}
|
||||
136
src/modules/hr/entities/employee.entity.ts
Normal file
136
src/modules/hr/entities/employee.entity.ts
Normal 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(' ');
|
||||
}
|
||||
}
|
||||
8
src/modules/hr/entities/index.ts
Normal file
8
src/modules/hr/entities/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* HR Entities Index
|
||||
* @module HR
|
||||
*/
|
||||
|
||||
export * from './puesto.entity';
|
||||
export * from './employee.entity';
|
||||
export * from './employee-fraccionamiento.entity';
|
||||
68
src/modules/hr/entities/puesto.entity.ts
Normal file
68
src/modules/hr/entities/puesto.entity.ts
Normal 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[];
|
||||
}
|
||||
74
src/modules/hse/entities/capacitacion.entity.ts
Normal file
74
src/modules/hse/entities/capacitacion.entity.ts
Normal 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;
|
||||
}
|
||||
71
src/modules/hse/entities/incidente-accion.entity.ts
Normal file
71
src/modules/hse/entities/incidente-accion.entity.ts
Normal 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;
|
||||
}
|
||||
58
src/modules/hse/entities/incidente-involucrado.entity.ts
Normal file
58
src/modules/hse/entities/incidente-involucrado.entity.ts
Normal 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;
|
||||
}
|
||||
111
src/modules/hse/entities/incidente.entity.ts
Normal file
111
src/modules/hse/entities/incidente.entity.ts
Normal 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[];
|
||||
}
|
||||
23
src/modules/hse/entities/index.ts
Normal file
23
src/modules/hse/entities/index.ts
Normal 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
|
||||
127
src/modules/progress/entities/avance-obra.entity.ts
Normal file
127
src/modules/progress/entities/avance-obra.entity.ts
Normal 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[];
|
||||
}
|
||||
102
src/modules/progress/entities/bitacora-obra.entity.ts
Normal file
102
src/modules/progress/entities/bitacora-obra.entity.ts
Normal 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;
|
||||
}
|
||||
87
src/modules/progress/entities/foto-avance.entity.ts
Normal file
87
src/modules/progress/entities/foto-avance.entity.ts
Normal 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;
|
||||
}
|
||||
10
src/modules/progress/entities/index.ts
Normal file
10
src/modules/progress/entities/index.ts
Normal 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';
|
||||
107
src/modules/progress/entities/programa-actividad.entity.ts
Normal file
107
src/modules/progress/entities/programa-actividad.entity.ts
Normal 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;
|
||||
}
|
||||
91
src/modules/progress/entities/programa-obra.entity.ts
Normal file
91
src/modules/progress/entities/programa-obra.entity.ts
Normal 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[];
|
||||
}
|
||||
284
src/modules/progress/services/avance-obra.service.ts
Normal file
284
src/modules/progress/services/avance-obra.service.ts
Normal 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;
|
||||
}
|
||||
209
src/modules/progress/services/bitacora-obra.service.ts
Normal file
209
src/modules/progress/services/bitacora-obra.service.ts
Normal 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;
|
||||
}
|
||||
7
src/modules/progress/services/index.ts
Normal file
7
src/modules/progress/services/index.ts
Normal 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
120
src/server.ts
Normal 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;
|
||||
249
src/shared/constants/api.constants.ts
Normal file
249
src/shared/constants/api.constants.ts
Normal 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;
|
||||
315
src/shared/constants/database.constants.ts
Normal file
315
src/shared/constants/database.constants.ts
Normal 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;
|
||||
494
src/shared/constants/enums.constants.ts
Normal file
494
src/shared/constants/enums.constants.ts
Normal 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];
|
||||
194
src/shared/constants/index.ts
Normal file
194
src/shared/constants/index.ts
Normal 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;
|
||||
62
src/shared/database/typeorm.config.ts
Normal file
62
src/shared/database/typeorm.config.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
77
src/shared/interfaces/base.interface.ts
Normal file
77
src/shared/interfaces/base.interface.ts
Normal 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>;
|
||||
}
|
||||
217
src/shared/services/base.service.ts
Normal file
217
src/shared/services/base.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
5
src/shared/services/index.ts
Normal file
5
src/shared/services/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Shared Services - Exports
|
||||
*/
|
||||
|
||||
export * from './base.service';
|
||||
36
tsconfig.json
Normal file
36
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user