Migración desde erp-construccion/backend - Estándar multi-repo v2
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
aeb201d7f6
commit
7c1480a819
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
|
||||
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"]
|
||||
462
README.md
462
README.md
@ -1,3 +1,461 @@
|
||||
# erp-construccion-backend-v2
|
||||
# Backend - ERP Construccion
|
||||
|
||||
Backend de erp-construccion - Workspace V2
|
||||
API REST para sistema de administracion de obra e INFONAVIT.
|
||||
|
||||
| Campo | Valor |
|
||||
|-------|-------|
|
||||
| **Stack** | Node.js 20 + Express 4 + TypeScript 5 + TypeORM 0.3 |
|
||||
| **Version** | 1.0.0 |
|
||||
| **Entidades** | 30 |
|
||||
| **Services** | 8 |
|
||||
| **Arquitectura** | Multi-tenant con RLS |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Instalar dependencias
|
||||
npm install
|
||||
|
||||
# Configurar variables de entorno
|
||||
cp ../.env.example .env
|
||||
|
||||
# Desarrollo con hot-reload
|
||||
npm run dev
|
||||
|
||||
# El servidor estara en http://localhost:3000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Estructura del Proyecto
|
||||
|
||||
```
|
||||
src/
|
||||
├── modules/
|
||||
│ ├── auth/ # Autenticacion JWT
|
||||
│ │ ├── dto/
|
||||
│ │ │ └── auth.dto.ts # DTOs tipados
|
||||
│ │ ├── middleware/
|
||||
│ │ │ └── auth.middleware.ts
|
||||
│ │ ├── services/
|
||||
│ │ │ └── auth.service.ts
|
||||
│ │ └── index.ts
|
||||
│ │
|
||||
│ ├── budgets/ # MAI-003 Presupuestos
|
||||
│ │ ├── entities/
|
||||
│ │ │ ├── concepto.entity.ts
|
||||
│ │ │ ├── presupuesto.entity.ts
|
||||
│ │ │ └── presupuesto-partida.entity.ts
|
||||
│ │ ├── services/
|
||||
│ │ │ ├── concepto.service.ts
|
||||
│ │ │ └── presupuesto.service.ts
|
||||
│ │ └── index.ts
|
||||
│ │
|
||||
│ ├── progress/ # MAI-005 Control de Obra
|
||||
│ │ ├── entities/
|
||||
│ │ │ ├── avance-obra.entity.ts
|
||||
│ │ │ ├── foto-avance.entity.ts
|
||||
│ │ │ ├── bitacora-obra.entity.ts
|
||||
│ │ │ ├── programa-obra.entity.ts
|
||||
│ │ │ └── programa-actividad.entity.ts
|
||||
│ │ ├── services/
|
||||
│ │ │ ├── avance-obra.service.ts
|
||||
│ │ │ └── bitacora-obra.service.ts
|
||||
│ │ └── index.ts
|
||||
│ │
|
||||
│ ├── estimates/ # MAI-008 Estimaciones
|
||||
│ │ ├── entities/
|
||||
│ │ │ ├── estimacion.entity.ts
|
||||
│ │ │ ├── estimacion-concepto.entity.ts
|
||||
│ │ │ ├── generador.entity.ts
|
||||
│ │ │ ├── anticipo.entity.ts
|
||||
│ │ │ ├── amortizacion.entity.ts
|
||||
│ │ │ ├── retencion.entity.ts
|
||||
│ │ │ ├── fondo-garantia.entity.ts
|
||||
│ │ │ └── estimacion-workflow.entity.ts
|
||||
│ │ ├── services/
|
||||
│ │ │ └── estimacion.service.ts
|
||||
│ │ └── index.ts
|
||||
│ │
|
||||
│ ├── construction/ # MAI-002 Proyectos
|
||||
│ │ └── entities/
|
||||
│ │ ├── proyecto.entity.ts
|
||||
│ │ └── fraccionamiento.entity.ts
|
||||
│ │
|
||||
│ ├── hr/ # MAI-007 RRHH
|
||||
│ │ └── entities/
|
||||
│ │ ├── employee.entity.ts
|
||||
│ │ ├── puesto.entity.ts
|
||||
│ │ └── employee-fraccionamiento.entity.ts
|
||||
│ │
|
||||
│ ├── hse/ # MAA-017 Seguridad HSE
|
||||
│ │ └── entities/
|
||||
│ │ ├── incidente.entity.ts
|
||||
│ │ ├── incidente-involucrado.entity.ts
|
||||
│ │ ├── incidente-accion.entity.ts
|
||||
│ │ └── capacitacion.entity.ts
|
||||
│ │
|
||||
│ └── core/ # Entidades base
|
||||
│ └── entities/
|
||||
│ ├── user.entity.ts
|
||||
│ └── tenant.entity.ts
|
||||
│
|
||||
└── shared/
|
||||
├── constants/ # SSOT
|
||||
│ ├── database.constants.ts
|
||||
│ ├── api.constants.ts
|
||||
│ ├── enums.constants.ts
|
||||
│ └── index.ts
|
||||
├── services/
|
||||
│ └── base.service.ts # CRUD multi-tenant
|
||||
└── database/
|
||||
└── typeorm.config.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Modulos Implementados
|
||||
|
||||
### Auth Module
|
||||
|
||||
Autenticacion JWT con refresh tokens y multi-tenancy.
|
||||
|
||||
```typescript
|
||||
// Services
|
||||
AuthService
|
||||
├── login(dto) // Login con email/password
|
||||
├── register(dto) // Registro de usuarios
|
||||
├── refresh(dto) // Renovar tokens
|
||||
├── logout(token) // Revocar refresh token
|
||||
└── changePassword(dto) // Cambiar password
|
||||
|
||||
// Middleware
|
||||
AuthMiddleware
|
||||
├── authenticate // Validar JWT (requerido)
|
||||
├── optionalAuthenticate // Validar JWT (opcional)
|
||||
├── authorize(...roles) // Autorizar por roles
|
||||
├── requireAdmin // Solo admin/super_admin
|
||||
└── requireSupervisor // Solo supervisores+
|
||||
```
|
||||
|
||||
### Budgets Module (MAI-003)
|
||||
|
||||
Catalogo de conceptos y presupuestos de obra.
|
||||
|
||||
```typescript
|
||||
// Entities
|
||||
Concepto // Catalogo jerarquico (arbol)
|
||||
Presupuesto // Presupuestos versionados
|
||||
PresupuestoPartida // Lineas con calculo automatico
|
||||
|
||||
// Services
|
||||
ConceptoService
|
||||
├── createConcepto(ctx, dto) // Crear con nivel/path automatico
|
||||
├── findRootConceptos(ctx) // Conceptos raiz
|
||||
├── findChildren(ctx, parentId) // Hijos de un concepto
|
||||
├── getConceptoTree(ctx, rootId) // Arbol completo
|
||||
└── search(ctx, term) // Busqueda por codigo/nombre
|
||||
|
||||
PresupuestoService
|
||||
├── createPresupuesto(ctx, dto)
|
||||
├── findByFraccionamiento(ctx, id)
|
||||
├── findWithPartidas(ctx, id)
|
||||
├── addPartida(ctx, id, dto)
|
||||
├── updatePartida(ctx, id, dto)
|
||||
├── removePartida(ctx, id)
|
||||
├── recalculateTotal(ctx, id)
|
||||
├── createNewVersion(ctx, id) // Versionamiento
|
||||
└── approve(ctx, id)
|
||||
```
|
||||
|
||||
### Progress Module (MAI-005)
|
||||
|
||||
Control de avances fisicos y bitacora de obra.
|
||||
|
||||
```typescript
|
||||
// Entities
|
||||
AvanceObra // Avances con workflow
|
||||
FotoAvance // Evidencias fotograficas con GPS
|
||||
BitacoraObra // Bitacora diaria
|
||||
ProgramaObra // Programa maestro
|
||||
ProgramaActividad // Actividades WBS
|
||||
|
||||
// Services
|
||||
AvanceObraService
|
||||
├── createAvance(ctx, dto)
|
||||
├── findByLote(ctx, loteId)
|
||||
├── findByDepartamento(ctx, deptoId)
|
||||
├── findWithFilters(ctx, filters)
|
||||
├── findWithFotos(ctx, id)
|
||||
├── addFoto(ctx, id, dto)
|
||||
├── review(ctx, id) // Workflow: revisar
|
||||
├── approve(ctx, id) // Workflow: aprobar
|
||||
├── reject(ctx, id, reason) // Workflow: rechazar
|
||||
└── getAccumulatedProgress(ctx) // Acumulado por concepto
|
||||
|
||||
BitacoraObraService
|
||||
├── createEntry(ctx, dto) // Numero automatico
|
||||
├── findByFraccionamiento(ctx, id)
|
||||
├── findWithFilters(ctx, id, filters)
|
||||
├── findByDate(ctx, id, date)
|
||||
├── findLatest(ctx, id)
|
||||
└── getStats(ctx, id) // Estadisticas
|
||||
```
|
||||
|
||||
### Estimates Module (MAI-008)
|
||||
|
||||
Estimaciones periodicas con workflow de aprobacion.
|
||||
|
||||
```typescript
|
||||
// Entities
|
||||
Estimacion // Estimaciones con workflow
|
||||
EstimacionConcepto // Lineas con acumulados
|
||||
Generador // Numeros generadores
|
||||
Anticipo // Anticipos
|
||||
Amortizacion // Amortizaciones
|
||||
Retencion // Retenciones
|
||||
FondoGarantia // Fondo de garantia
|
||||
EstimacionWorkflow // Historial de estados
|
||||
|
||||
// Services
|
||||
EstimacionService
|
||||
├── createEstimacion(ctx, dto) // Numero automatico
|
||||
├── findByContrato(ctx, contratoId)
|
||||
├── findWithFilters(ctx, filters)
|
||||
├── findWithDetails(ctx, id) // Con relaciones
|
||||
├── addConcepto(ctx, id, dto)
|
||||
├── addGenerador(ctx, conceptoId, dto)
|
||||
├── recalculateTotals(ctx, id) // Llama funcion PG
|
||||
├── submit(ctx, id) // Workflow
|
||||
├── review(ctx, id) // Workflow
|
||||
├── approve(ctx, id) // Workflow
|
||||
├── reject(ctx, id, reason) // Workflow
|
||||
└── getContractSummary(ctx, id) // Resumen financiero
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Base Service
|
||||
|
||||
Servicio base con CRUD multi-tenant.
|
||||
|
||||
```typescript
|
||||
// Uso
|
||||
class MiService extends BaseService<MiEntity> {
|
||||
constructor(repository: Repository<MiEntity>) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
|
||||
// Metodos disponibles
|
||||
BaseService<T>
|
||||
├── findAll(ctx, options?) // Paginado
|
||||
├── findById(ctx, id)
|
||||
├── findOne(ctx, where)
|
||||
├── find(ctx, options)
|
||||
├── create(ctx, data)
|
||||
├── update(ctx, id, data)
|
||||
├── softDelete(ctx, id)
|
||||
├── hardDelete(ctx, id)
|
||||
├── count(ctx, where?)
|
||||
└── exists(ctx, where)
|
||||
|
||||
// ServiceContext
|
||||
interface ServiceContext {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SSOT Constants
|
||||
|
||||
Sistema de constantes centralizadas.
|
||||
|
||||
```typescript
|
||||
// database.constants.ts
|
||||
import { DB_SCHEMAS, DB_TABLES, TABLE_REFS } from '@shared/constants';
|
||||
|
||||
DB_SCHEMAS.CONSTRUCTION // 'construction'
|
||||
DB_TABLES.construction.CONCEPTOS // 'conceptos'
|
||||
TABLE_REFS.FRACCIONAMIENTOS // 'construction.fraccionamientos'
|
||||
|
||||
// api.constants.ts
|
||||
import { API_ROUTES } from '@shared/constants';
|
||||
|
||||
API_ROUTES.PRESUPUESTOS.BASE // '/api/v1/presupuestos'
|
||||
API_ROUTES.ESTIMACIONES.BY_ID(id) // '/api/v1/estimaciones/:id'
|
||||
|
||||
// enums.constants.ts
|
||||
import { ROLES, PROJECT_STATUS } from '@shared/constants';
|
||||
|
||||
ROLES.ADMIN // 'admin'
|
||||
PROJECT_STATUS.IN_PROGRESS // 'in_progress'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scripts NPM
|
||||
|
||||
```bash
|
||||
# Desarrollo
|
||||
npm run dev # Hot-reload con ts-node-dev
|
||||
npm run build # Compilar TypeScript
|
||||
npm run start # Produccion (dist/)
|
||||
|
||||
# Calidad
|
||||
npm run lint # ESLint
|
||||
npm run lint:fix # ESLint con autofix
|
||||
npm run test # Jest
|
||||
npm run test:watch # Jest watch mode
|
||||
npm run test:coverage # Jest con cobertura
|
||||
|
||||
# Base de datos
|
||||
npm run migration:generate # Generar migracion
|
||||
npm run migration:run # Ejecutar migraciones
|
||||
npm run migration:revert # Revertir ultima
|
||||
|
||||
# SSOT
|
||||
npm run validate:constants # Validar no hardcoding
|
||||
npm run sync:enums # Sincronizar a frontend
|
||||
npm run precommit # lint + validate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Convenciones
|
||||
|
||||
### Nomenclatura
|
||||
|
||||
| Tipo | Convencion | Ejemplo |
|
||||
|------|------------|---------|
|
||||
| Archivos | kebab-case.tipo.ts | `concepto.entity.ts` |
|
||||
| Clases | PascalCase + sufijo | `ConceptoService` |
|
||||
| Variables | camelCase | `totalAmount` |
|
||||
| Constantes | UPPER_SNAKE_CASE | `DB_SCHEMAS` |
|
||||
| Metodos | camelCase + verbo | `findByContrato` |
|
||||
|
||||
### Entity Pattern
|
||||
|
||||
```typescript
|
||||
@Entity({ schema: 'construction', name: 'conceptos' })
|
||||
@Index(['tenantId', 'code'], { unique: true })
|
||||
export class Concepto {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
// ... columnas con name: 'snake_case'
|
||||
|
||||
// Soft delete
|
||||
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||
deletedAt: Date | null;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => Tenant)
|
||||
@JoinColumn({ name: 'tenant_id' })
|
||||
tenant: Tenant;
|
||||
}
|
||||
```
|
||||
|
||||
### Service Pattern
|
||||
|
||||
```typescript
|
||||
export class MiService extends BaseService<MiEntity> {
|
||||
constructor(
|
||||
repository: Repository<MiEntity>,
|
||||
private readonly otroRepo: Repository<OtroEntity>
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
async miMetodo(ctx: ServiceContext, data: MiDto): Promise<MiEntity> {
|
||||
// ctx tiene tenantId y userId
|
||||
return this.create(ctx, data);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Seguridad
|
||||
|
||||
- Helmet para HTTP security headers
|
||||
- CORS configurado por dominio
|
||||
- Rate limiting por IP
|
||||
- JWT con refresh tokens
|
||||
- Bcrypt (12 rounds) para passwords
|
||||
- class-validator para inputs
|
||||
- RLS para aislamiento de tenants
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Ejecutar tests
|
||||
npm test
|
||||
|
||||
# Con cobertura
|
||||
npm run test:coverage
|
||||
|
||||
# Watch mode
|
||||
npm run test:watch
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Ejemplo de test
|
||||
describe('ConceptoService', () => {
|
||||
let service: ConceptoService;
|
||||
let mockRepo: jest.Mocked<Repository<Concepto>>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepo = createMockRepository();
|
||||
service = new ConceptoService(mockRepo);
|
||||
});
|
||||
|
||||
it('should create concepto with level', async () => {
|
||||
const ctx = { tenantId: 'uuid', userId: 'uuid' };
|
||||
const dto = { code: '001', name: 'Test' };
|
||||
|
||||
mockRepo.save.mockResolvedValue({ ...dto, level: 0 });
|
||||
|
||||
const result = await service.createConcepto(ctx, dto);
|
||||
expect(result.level).toBe(0);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Debugging
|
||||
|
||||
### VS Code
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Debug Backend",
|
||||
"runtimeArgs": ["-r", "ts-node/register"],
|
||||
"args": ["${workspaceFolder}/src/server.ts"],
|
||||
"env": { "NODE_ENV": "development" }
|
||||
}
|
||||
```
|
||||
|
||||
### Logs
|
||||
|
||||
```typescript
|
||||
// Configurar en .env
|
||||
LOG_LEVEL=debug
|
||||
LOG_FORMAT=dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Ultima actualizacion:** 2025-12-12
|
||||
|
||||
7817
package-lock.json
generated
Normal file
7817
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();
|
||||
169
service.descriptor.yml
Normal file
169
service.descriptor.yml
Normal file
@ -0,0 +1,169 @@
|
||||
# ==============================================================================
|
||||
# SERVICE DESCRIPTOR - ERP CONSTRUCCION API
|
||||
# ==============================================================================
|
||||
# API especializada para construccion residencial
|
||||
# Mantenido por: Backend-Agent
|
||||
# Actualizado: 2025-12-18
|
||||
# ==============================================================================
|
||||
|
||||
version: "1.0.0"
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# IDENTIFICACION DEL SERVICIO
|
||||
# ------------------------------------------------------------------------------
|
||||
service:
|
||||
name: "erp-construccion-api"
|
||||
display_name: "ERP Construccion API"
|
||||
description: "API especializada para gestion de construccion residencial"
|
||||
type: "backend"
|
||||
runtime: "node"
|
||||
framework: "express"
|
||||
owner_agent: "NEXUS-BACKEND"
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# CONFIGURACION DE PUERTOS
|
||||
# ------------------------------------------------------------------------------
|
||||
ports:
|
||||
internal: 3020
|
||||
registry_ref: "projects.erp_suite.verticales.construccion.api"
|
||||
protocol: "http"
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# CONFIGURACION DE BASE DE DATOS
|
||||
# ------------------------------------------------------------------------------
|
||||
database:
|
||||
registry_ref: "erp_construccion"
|
||||
schemas:
|
||||
- "construction"
|
||||
- "budgets"
|
||||
- "estimates"
|
||||
- "progress"
|
||||
- "hr"
|
||||
- "hse"
|
||||
- "inventory"
|
||||
- "purchase"
|
||||
role: "runtime"
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# DEPENDENCIAS
|
||||
# ------------------------------------------------------------------------------
|
||||
dependencies:
|
||||
services:
|
||||
- name: "erp-core-api"
|
||||
type: "backend"
|
||||
required: true
|
||||
description: "Core ERP para auth y catalogos"
|
||||
- name: "postgres"
|
||||
type: "database"
|
||||
required: true
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# MODULOS ESPECIALIZADOS
|
||||
# ------------------------------------------------------------------------------
|
||||
modules:
|
||||
construction:
|
||||
description: "Gestion de construccion"
|
||||
entities:
|
||||
- fraccionamiento
|
||||
- etapa
|
||||
- manzana
|
||||
- lote
|
||||
- prototipo
|
||||
endpoints:
|
||||
- { path: "/fraccionamientos", methods: ["GET", "POST"] }
|
||||
- { path: "/etapas", methods: ["GET", "POST"] }
|
||||
- { path: "/manzanas", methods: ["GET", "POST"] }
|
||||
- { path: "/lotes", methods: ["GET", "POST"] }
|
||||
|
||||
budgets:
|
||||
description: "Presupuestos de obra"
|
||||
entities:
|
||||
- presupuesto
|
||||
- partida
|
||||
- concepto
|
||||
endpoints:
|
||||
- { path: "/presupuestos", methods: ["GET", "POST"] }
|
||||
|
||||
estimates:
|
||||
description: "Estimaciones"
|
||||
entities:
|
||||
- estimacion
|
||||
- detalle_estimacion
|
||||
endpoints:
|
||||
- { path: "/estimaciones", methods: ["GET", "POST"] }
|
||||
|
||||
progress:
|
||||
description: "Avance de obra"
|
||||
entities:
|
||||
- avance_obra
|
||||
- bitacora
|
||||
endpoints:
|
||||
- { path: "/avance-obra", methods: ["GET", "POST"] }
|
||||
|
||||
hr:
|
||||
description: "Recursos humanos"
|
||||
entities:
|
||||
- empleado
|
||||
- nomina
|
||||
- asistencia
|
||||
|
||||
hse:
|
||||
description: "Seguridad e higiene"
|
||||
entities:
|
||||
- incidente
|
||||
- capacitacion
|
||||
|
||||
inventory:
|
||||
description: "Inventario de materiales"
|
||||
entities:
|
||||
- material
|
||||
- movimiento_inventario
|
||||
|
||||
purchase:
|
||||
description: "Compras"
|
||||
entities:
|
||||
- orden_compra
|
||||
- proveedor
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# DOCKER
|
||||
# ------------------------------------------------------------------------------
|
||||
docker:
|
||||
image: "erp-construccion-api"
|
||||
dockerfile: "Dockerfile"
|
||||
networks:
|
||||
- "erp_construccion_${ENV:-local}"
|
||||
- "erp_core_${ENV:-local}"
|
||||
- "infra_shared"
|
||||
labels:
|
||||
traefik:
|
||||
enable: true
|
||||
router: "erp-construccion-api"
|
||||
rule: "Host(`api.construccion.erp.localhost`)"
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# HEALTH CHECK
|
||||
# ------------------------------------------------------------------------------
|
||||
healthcheck:
|
||||
endpoint: "/health"
|
||||
interval: "30s"
|
||||
timeout: "5s"
|
||||
retries: 3
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ESTADO
|
||||
# ------------------------------------------------------------------------------
|
||||
status:
|
||||
phase: "development"
|
||||
version: "0.1.0"
|
||||
completeness: 40
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# METADATA
|
||||
# ------------------------------------------------------------------------------
|
||||
metadata:
|
||||
created_at: "2025-12-18"
|
||||
created_by: "Backend-Agent"
|
||||
project: "erp-suite"
|
||||
vertical: "construccion"
|
||||
team: "construccion-team"
|
||||
229
src/modules/admin/controllers/audit-log.controller.ts
Normal file
229
src/modules/admin/controllers/audit-log.controller.ts
Normal file
@ -0,0 +1,229 @@
|
||||
/**
|
||||
* AuditLogController - Controller de Logs de Auditoría
|
||||
*
|
||||
* Endpoints REST para consulta de logs de auditoría.
|
||||
*
|
||||
* @module Admin
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AuditLogService, AuditLogFilters } from '../services/audit-log.service';
|
||||
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
|
||||
import { AuthService } from '../../auth/services/auth.service';
|
||||
import { AuditLog } from '../entities/audit-log.entity';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
import { Tenant } from '../../core/entities/tenant.entity';
|
||||
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
|
||||
import { ServiceContext } from '../../../shared/services/base.service';
|
||||
|
||||
export function createAuditLogController(dataSource: DataSource): Router {
|
||||
const router = Router();
|
||||
|
||||
// Repositorios
|
||||
const auditLogRepository = dataSource.getRepository(AuditLog);
|
||||
const userRepository = dataSource.getRepository(User);
|
||||
const tenantRepository = dataSource.getRepository(Tenant);
|
||||
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
|
||||
|
||||
// Servicios
|
||||
const auditLogService = new AuditLogService(auditLogRepository);
|
||||
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
|
||||
const authMiddleware = new AuthMiddleware(authService, dataSource);
|
||||
|
||||
// Helper para crear contexto
|
||||
const getContext = (req: Request): ServiceContext => {
|
||||
if (!req.tenantId) {
|
||||
throw new Error('Tenant ID is required');
|
||||
}
|
||||
return {
|
||||
tenantId: req.tenantId,
|
||||
userId: req.user?.sub,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /audit-logs
|
||||
*/
|
||||
router.get('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'auditor'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 200);
|
||||
|
||||
const filters: AuditLogFilters = {};
|
||||
if (req.query.userId) filters.userId = req.query.userId as string;
|
||||
if (req.query.category) filters.category = req.query.category as any;
|
||||
if (req.query.action) filters.action = req.query.action as any;
|
||||
if (req.query.severity) filters.severity = req.query.severity as any;
|
||||
if (req.query.entityType) filters.entityType = req.query.entityType as string;
|
||||
if (req.query.entityId) filters.entityId = req.query.entityId as string;
|
||||
if (req.query.module) filters.module = req.query.module as string;
|
||||
if (req.query.isSuccess !== undefined) filters.isSuccess = req.query.isSuccess === 'true';
|
||||
if (req.query.dateFrom) filters.dateFrom = new Date(req.query.dateFrom as string);
|
||||
if (req.query.dateTo) filters.dateTo = new Date(req.query.dateTo as string);
|
||||
if (req.query.ipAddress) filters.ipAddress = req.query.ipAddress as string;
|
||||
if (req.query.search) filters.search = req.query.search as string;
|
||||
|
||||
const result = await auditLogService.findWithFilters(getContext(req), filters, page, limit);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: result.meta,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /audit-logs/stats
|
||||
*/
|
||||
router.get('/stats', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'auditor'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const days = parseInt(req.query.days as string) || 30;
|
||||
const stats = await auditLogService.getStats(getContext(req), days);
|
||||
|
||||
res.status(200).json({ success: true, data: stats });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /audit-logs/critical
|
||||
*/
|
||||
router.get('/critical', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const days = parseInt(req.query.days as string) || 7;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 100, 500);
|
||||
|
||||
const logs = await auditLogService.getCriticalLogs(getContext(req), days, limit);
|
||||
res.status(200).json({ success: true, data: logs });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /audit-logs/failed
|
||||
*/
|
||||
router.get('/failed', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const hours = parseInt(req.query.hours as string) || 24;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 100, 500);
|
||||
|
||||
const logs = await auditLogService.getFailedLogs(getContext(req), hours, limit);
|
||||
res.status(200).json({ success: true, data: logs });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /audit-logs/entity/:type/:id
|
||||
*/
|
||||
router.get('/entity/:type/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'auditor'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||
|
||||
const result = await auditLogService.findByEntity(
|
||||
getContext(req),
|
||||
req.params.type,
|
||||
req.params.id,
|
||||
page,
|
||||
limit
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: result.meta,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /audit-logs/user/:userId
|
||||
*/
|
||||
router.get('/user/:userId', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'auditor'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 200);
|
||||
|
||||
const result = await auditLogService.findByUser(
|
||||
getContext(req),
|
||||
req.params.userId,
|
||||
page,
|
||||
limit
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: result.meta,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /audit-logs/cleanup
|
||||
* Cleanup expired logs
|
||||
*/
|
||||
router.post('/cleanup', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = await auditLogService.cleanupExpiredLogs(getContext(req));
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: `Cleaned up ${deleted} expired audit logs`,
|
||||
data: { deleted },
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
export default createAuditLogController;
|
||||
283
src/modules/admin/controllers/backup.controller.ts
Normal file
283
src/modules/admin/controllers/backup.controller.ts
Normal file
@ -0,0 +1,283 @@
|
||||
/**
|
||||
* BackupController - Controller de Backups
|
||||
*
|
||||
* Endpoints REST para gestión de backups del sistema.
|
||||
*
|
||||
* @module Admin
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { BackupService, CreateBackupDto, BackupFilters } from '../services/backup.service';
|
||||
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
|
||||
import { AuthService } from '../../auth/services/auth.service';
|
||||
import { Backup } from '../entities/backup.entity';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
import { Tenant } from '../../core/entities/tenant.entity';
|
||||
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
|
||||
import { ServiceContext } from '../../../shared/services/base.service';
|
||||
|
||||
export function createBackupController(dataSource: DataSource): Router {
|
||||
const router = Router();
|
||||
|
||||
// Repositorios
|
||||
const backupRepository = dataSource.getRepository(Backup);
|
||||
const userRepository = dataSource.getRepository(User);
|
||||
const tenantRepository = dataSource.getRepository(Tenant);
|
||||
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
|
||||
|
||||
// Servicios
|
||||
const backupService = new BackupService(backupRepository);
|
||||
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
|
||||
const authMiddleware = new AuthMiddleware(authService, dataSource);
|
||||
|
||||
// Helper para crear contexto
|
||||
const getContext = (req: Request): ServiceContext => {
|
||||
if (!req.tenantId) {
|
||||
throw new Error('Tenant ID is required');
|
||||
}
|
||||
return {
|
||||
tenantId: req.tenantId,
|
||||
userId: req.user?.sub,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /backups
|
||||
*/
|
||||
router.get('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||
|
||||
const filters: BackupFilters = {};
|
||||
if (req.query.backupType) filters.backupType = req.query.backupType as any;
|
||||
if (req.query.status) filters.status = req.query.status as any;
|
||||
if (req.query.storageLocation) filters.storageLocation = req.query.storageLocation as any;
|
||||
if (req.query.isScheduled !== undefined) filters.isScheduled = req.query.isScheduled === 'true';
|
||||
if (req.query.isVerified !== undefined) filters.isVerified = req.query.isVerified === 'true';
|
||||
if (req.query.dateFrom) filters.dateFrom = new Date(req.query.dateFrom as string);
|
||||
if (req.query.dateTo) filters.dateTo = new Date(req.query.dateTo as string);
|
||||
|
||||
const result = await backupService.findWithFilters(getContext(req), filters, page, limit);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: result.meta,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /backups/stats
|
||||
*/
|
||||
router.get('/stats', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = await backupService.getStats(getContext(req));
|
||||
res.status(200).json({ success: true, data: stats });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /backups/last
|
||||
*/
|
||||
router.get('/last', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const backupType = req.query.backupType as any;
|
||||
const backup = await backupService.getLastSuccessful(getContext(req), backupType);
|
||||
|
||||
if (!backup) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'No successful backup found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: backup });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /backups/pending-verification
|
||||
*/
|
||||
router.get('/pending-verification', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const backups = await backupService.getPendingVerification(getContext(req));
|
||||
res.status(200).json({ success: true, data: backups });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /backups/:id
|
||||
*/
|
||||
router.get('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const backup = await backupService.findById(getContext(req), req.params.id);
|
||||
if (!backup) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Backup not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: backup });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /backups
|
||||
*/
|
||||
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dto: CreateBackupDto = req.body;
|
||||
if (!dto.backupType || !dto.name) {
|
||||
res.status(400).json({
|
||||
error: 'Bad Request',
|
||||
message: 'backupType and name are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const backup = await backupService.initiateBackup(getContext(req), dto);
|
||||
res.status(201).json({ success: true, data: backup });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /backups/:id/verify
|
||||
*/
|
||||
router.post('/:id/verify', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const backup = await backupService.markVerified(getContext(req), req.params.id);
|
||||
if (!backup) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Backup not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: backup });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('Only completed')) {
|
||||
res.status(400).json({ error: 'Bad Request', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /backups/:id/cancel
|
||||
*/
|
||||
router.post('/:id/cancel', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const backup = await backupService.cancelBackup(getContext(req), req.params.id);
|
||||
if (!backup) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Backup not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: backup });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('Only pending')) {
|
||||
res.status(400).json({ error: 'Bad Request', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /backups/cleanup
|
||||
*/
|
||||
router.post('/cleanup', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const expired = await backupService.cleanupExpired(getContext(req));
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: `Marked ${expired} backups as expired`,
|
||||
data: { expired },
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /backups/:id
|
||||
*/
|
||||
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = await backupService.softDelete(getContext(req), req.params.id);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Backup not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, message: 'Backup deleted' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
export default createBackupController;
|
||||
280
src/modules/admin/controllers/cost-center.controller.ts
Normal file
280
src/modules/admin/controllers/cost-center.controller.ts
Normal file
@ -0,0 +1,280 @@
|
||||
/**
|
||||
* CostCenterController - Controller de Centros de Costo
|
||||
*
|
||||
* Endpoints REST para gestión de centros de costo jerárquicos.
|
||||
*
|
||||
* @module Admin
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { CostCenterService, CreateCostCenterDto, UpdateCostCenterDto, CostCenterFilters } from '../services/cost-center.service';
|
||||
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
|
||||
import { AuthService } from '../../auth/services/auth.service';
|
||||
import { CostCenter } from '../entities/cost-center.entity';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
import { Tenant } from '../../core/entities/tenant.entity';
|
||||
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
|
||||
import { ServiceContext } from '../../../shared/services/base.service';
|
||||
|
||||
export function createCostCenterController(dataSource: DataSource): Router {
|
||||
const router = Router();
|
||||
|
||||
// Repositorios
|
||||
const costCenterRepository = dataSource.getRepository(CostCenter);
|
||||
const userRepository = dataSource.getRepository(User);
|
||||
const tenantRepository = dataSource.getRepository(Tenant);
|
||||
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
|
||||
|
||||
// Servicios
|
||||
const costCenterService = new CostCenterService(costCenterRepository);
|
||||
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
|
||||
const authMiddleware = new AuthMiddleware(authService, dataSource);
|
||||
|
||||
// Helper para crear contexto
|
||||
const getContext = (req: Request): ServiceContext => {
|
||||
if (!req.tenantId) {
|
||||
throw new Error('Tenant ID is required');
|
||||
}
|
||||
return {
|
||||
tenantId: req.tenantId,
|
||||
userId: req.user?.sub,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /cost-centers
|
||||
*/
|
||||
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||
|
||||
const filters: CostCenterFilters = {};
|
||||
if (req.query.costCenterType) filters.costCenterType = req.query.costCenterType as any;
|
||||
if (req.query.level) filters.level = req.query.level as any;
|
||||
if (req.query.fraccionamientoId) filters.fraccionamientoId = req.query.fraccionamientoId as string;
|
||||
if (req.query.parentId) filters.parentId = req.query.parentId as string;
|
||||
if (req.query.responsibleId) filters.responsibleId = req.query.responsibleId as string;
|
||||
if (req.query.isActive !== undefined) filters.isActive = req.query.isActive === 'true';
|
||||
if (req.query.search) filters.search = req.query.search as string;
|
||||
|
||||
const result = await costCenterService.findWithFilters(getContext(req), filters, page, limit);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: result.meta,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /cost-centers/tree
|
||||
*/
|
||||
router.get('/tree', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const fraccionamientoId = req.query.fraccionamientoId as string | undefined;
|
||||
const tree = await costCenterService.getTree(getContext(req), fraccionamientoId);
|
||||
|
||||
res.status(200).json({ success: true, data: tree });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /cost-centers/stats
|
||||
*/
|
||||
router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = await costCenterService.getStats(getContext(req));
|
||||
res.status(200).json({ success: true, data: stats });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /cost-centers/budget-summary
|
||||
*/
|
||||
router.get('/budget-summary', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const fraccionamientoId = req.query.fraccionamientoId as string | undefined;
|
||||
const summary = await costCenterService.getBudgetSummary(getContext(req), fraccionamientoId);
|
||||
|
||||
res.status(200).json({ success: true, data: summary });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /cost-centers/code/:code
|
||||
*/
|
||||
router.get('/code/:code', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const cc = await costCenterService.findByCode(getContext(req), req.params.code);
|
||||
if (!cc) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Cost center not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: cc });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /cost-centers/:id
|
||||
*/
|
||||
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const cc = await costCenterService.findById(getContext(req), req.params.id);
|
||||
if (!cc) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Cost center not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: cc });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /cost-centers/:id/children
|
||||
*/
|
||||
router.get('/:id/children', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const children = await costCenterService.getChildren(getContext(req), req.params.id);
|
||||
res.status(200).json({ success: true, data: children });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /cost-centers
|
||||
*/
|
||||
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'finance'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dto: CreateCostCenterDto = req.body;
|
||||
if (!dto.code || !dto.name) {
|
||||
res.status(400).json({
|
||||
error: 'Bad Request',
|
||||
message: 'code and name are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const cc = await costCenterService.createCostCenter(getContext(req), dto);
|
||||
res.status(201).json({ success: true, data: cc });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('already exists')) {
|
||||
res.status(409).json({ error: 'Conflict', message: error.message });
|
||||
return;
|
||||
}
|
||||
if (error.message.includes('not found')) {
|
||||
res.status(400).json({ error: 'Bad Request', message: error.message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /cost-centers/:id
|
||||
*/
|
||||
router.put('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'finance'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dto: UpdateCostCenterDto = req.body;
|
||||
const cc = await costCenterService.update(getContext(req), req.params.id, dto);
|
||||
|
||||
if (!cc) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Cost center not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: cc });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /cost-centers/:id
|
||||
*/
|
||||
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = await costCenterService.softDelete(getContext(req), req.params.id);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Cost center not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, message: 'Cost center deleted' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
export default createCostCenterController;
|
||||
9
src/modules/admin/controllers/index.ts
Normal file
9
src/modules/admin/controllers/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Admin Controllers Index
|
||||
* @module Admin
|
||||
*/
|
||||
|
||||
export { createCostCenterController } from './cost-center.controller';
|
||||
export { createAuditLogController } from './audit-log.controller';
|
||||
export { createSystemSettingController } from './system-setting.controller';
|
||||
export { createBackupController } from './backup.controller';
|
||||
370
src/modules/admin/controllers/system-setting.controller.ts
Normal file
370
src/modules/admin/controllers/system-setting.controller.ts
Normal file
@ -0,0 +1,370 @@
|
||||
/**
|
||||
* SystemSettingController - Controller de Configuración del Sistema
|
||||
*
|
||||
* Endpoints REST para gestión de configuraciones.
|
||||
*
|
||||
* @module Admin
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { SystemSettingService, CreateSettingDto, UpdateSettingDto, SettingFilters } from '../services/system-setting.service';
|
||||
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
|
||||
import { AuthService } from '../../auth/services/auth.service';
|
||||
import { SystemSetting } from '../entities/system-setting.entity';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
import { Tenant } from '../../core/entities/tenant.entity';
|
||||
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
|
||||
import { ServiceContext } from '../../../shared/services/base.service';
|
||||
|
||||
export function createSystemSettingController(dataSource: DataSource): Router {
|
||||
const router = Router();
|
||||
|
||||
// Repositorios
|
||||
const settingRepository = dataSource.getRepository(SystemSetting);
|
||||
const userRepository = dataSource.getRepository(User);
|
||||
const tenantRepository = dataSource.getRepository(Tenant);
|
||||
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
|
||||
|
||||
// Servicios
|
||||
const settingService = new SystemSettingService(settingRepository);
|
||||
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
|
||||
const authMiddleware = new AuthMiddleware(authService, dataSource);
|
||||
|
||||
// Helper para crear contexto
|
||||
const getContext = (req: Request): ServiceContext => {
|
||||
if (!req.tenantId) {
|
||||
throw new Error('Tenant ID is required');
|
||||
}
|
||||
return {
|
||||
tenantId: req.tenantId,
|
||||
userId: req.user?.sub,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /settings
|
||||
*/
|
||||
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
||||
|
||||
const filters: SettingFilters = {};
|
||||
if (req.query.category) filters.category = req.query.category as any;
|
||||
if (req.query.isPublic !== undefined) filters.isPublic = req.query.isPublic === 'true';
|
||||
if (req.query.search) filters.search = req.query.search as string;
|
||||
|
||||
const result = await settingService.findWithFilters(getContext(req), filters, page, limit);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: result.meta,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /settings/public
|
||||
*/
|
||||
router.get('/public', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await settingService.getPublicSettings(getContext(req));
|
||||
res.status(200).json({ success: true, data: settings });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /settings/stats
|
||||
*/
|
||||
router.get('/stats', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = await settingService.getStats(getContext(req));
|
||||
res.status(200).json({ success: true, data: stats });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /settings/category/:category
|
||||
*/
|
||||
router.get('/category/:category', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await settingService.findByCategory(getContext(req), req.params.category as any);
|
||||
res.status(200).json({ success: true, data: settings });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /settings/key/:key
|
||||
*/
|
||||
router.get('/key/:key', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const setting = await settingService.findByKey(getContext(req), req.params.key);
|
||||
if (!setting) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Setting not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: setting });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /settings/key/:key/value
|
||||
*/
|
||||
router.get('/key/:key/value', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const value = await settingService.getValue(getContext(req), req.params.key);
|
||||
if (value === null) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Setting not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: { key: req.params.key, value } });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /settings/:id
|
||||
*/
|
||||
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const setting = await settingService.findById(getContext(req), req.params.id);
|
||||
if (!setting) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Setting not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: setting });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /settings
|
||||
*/
|
||||
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dto: CreateSettingDto = req.body;
|
||||
if (!dto.key || !dto.name || dto.value === undefined) {
|
||||
res.status(400).json({
|
||||
error: 'Bad Request',
|
||||
message: 'key, name, and value are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const setting = await settingService.createSetting(getContext(req), dto);
|
||||
res.status(201).json({ success: true, data: setting });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('already exists')) {
|
||||
res.status(409).json({ error: 'Conflict', message: error.message });
|
||||
return;
|
||||
}
|
||||
if (error.message.includes('must be') || error.message.includes('pattern')) {
|
||||
res.status(400).json({ error: 'Bad Request', message: error.message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /settings/:id
|
||||
*/
|
||||
router.put('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dto: UpdateSettingDto = req.body;
|
||||
const setting = await settingService.update(getContext(req), req.params.id, dto);
|
||||
|
||||
if (!setting) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Setting not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: setting });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /settings/key/:key/value
|
||||
*/
|
||||
router.put('/key/:key/value', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { value } = req.body;
|
||||
if (value === undefined) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'value is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const setting = await settingService.updateValue(getContext(req), req.params.key, value);
|
||||
if (!setting) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Setting not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: setting });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && (error.message.includes('must be') || error.message.includes('pattern'))) {
|
||||
res.status(400).json({ error: 'Bad Request', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /settings/key/:key/reset
|
||||
*/
|
||||
router.post('/key/:key/reset', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const setting = await settingService.resetToDefault(getContext(req), req.params.key);
|
||||
if (!setting) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Setting not found or no default value' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: setting });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /settings/bulk
|
||||
*/
|
||||
router.put('/bulk', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { settings } = req.body;
|
||||
if (!Array.isArray(settings)) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'settings array is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await settingService.updateMultiple(getContext(req), settings);
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: `Updated ${result.updated} settings`,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /settings/:id
|
||||
*/
|
||||
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if system setting
|
||||
const setting = await settingService.findById(getContext(req), req.params.id);
|
||||
if (!setting) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Setting not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (setting.isSystem) {
|
||||
res.status(403).json({ error: 'Forbidden', message: 'Cannot delete system settings' });
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = await settingService.hardDelete(getContext(req), req.params.id);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Setting not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, message: 'Setting deleted' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
export default createSystemSettingController;
|
||||
256
src/modules/admin/entities/audit-log.entity.ts
Normal file
256
src/modules/admin/entities/audit-log.entity.ts
Normal file
@ -0,0 +1,256 @@
|
||||
/**
|
||||
* AuditLog Entity
|
||||
* Registro de auditoría para trazabilidad de operaciones
|
||||
*
|
||||
* @module Admin
|
||||
* @table admin.audit_logs
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Tenant } from '../../core/entities/tenant.entity';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
|
||||
export type AuditCategory =
|
||||
| 'authentication'
|
||||
| 'user_management'
|
||||
| 'critical_operation'
|
||||
| 'administration'
|
||||
| 'data_access'
|
||||
| 'system';
|
||||
|
||||
export type AuditAction =
|
||||
| 'login'
|
||||
| 'logout'
|
||||
| 'login_failed'
|
||||
| 'password_change'
|
||||
| 'password_reset'
|
||||
| 'create'
|
||||
| 'read'
|
||||
| 'update'
|
||||
| 'delete'
|
||||
| 'approve'
|
||||
| 'reject'
|
||||
| 'export'
|
||||
| 'import'
|
||||
| 'configure'
|
||||
| 'backup'
|
||||
| 'restore';
|
||||
|
||||
export type AuditSeverity = 'low' | 'medium' | 'high' | 'critical';
|
||||
|
||||
@Entity({ schema: 'admin', name: 'audit_logs' })
|
||||
@Index(['tenantId'])
|
||||
@Index(['userId'])
|
||||
@Index(['category'])
|
||||
@Index(['action'])
|
||||
@Index(['entityType', 'entityId'])
|
||||
@Index(['createdAt'])
|
||||
@Index(['severity'])
|
||||
@Index(['ipAddress'])
|
||||
export class AuditLog {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'user_id', type: 'uuid', nullable: true })
|
||||
userId: string | null;
|
||||
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 30,
|
||||
})
|
||||
category: AuditCategory;
|
||||
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 30,
|
||||
})
|
||||
action: AuditAction;
|
||||
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 20,
|
||||
default: 'medium',
|
||||
})
|
||||
severity: AuditSeverity;
|
||||
|
||||
@Column({
|
||||
name: 'entity_type',
|
||||
type: 'varchar',
|
||||
length: 100,
|
||||
nullable: true,
|
||||
comment: 'Type of entity affected (e.g., User, Estimacion)',
|
||||
})
|
||||
entityType: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'entity_id',
|
||||
type: 'uuid',
|
||||
nullable: true,
|
||||
comment: 'ID of the affected entity',
|
||||
})
|
||||
entityId: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'entity_name',
|
||||
type: 'varchar',
|
||||
length: 200,
|
||||
nullable: true,
|
||||
comment: 'Human-readable name of the entity',
|
||||
})
|
||||
entityName: string | null;
|
||||
|
||||
@Column({
|
||||
type: 'text',
|
||||
comment: 'Human-readable description of the action',
|
||||
})
|
||||
description: string;
|
||||
|
||||
@Column({
|
||||
name: 'old_values',
|
||||
type: 'jsonb',
|
||||
nullable: true,
|
||||
comment: 'Previous values before the change',
|
||||
})
|
||||
oldValues: Record<string, any> | null;
|
||||
|
||||
@Column({
|
||||
name: 'new_values',
|
||||
type: 'jsonb',
|
||||
nullable: true,
|
||||
comment: 'New values after the change',
|
||||
})
|
||||
newValues: Record<string, any> | null;
|
||||
|
||||
@Column({
|
||||
name: 'changed_fields',
|
||||
type: 'varchar',
|
||||
array: true,
|
||||
nullable: true,
|
||||
comment: 'List of fields that were changed',
|
||||
})
|
||||
changedFields: string[] | null;
|
||||
|
||||
@Column({
|
||||
name: 'ip_address',
|
||||
type: 'varchar',
|
||||
length: 45,
|
||||
nullable: true,
|
||||
})
|
||||
ipAddress: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'user_agent',
|
||||
type: 'varchar',
|
||||
length: 500,
|
||||
nullable: true,
|
||||
})
|
||||
userAgent: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'request_id',
|
||||
type: 'varchar',
|
||||
length: 100,
|
||||
nullable: true,
|
||||
comment: 'Request/correlation ID for tracing',
|
||||
})
|
||||
requestId: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'session_id',
|
||||
type: 'varchar',
|
||||
length: 100,
|
||||
nullable: true,
|
||||
})
|
||||
sessionId: string | null;
|
||||
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 100,
|
||||
nullable: true,
|
||||
comment: 'Module where action occurred',
|
||||
})
|
||||
module: string | null;
|
||||
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 200,
|
||||
nullable: true,
|
||||
comment: 'API endpoint or route',
|
||||
})
|
||||
endpoint: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'http_method',
|
||||
type: 'varchar',
|
||||
length: 10,
|
||||
nullable: true,
|
||||
})
|
||||
httpMethod: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'response_status',
|
||||
type: 'integer',
|
||||
nullable: true,
|
||||
})
|
||||
responseStatus: number | null;
|
||||
|
||||
@Column({
|
||||
name: 'duration_ms',
|
||||
type: 'integer',
|
||||
nullable: true,
|
||||
comment: 'Request duration in milliseconds',
|
||||
})
|
||||
durationMs: number | null;
|
||||
|
||||
@Column({
|
||||
name: 'is_success',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
})
|
||||
isSuccess: boolean;
|
||||
|
||||
@Column({
|
||||
name: 'error_message',
|
||||
type: 'text',
|
||||
nullable: true,
|
||||
})
|
||||
errorMessage: string | null;
|
||||
|
||||
@Column({
|
||||
type: 'jsonb',
|
||||
nullable: true,
|
||||
comment: 'Additional context data',
|
||||
})
|
||||
metadata: Record<string, any> | null;
|
||||
|
||||
@Column({
|
||||
name: 'retention_days',
|
||||
type: 'integer',
|
||||
default: 90,
|
||||
comment: 'Days to retain this log (90 for operational, 1825 for critical)',
|
||||
})
|
||||
retentionDays: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => Tenant)
|
||||
@JoinColumn({ name: 'tenant_id' })
|
||||
tenant: Tenant;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user: User | null;
|
||||
}
|
||||
301
src/modules/admin/entities/backup.entity.ts
Normal file
301
src/modules/admin/entities/backup.entity.ts
Normal file
@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Backup Entity
|
||||
* Registro de backups del sistema
|
||||
*
|
||||
* @module Admin
|
||||
* @table admin.backups
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Tenant } from '../../core/entities/tenant.entity';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
|
||||
export type BackupType = 'full' | 'incremental' | 'differential' | 'files' | 'snapshot';
|
||||
|
||||
export type BackupStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'expired';
|
||||
|
||||
export type BackupStorage = 'local' | 's3' | 'gcs' | 'azure' | 'offsite';
|
||||
|
||||
@Entity({ schema: 'admin', name: 'backups' })
|
||||
@Index(['tenantId'])
|
||||
@Index(['backupType'])
|
||||
@Index(['status'])
|
||||
@Index(['createdAt'])
|
||||
@Index(['expiresAt'])
|
||||
export class Backup {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({
|
||||
name: 'backup_type',
|
||||
type: 'varchar',
|
||||
length: 20,
|
||||
})
|
||||
backupType: BackupType;
|
||||
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 20,
|
||||
default: 'pending',
|
||||
})
|
||||
status: BackupStatus;
|
||||
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 200,
|
||||
comment: 'Descriptive name for the backup',
|
||||
})
|
||||
name: string;
|
||||
|
||||
@Column({
|
||||
type: 'text',
|
||||
nullable: true,
|
||||
})
|
||||
description: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'file_path',
|
||||
type: 'varchar',
|
||||
length: 500,
|
||||
nullable: true,
|
||||
})
|
||||
filePath: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'file_name',
|
||||
type: 'varchar',
|
||||
length: 200,
|
||||
nullable: true,
|
||||
})
|
||||
fileName: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'file_size',
|
||||
type: 'bigint',
|
||||
nullable: true,
|
||||
comment: 'Size in bytes',
|
||||
})
|
||||
fileSize: number | null;
|
||||
|
||||
@Column({
|
||||
name: 'storage_location',
|
||||
type: 'varchar',
|
||||
length: 20,
|
||||
default: 'local',
|
||||
})
|
||||
storageLocation: BackupStorage;
|
||||
|
||||
@Column({
|
||||
name: 'storage_url',
|
||||
type: 'varchar',
|
||||
length: 1000,
|
||||
nullable: true,
|
||||
comment: 'Full URL or path to backup file',
|
||||
})
|
||||
storageUrl: string | null;
|
||||
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 64,
|
||||
nullable: true,
|
||||
comment: 'SHA-256 checksum for integrity verification',
|
||||
})
|
||||
checksum: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'is_encrypted',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
})
|
||||
isEncrypted: boolean;
|
||||
|
||||
@Column({
|
||||
name: 'encryption_key_id',
|
||||
type: 'varchar',
|
||||
length: 100,
|
||||
nullable: true,
|
||||
comment: 'Reference to encryption key used',
|
||||
})
|
||||
encryptionKeyId: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'is_compressed',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
})
|
||||
isCompressed: boolean;
|
||||
|
||||
@Column({
|
||||
name: 'compression_type',
|
||||
type: 'varchar',
|
||||
length: 20,
|
||||
nullable: true,
|
||||
comment: 'gzip, lz4, zstd, etc.',
|
||||
})
|
||||
compressionType: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'database_version',
|
||||
type: 'varchar',
|
||||
length: 50,
|
||||
nullable: true,
|
||||
comment: 'PostgreSQL version at backup time',
|
||||
})
|
||||
databaseVersion: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'app_version',
|
||||
type: 'varchar',
|
||||
length: 50,
|
||||
nullable: true,
|
||||
comment: 'Application version at backup time',
|
||||
})
|
||||
appVersion: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'tables_included',
|
||||
type: 'varchar',
|
||||
array: true,
|
||||
nullable: true,
|
||||
comment: 'List of tables included in backup',
|
||||
})
|
||||
tablesIncluded: string[] | null;
|
||||
|
||||
@Column({
|
||||
name: 'tables_excluded',
|
||||
type: 'varchar',
|
||||
array: true,
|
||||
nullable: true,
|
||||
comment: 'List of tables excluded from backup',
|
||||
})
|
||||
tablesExcluded: string[] | null;
|
||||
|
||||
@Column({
|
||||
name: 'row_count',
|
||||
type: 'integer',
|
||||
nullable: true,
|
||||
comment: 'Total rows backed up',
|
||||
})
|
||||
rowCount: number | null;
|
||||
|
||||
@Column({
|
||||
name: 'started_at',
|
||||
type: 'timestamptz',
|
||||
nullable: true,
|
||||
})
|
||||
startedAt: Date | null;
|
||||
|
||||
@Column({
|
||||
name: 'completed_at',
|
||||
type: 'timestamptz',
|
||||
nullable: true,
|
||||
})
|
||||
completedAt: Date | null;
|
||||
|
||||
@Column({
|
||||
name: 'duration_seconds',
|
||||
type: 'integer',
|
||||
nullable: true,
|
||||
})
|
||||
durationSeconds: number | null;
|
||||
|
||||
@Column({
|
||||
name: 'expires_at',
|
||||
type: 'timestamptz',
|
||||
nullable: true,
|
||||
comment: 'When this backup will be automatically deleted',
|
||||
})
|
||||
expiresAt: Date | null;
|
||||
|
||||
@Column({
|
||||
name: 'retention_policy',
|
||||
type: 'varchar',
|
||||
length: 50,
|
||||
nullable: true,
|
||||
comment: 'daily, weekly, monthly, yearly, permanent',
|
||||
})
|
||||
retentionPolicy: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'is_scheduled',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
comment: 'Was this a scheduled backup?',
|
||||
})
|
||||
isScheduled: boolean;
|
||||
|
||||
@Column({
|
||||
name: 'schedule_id',
|
||||
type: 'varchar',
|
||||
length: 100,
|
||||
nullable: true,
|
||||
comment: 'Reference to schedule that triggered this backup',
|
||||
})
|
||||
scheduleId: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'is_verified',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
comment: 'Has restore been tested?',
|
||||
})
|
||||
isVerified: boolean;
|
||||
|
||||
@Column({
|
||||
name: 'verified_at',
|
||||
type: 'timestamptz',
|
||||
nullable: true,
|
||||
})
|
||||
verifiedAt: Date | null;
|
||||
|
||||
@Column({
|
||||
name: 'verified_by',
|
||||
type: 'uuid',
|
||||
nullable: true,
|
||||
})
|
||||
verifiedById: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'error_message',
|
||||
type: 'text',
|
||||
nullable: true,
|
||||
})
|
||||
errorMessage: string | null;
|
||||
|
||||
@Column({
|
||||
type: 'jsonb',
|
||||
nullable: true,
|
||||
comment: 'Additional backup metadata',
|
||||
})
|
||||
metadata: Record<string, any> | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdById: string | null;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => Tenant)
|
||||
@JoinColumn({ name: 'tenant_id' })
|
||||
tenant: Tenant;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'created_by' })
|
||||
createdBy: User | null;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'verified_by' })
|
||||
verifiedBy: User | null;
|
||||
}
|
||||
208
src/modules/admin/entities/cost-center.entity.ts
Normal file
208
src/modules/admin/entities/cost-center.entity.ts
Normal file
@ -0,0 +1,208 @@
|
||||
/**
|
||||
* CostCenter Entity
|
||||
* Centros de costo jerárquicos para imputación de gastos
|
||||
*
|
||||
* @module Admin
|
||||
* @table admin.cost_centers
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
JoinColumn,
|
||||
Index,
|
||||
Tree,
|
||||
TreeChildren,
|
||||
TreeParent,
|
||||
} from 'typeorm';
|
||||
import { Tenant } from '../../core/entities/tenant.entity';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
|
||||
export type CostCenterType = 'direct' | 'indirect' | 'shared_services' | 'overhead';
|
||||
|
||||
export type CostCenterLevel = 'company' | 'project' | 'phase' | 'front' | 'activity';
|
||||
|
||||
@Entity({ schema: 'admin', name: 'cost_centers' })
|
||||
@Tree('closure-table')
|
||||
@Index(['tenantId', 'code'], { unique: true })
|
||||
@Index(['tenantId'])
|
||||
@Index(['parentId'])
|
||||
@Index(['fraccionamientoId'])
|
||||
@Index(['costCenterType'])
|
||||
@Index(['level'])
|
||||
@Index(['isActive'])
|
||||
export class CostCenter {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50 })
|
||||
code: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 200 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'cost_center_type',
|
||||
type: 'varchar',
|
||||
length: 30,
|
||||
default: 'direct',
|
||||
})
|
||||
costCenterType: CostCenterType;
|
||||
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 20,
|
||||
default: 'activity',
|
||||
})
|
||||
level: CostCenterLevel;
|
||||
|
||||
@Column({
|
||||
name: 'parent_id',
|
||||
type: 'uuid',
|
||||
nullable: true,
|
||||
})
|
||||
parentId: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'fraccionamiento_id',
|
||||
type: 'uuid',
|
||||
nullable: true,
|
||||
comment: 'Project this cost center belongs to (null for company level)',
|
||||
})
|
||||
fraccionamientoId: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'responsible_id',
|
||||
type: 'uuid',
|
||||
nullable: true,
|
||||
comment: 'User responsible for this cost center',
|
||||
})
|
||||
responsibleId: string | null;
|
||||
|
||||
@Column({
|
||||
type: 'decimal',
|
||||
precision: 16,
|
||||
scale: 2,
|
||||
default: 0,
|
||||
comment: 'Annual budget for this cost center',
|
||||
})
|
||||
budget: number;
|
||||
|
||||
@Column({
|
||||
name: 'budget_consumed',
|
||||
type: 'decimal',
|
||||
precision: 16,
|
||||
scale: 2,
|
||||
default: 0,
|
||||
comment: 'Amount consumed from budget',
|
||||
})
|
||||
budgetConsumed: number;
|
||||
|
||||
@Column({
|
||||
name: 'distribution_percentage',
|
||||
type: 'decimal',
|
||||
precision: 5,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
comment: 'Percentage for indirect cost distribution',
|
||||
})
|
||||
distributionPercentage: number | null;
|
||||
|
||||
@Column({
|
||||
name: 'distribution_base',
|
||||
type: 'varchar',
|
||||
length: 50,
|
||||
nullable: true,
|
||||
comment: 'Base for distribution: direct_cost, headcount, area, etc.',
|
||||
})
|
||||
distributionBase: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'accounting_code',
|
||||
type: 'varchar',
|
||||
length: 50,
|
||||
nullable: true,
|
||||
comment: 'Code for accounting system integration',
|
||||
})
|
||||
accountingCode: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'is_billable',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
comment: 'Can be billed to client',
|
||||
})
|
||||
isBillable: boolean;
|
||||
|
||||
@Column({
|
||||
name: 'is_active',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
})
|
||||
isActive: boolean;
|
||||
|
||||
@Column({
|
||||
name: 'sort_order',
|
||||
type: 'integer',
|
||||
default: 0,
|
||||
})
|
||||
sortOrder: number;
|
||||
|
||||
@Column({
|
||||
type: 'jsonb',
|
||||
nullable: true,
|
||||
comment: 'Additional metadata',
|
||||
})
|
||||
metadata: Record<string, any> | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdById: string | null;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true })
|
||||
updatedAt: Date | null;
|
||||
|
||||
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||
updatedById: string | null;
|
||||
|
||||
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||
deletedAt: Date | null;
|
||||
|
||||
@Column({ name: 'deleted_by', type: 'uuid', nullable: true })
|
||||
deletedById: string | null;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => Tenant)
|
||||
@JoinColumn({ name: 'tenant_id' })
|
||||
tenant: Tenant;
|
||||
|
||||
@TreeParent()
|
||||
@ManyToOne(() => CostCenter, (cc) => cc.children)
|
||||
@JoinColumn({ name: 'parent_id' })
|
||||
parent: CostCenter | null;
|
||||
|
||||
@TreeChildren()
|
||||
@OneToMany(() => CostCenter, (cc) => cc.parent)
|
||||
children: CostCenter[];
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'responsible_id' })
|
||||
responsible: User | null;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'created_by' })
|
||||
createdBy: User | null;
|
||||
}
|
||||
161
src/modules/admin/entities/custom-permission.entity.ts
Normal file
161
src/modules/admin/entities/custom-permission.entity.ts
Normal file
@ -0,0 +1,161 @@
|
||||
/**
|
||||
* CustomPermission Entity
|
||||
* Permisos personalizados y temporales para usuarios
|
||||
*
|
||||
* @module Admin
|
||||
* @table admin.custom_permissions
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Tenant } from '../../core/entities/tenant.entity';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
|
||||
export type PermissionAction = 'create' | 'read' | 'update' | 'delete' | 'approve' | 'export' | 'import';
|
||||
|
||||
@Entity({ schema: 'admin', name: 'custom_permissions' })
|
||||
@Index(['tenantId'])
|
||||
@Index(['userId'])
|
||||
@Index(['module'])
|
||||
@Index(['isActive'])
|
||||
@Index(['validUntil'])
|
||||
@Index(['tenantId', 'userId', 'module'])
|
||||
export class CustomPermission {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'user_id', type: 'uuid' })
|
||||
userId: string;
|
||||
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 100,
|
||||
comment: 'Module this permission applies to',
|
||||
})
|
||||
module: string;
|
||||
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
array: true,
|
||||
comment: 'Actions allowed',
|
||||
})
|
||||
actions: PermissionAction[];
|
||||
|
||||
@Column({
|
||||
name: 'resource_type',
|
||||
type: 'varchar',
|
||||
length: 100,
|
||||
nullable: true,
|
||||
comment: 'Specific resource type (e.g., Estimacion, Proyecto)',
|
||||
})
|
||||
resourceType: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'resource_id',
|
||||
type: 'uuid',
|
||||
nullable: true,
|
||||
comment: 'Specific resource ID (null = all resources)',
|
||||
})
|
||||
resourceId: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'fraccionamiento_id',
|
||||
type: 'uuid',
|
||||
nullable: true,
|
||||
comment: 'Project scope (null = all projects)',
|
||||
})
|
||||
fraccionamientoId: string | null;
|
||||
|
||||
@Column({
|
||||
type: 'jsonb',
|
||||
nullable: true,
|
||||
comment: 'Additional conditions for permission',
|
||||
})
|
||||
conditions: Record<string, any> | null;
|
||||
|
||||
@Column({
|
||||
type: 'text',
|
||||
nullable: true,
|
||||
comment: 'Reason for granting this permission',
|
||||
})
|
||||
reason: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'valid_from',
|
||||
type: 'timestamptz',
|
||||
default: () => 'CURRENT_TIMESTAMP',
|
||||
})
|
||||
validFrom: Date;
|
||||
|
||||
@Column({
|
||||
name: 'valid_until',
|
||||
type: 'timestamptz',
|
||||
nullable: true,
|
||||
comment: 'Expiration date (null = permanent)',
|
||||
})
|
||||
validUntil: Date | null;
|
||||
|
||||
@Column({
|
||||
name: 'is_active',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
})
|
||||
isActive: boolean;
|
||||
|
||||
@Column({
|
||||
name: 'granted_by',
|
||||
type: 'uuid',
|
||||
})
|
||||
grantedById: string;
|
||||
|
||||
@Column({
|
||||
name: 'revoked_at',
|
||||
type: 'timestamptz',
|
||||
nullable: true,
|
||||
})
|
||||
revokedAt: Date | null;
|
||||
|
||||
@Column({
|
||||
name: 'revoked_by',
|
||||
type: 'uuid',
|
||||
nullable: true,
|
||||
})
|
||||
revokedById: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'revoke_reason',
|
||||
type: 'text',
|
||||
nullable: true,
|
||||
})
|
||||
revokeReason: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => Tenant)
|
||||
@JoinColumn({ name: 'tenant_id' })
|
||||
tenant: Tenant;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user: User;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'granted_by' })
|
||||
grantedBy: User;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'revoked_by' })
|
||||
revokedBy: User | null;
|
||||
}
|
||||
10
src/modules/admin/entities/index.ts
Normal file
10
src/modules/admin/entities/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Admin Module - Entity Exports
|
||||
* MAI-013: Administración & Seguridad
|
||||
*/
|
||||
|
||||
export * from './cost-center.entity';
|
||||
export * from './audit-log.entity';
|
||||
export * from './system-setting.entity';
|
||||
export * from './backup.entity';
|
||||
export * from './custom-permission.entity';
|
||||
180
src/modules/admin/entities/system-setting.entity.ts
Normal file
180
src/modules/admin/entities/system-setting.entity.ts
Normal file
@ -0,0 +1,180 @@
|
||||
/**
|
||||
* SystemSetting Entity
|
||||
* Configuración del sistema por tenant
|
||||
*
|
||||
* @module Admin
|
||||
* @table admin.system_settings
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Tenant } from '../../core/entities/tenant.entity';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
|
||||
export type SettingCategory =
|
||||
| 'general'
|
||||
| 'security'
|
||||
| 'notifications'
|
||||
| 'integrations'
|
||||
| 'workflow'
|
||||
| 'reports'
|
||||
| 'backups'
|
||||
| 'appearance';
|
||||
|
||||
export type SettingDataType = 'string' | 'number' | 'boolean' | 'json' | 'array';
|
||||
|
||||
@Entity({ schema: 'admin', name: 'system_settings' })
|
||||
@Index(['tenantId', 'key'], { unique: true })
|
||||
@Index(['tenantId'])
|
||||
@Index(['category'])
|
||||
@Index(['isPublic'])
|
||||
export class SystemSetting {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 100,
|
||||
comment: 'Unique key for the setting',
|
||||
})
|
||||
key: string;
|
||||
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 200,
|
||||
})
|
||||
name: string;
|
||||
|
||||
@Column({
|
||||
type: 'text',
|
||||
nullable: true,
|
||||
})
|
||||
description: string | null;
|
||||
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 30,
|
||||
default: 'general',
|
||||
})
|
||||
category: SettingCategory;
|
||||
|
||||
@Column({
|
||||
name: 'data_type',
|
||||
type: 'varchar',
|
||||
length: 20,
|
||||
default: 'string',
|
||||
})
|
||||
dataType: SettingDataType;
|
||||
|
||||
@Column({
|
||||
type: 'text',
|
||||
comment: 'Current value (stored as string)',
|
||||
})
|
||||
value: string;
|
||||
|
||||
@Column({
|
||||
name: 'default_value',
|
||||
type: 'text',
|
||||
nullable: true,
|
||||
comment: 'Default value for reset',
|
||||
})
|
||||
defaultValue: string | null;
|
||||
|
||||
@Column({
|
||||
type: 'jsonb',
|
||||
nullable: true,
|
||||
comment: 'Validation rules (min, max, pattern, options, etc.)',
|
||||
})
|
||||
validation: Record<string, any> | null;
|
||||
|
||||
@Column({
|
||||
type: 'jsonb',
|
||||
nullable: true,
|
||||
comment: 'Allowed options for select/enum types',
|
||||
})
|
||||
options: Record<string, any>[] | null;
|
||||
|
||||
@Column({
|
||||
name: 'is_public',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
comment: 'Can be read without authentication',
|
||||
})
|
||||
isPublic: boolean;
|
||||
|
||||
@Column({
|
||||
name: 'is_encrypted',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
comment: 'Value is encrypted (for sensitive data)',
|
||||
})
|
||||
isEncrypted: boolean;
|
||||
|
||||
@Column({
|
||||
name: 'is_system',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
comment: 'System setting cannot be deleted',
|
||||
})
|
||||
isSystem: boolean;
|
||||
|
||||
@Column({
|
||||
name: 'requires_restart',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
comment: 'Requires app restart to take effect',
|
||||
})
|
||||
requiresRestart: boolean;
|
||||
|
||||
@Column({
|
||||
name: 'allowed_roles',
|
||||
type: 'varchar',
|
||||
array: true,
|
||||
nullable: true,
|
||||
comment: 'Roles that can modify this setting',
|
||||
})
|
||||
allowedRoles: string[] | null;
|
||||
|
||||
@Column({
|
||||
name: 'sort_order',
|
||||
type: 'integer',
|
||||
default: 0,
|
||||
})
|
||||
sortOrder: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdById: string | null;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true })
|
||||
updatedAt: Date | null;
|
||||
|
||||
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||
updatedById: string | null;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => Tenant)
|
||||
@JoinColumn({ name: 'tenant_id' })
|
||||
tenant: Tenant;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'created_by' })
|
||||
createdBy: User | null;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'updated_by' })
|
||||
updatedBy: User | null;
|
||||
}
|
||||
309
src/modules/admin/services/audit-log.service.ts
Normal file
309
src/modules/admin/services/audit-log.service.ts
Normal file
@ -0,0 +1,309 @@
|
||||
/**
|
||||
* AuditLogService - Gestión de Logs de Auditoría
|
||||
*
|
||||
* Registra y consulta eventos de auditoría para trazabilidad.
|
||||
*
|
||||
* @module Admin
|
||||
*/
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
|
||||
import { AuditLog, AuditCategory, AuditAction, AuditSeverity } from '../entities/audit-log.entity';
|
||||
|
||||
export interface CreateAuditLogDto {
|
||||
userId?: string;
|
||||
category: AuditCategory;
|
||||
action: AuditAction;
|
||||
severity?: AuditSeverity;
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
entityName?: string;
|
||||
description: string;
|
||||
oldValues?: Record<string, any>;
|
||||
newValues?: Record<string, any>;
|
||||
changedFields?: string[];
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
requestId?: string;
|
||||
sessionId?: string;
|
||||
module?: string;
|
||||
endpoint?: string;
|
||||
httpMethod?: string;
|
||||
responseStatus?: number;
|
||||
durationMs?: number;
|
||||
isSuccess?: boolean;
|
||||
errorMessage?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface AuditLogFilters {
|
||||
userId?: string;
|
||||
category?: AuditCategory;
|
||||
action?: AuditAction;
|
||||
severity?: AuditSeverity;
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
module?: string;
|
||||
isSuccess?: boolean;
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
ipAddress?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export class AuditLogService {
|
||||
constructor(private readonly repository: Repository<AuditLog>) {}
|
||||
|
||||
/**
|
||||
* Crear registro de auditoría
|
||||
*/
|
||||
async log(ctx: ServiceContext, data: CreateAuditLogDto): Promise<AuditLog> {
|
||||
// Determinar retención basada en severidad
|
||||
let retentionDays = 90; // Default for operational logs
|
||||
if (data.severity === 'critical' || data.category === 'critical_operation') {
|
||||
retentionDays = 1825; // 5 years for critical
|
||||
} else if (data.severity === 'high') {
|
||||
retentionDays = 365; // 1 year for high severity
|
||||
}
|
||||
|
||||
const log = this.repository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
...data,
|
||||
severity: data.severity || 'medium',
|
||||
isSuccess: data.isSuccess ?? true,
|
||||
retentionDays,
|
||||
});
|
||||
|
||||
return this.repository.save(log);
|
||||
}
|
||||
|
||||
/**
|
||||
* Buscar logs con filtros
|
||||
*/
|
||||
async findWithFilters(
|
||||
ctx: ServiceContext,
|
||||
filters: AuditLogFilters,
|
||||
page = 1,
|
||||
limit = 50
|
||||
): Promise<PaginatedResult<AuditLog>> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('al')
|
||||
.leftJoinAndSelect('al.user', 'u')
|
||||
.where('al.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (filters.userId) {
|
||||
qb.andWhere('al.user_id = :userId', { userId: filters.userId });
|
||||
}
|
||||
if (filters.category) {
|
||||
qb.andWhere('al.category = :category', { category: filters.category });
|
||||
}
|
||||
if (filters.action) {
|
||||
qb.andWhere('al.action = :action', { action: filters.action });
|
||||
}
|
||||
if (filters.severity) {
|
||||
qb.andWhere('al.severity = :severity', { severity: filters.severity });
|
||||
}
|
||||
if (filters.entityType) {
|
||||
qb.andWhere('al.entity_type = :entityType', { entityType: filters.entityType });
|
||||
}
|
||||
if (filters.entityId) {
|
||||
qb.andWhere('al.entity_id = :entityId', { entityId: filters.entityId });
|
||||
}
|
||||
if (filters.module) {
|
||||
qb.andWhere('al.module = :module', { module: filters.module });
|
||||
}
|
||||
if (filters.isSuccess !== undefined) {
|
||||
qb.andWhere('al.is_success = :isSuccess', { isSuccess: filters.isSuccess });
|
||||
}
|
||||
if (filters.dateFrom) {
|
||||
qb.andWhere('al.created_at >= :dateFrom', { dateFrom: filters.dateFrom });
|
||||
}
|
||||
if (filters.dateTo) {
|
||||
qb.andWhere('al.created_at <= :dateTo', { dateTo: filters.dateTo });
|
||||
}
|
||||
if (filters.ipAddress) {
|
||||
qb.andWhere('al.ip_address = :ipAddress', { ipAddress: filters.ipAddress });
|
||||
}
|
||||
if (filters.search) {
|
||||
qb.andWhere(
|
||||
'(al.description ILIKE :search OR al.entity_name ILIKE :search)',
|
||||
{ search: `%${filters.search}%` }
|
||||
);
|
||||
}
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
qb.orderBy('al.created_at', 'DESC').skip(skip).take(limit);
|
||||
|
||||
const [data, total] = await qb.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener logs por entidad
|
||||
*/
|
||||
async findByEntity(
|
||||
ctx: ServiceContext,
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
page = 1,
|
||||
limit = 20
|
||||
): Promise<PaginatedResult<AuditLog>> {
|
||||
return this.findWithFilters(ctx, { entityType, entityId }, page, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener logs por usuario
|
||||
*/
|
||||
async findByUser(
|
||||
ctx: ServiceContext,
|
||||
userId: string,
|
||||
page = 1,
|
||||
limit = 50
|
||||
): Promise<PaginatedResult<AuditLog>> {
|
||||
return this.findWithFilters(ctx, { userId }, page, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener logs críticos recientes
|
||||
*/
|
||||
async getCriticalLogs(
|
||||
ctx: ServiceContext,
|
||||
days = 7,
|
||||
limit = 100
|
||||
): Promise<AuditLog[]> {
|
||||
const dateFrom = new Date();
|
||||
dateFrom.setDate(dateFrom.getDate() - days);
|
||||
|
||||
return this.repository
|
||||
.createQueryBuilder('al')
|
||||
.leftJoinAndSelect('al.user', 'u')
|
||||
.where('al.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('al.severity IN (:...severities)', { severities: ['high', 'critical'] })
|
||||
.andWhere('al.created_at >= :dateFrom', { dateFrom })
|
||||
.orderBy('al.created_at', 'DESC')
|
||||
.take(limit)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener logs fallidos
|
||||
*/
|
||||
async getFailedLogs(
|
||||
ctx: ServiceContext,
|
||||
hours = 24,
|
||||
limit = 100
|
||||
): Promise<AuditLog[]> {
|
||||
const dateFrom = new Date();
|
||||
dateFrom.setHours(dateFrom.getHours() - hours);
|
||||
|
||||
return this.repository
|
||||
.createQueryBuilder('al')
|
||||
.leftJoinAndSelect('al.user', 'u')
|
||||
.where('al.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('al.is_success = false')
|
||||
.andWhere('al.created_at >= :dateFrom', { dateFrom })
|
||||
.orderBy('al.created_at', 'DESC')
|
||||
.take(limit)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpiar logs expirados
|
||||
*/
|
||||
async cleanupExpiredLogs(ctx: ServiceContext): Promise<number> {
|
||||
const result = await this.repository
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.from(AuditLog)
|
||||
.where('tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere("created_at < NOW() - (retention_days || ' days')::interval")
|
||||
.execute();
|
||||
|
||||
return result.affected || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener estadísticas
|
||||
*/
|
||||
async getStats(ctx: ServiceContext, days = 30): Promise<AuditLogStats> {
|
||||
const dateFrom = new Date();
|
||||
dateFrom.setDate(dateFrom.getDate() - days);
|
||||
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('al')
|
||||
.where('al.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('al.created_at >= :dateFrom', { dateFrom });
|
||||
|
||||
// Total count
|
||||
const total = await qb.getCount();
|
||||
|
||||
// By category
|
||||
const byCategory = await this.repository
|
||||
.createQueryBuilder('al')
|
||||
.select('al.category', 'category')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('al.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('al.created_at >= :dateFrom', { dateFrom })
|
||||
.groupBy('al.category')
|
||||
.getRawMany();
|
||||
|
||||
// By action
|
||||
const byAction = await this.repository
|
||||
.createQueryBuilder('al')
|
||||
.select('al.action', 'action')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('al.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('al.created_at >= :dateFrom', { dateFrom })
|
||||
.groupBy('al.action')
|
||||
.getRawMany();
|
||||
|
||||
// Success/failure
|
||||
const successCount = await this.repository
|
||||
.createQueryBuilder('al')
|
||||
.where('al.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('al.created_at >= :dateFrom', { dateFrom })
|
||||
.andWhere('al.is_success = true')
|
||||
.getCount();
|
||||
|
||||
// By severity
|
||||
const bySeverity = await this.repository
|
||||
.createQueryBuilder('al')
|
||||
.select('al.severity', 'severity')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('al.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('al.created_at >= :dateFrom', { dateFrom })
|
||||
.groupBy('al.severity')
|
||||
.getRawMany();
|
||||
|
||||
return {
|
||||
period: { days, from: dateFrom, to: new Date() },
|
||||
total,
|
||||
successCount,
|
||||
failureCount: total - successCount,
|
||||
successRate: total > 0 ? (successCount / total) * 100 : 0,
|
||||
byCategory: byCategory.map((r) => ({ category: r.category, count: parseInt(r.count) })),
|
||||
byAction: byAction.map((r) => ({ action: r.action, count: parseInt(r.count) })),
|
||||
bySeverity: bySeverity.map((r) => ({ severity: r.severity, count: parseInt(r.count) })),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuditLogStats {
|
||||
period: { days: number; from: Date; to: Date };
|
||||
total: number;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
successRate: number;
|
||||
byCategory: { category: AuditCategory; count: number }[];
|
||||
byAction: { action: AuditAction; count: number }[];
|
||||
bySeverity: { severity: AuditSeverity; count: number }[];
|
||||
}
|
||||
308
src/modules/admin/services/backup.service.ts
Normal file
308
src/modules/admin/services/backup.service.ts
Normal file
@ -0,0 +1,308 @@
|
||||
/**
|
||||
* BackupService - Gestión de Backups
|
||||
*
|
||||
* Administra creación, verificación y restauración de backups.
|
||||
*
|
||||
* @module Admin
|
||||
*/
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
|
||||
import { Backup, BackupType, BackupStatus, BackupStorage } from '../entities/backup.entity';
|
||||
|
||||
export interface CreateBackupDto {
|
||||
backupType: BackupType;
|
||||
name: string;
|
||||
description?: string;
|
||||
storageLocation?: BackupStorage;
|
||||
tablesIncluded?: string[];
|
||||
tablesExcluded?: string[];
|
||||
retentionPolicy?: string;
|
||||
isScheduled?: boolean;
|
||||
scheduleId?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface BackupFilters {
|
||||
backupType?: BackupType;
|
||||
status?: BackupStatus;
|
||||
storageLocation?: BackupStorage;
|
||||
isScheduled?: boolean;
|
||||
isVerified?: boolean;
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
}
|
||||
|
||||
export class BackupService extends BaseService<Backup> {
|
||||
constructor(repository: Repository<Backup>) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* Buscar backups con filtros
|
||||
*/
|
||||
async findWithFilters(
|
||||
ctx: ServiceContext,
|
||||
filters: BackupFilters,
|
||||
page = 1,
|
||||
limit = 20
|
||||
): Promise<PaginatedResult<Backup>> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('b')
|
||||
.leftJoinAndSelect('b.createdBy', 'cb')
|
||||
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (filters.backupType) {
|
||||
qb.andWhere('b.backup_type = :backupType', { backupType: filters.backupType });
|
||||
}
|
||||
if (filters.status) {
|
||||
qb.andWhere('b.status = :status', { status: filters.status });
|
||||
}
|
||||
if (filters.storageLocation) {
|
||||
qb.andWhere('b.storage_location = :storageLocation', { storageLocation: filters.storageLocation });
|
||||
}
|
||||
if (filters.isScheduled !== undefined) {
|
||||
qb.andWhere('b.is_scheduled = :isScheduled', { isScheduled: filters.isScheduled });
|
||||
}
|
||||
if (filters.isVerified !== undefined) {
|
||||
qb.andWhere('b.is_verified = :isVerified', { isVerified: filters.isVerified });
|
||||
}
|
||||
if (filters.dateFrom) {
|
||||
qb.andWhere('b.created_at >= :dateFrom', { dateFrom: filters.dateFrom });
|
||||
}
|
||||
if (filters.dateTo) {
|
||||
qb.andWhere('b.created_at <= :dateTo', { dateTo: filters.dateTo });
|
||||
}
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
qb.orderBy('b.created_at', 'DESC').skip(skip).take(limit);
|
||||
|
||||
const [data, total] = await qb.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Iniciar backup
|
||||
*/
|
||||
async initiateBackup(ctx: ServiceContext, data: CreateBackupDto): Promise<Backup> {
|
||||
// Calculate expiration based on retention policy
|
||||
let expiresAt: Date | null = null;
|
||||
if (data.retentionPolicy) {
|
||||
expiresAt = this.calculateExpiration(data.retentionPolicy);
|
||||
}
|
||||
|
||||
const backup = await this.create(ctx, {
|
||||
...data,
|
||||
status: 'pending' as BackupStatus,
|
||||
isEncrypted: true,
|
||||
isCompressed: true,
|
||||
compressionType: 'gzip',
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
// In production, this would trigger an async job
|
||||
// For now, we simulate starting the backup
|
||||
await this.startBackupProcess(ctx, backup.id);
|
||||
|
||||
return this.findById(ctx, backup.id) as Promise<Backup>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proceso de backup (simulado)
|
||||
*/
|
||||
private async startBackupProcess(ctx: ServiceContext, backupId: string): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
await this.update(ctx, backupId, {
|
||||
status: 'running' as BackupStatus,
|
||||
startedAt: new Date(),
|
||||
});
|
||||
|
||||
// Simulate backup process
|
||||
// In production, this would be handled by a job queue
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
await this.update(ctx, backupId, {
|
||||
status: 'completed' as BackupStatus,
|
||||
completedAt: new Date(),
|
||||
durationSeconds: Math.ceil(duration / 1000),
|
||||
// These would be real values from the backup process
|
||||
fileSize: 0,
|
||||
rowCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcular fecha de expiración
|
||||
*/
|
||||
private calculateExpiration(policy: string): Date | null {
|
||||
const now = new Date();
|
||||
switch (policy) {
|
||||
case 'daily':
|
||||
now.setDate(now.getDate() + 7); // Keep 7 days
|
||||
return now;
|
||||
case 'weekly':
|
||||
now.setDate(now.getDate() + 30); // Keep 30 days
|
||||
return now;
|
||||
case 'monthly':
|
||||
now.setMonth(now.getMonth() + 12); // Keep 12 months
|
||||
return now;
|
||||
case 'yearly':
|
||||
now.setFullYear(now.getFullYear() + 7); // Keep 7 years
|
||||
return now;
|
||||
case 'permanent':
|
||||
return null; // Never expires
|
||||
default:
|
||||
now.setDate(now.getDate() + 30); // Default 30 days
|
||||
return now;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marcar backup como verificado
|
||||
*/
|
||||
async markVerified(ctx: ServiceContext, id: string): Promise<Backup | null> {
|
||||
const backup = await this.findById(ctx, id);
|
||||
if (!backup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (backup.status !== 'completed') {
|
||||
throw new Error('Only completed backups can be verified');
|
||||
}
|
||||
|
||||
return this.update(ctx, id, {
|
||||
isVerified: true,
|
||||
verifiedAt: new Date(),
|
||||
verifiedById: ctx.userId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancelar backup en progreso
|
||||
*/
|
||||
async cancelBackup(ctx: ServiceContext, id: string): Promise<Backup | null> {
|
||||
const backup = await this.findById(ctx, id);
|
||||
if (!backup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!['pending', 'running'].includes(backup.status)) {
|
||||
throw new Error('Only pending or running backups can be cancelled');
|
||||
}
|
||||
|
||||
return this.update(ctx, id, {
|
||||
status: 'cancelled' as BackupStatus,
|
||||
completedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener último backup exitoso
|
||||
*/
|
||||
async getLastSuccessful(ctx: ServiceContext, backupType?: BackupType): Promise<Backup | null> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('b')
|
||||
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('b.status = :status', { status: 'completed' });
|
||||
|
||||
if (backupType) {
|
||||
qb.andWhere('b.backup_type = :backupType', { backupType });
|
||||
}
|
||||
|
||||
return qb.orderBy('b.completed_at', 'DESC').getOne();
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener backups pendientes de verificación
|
||||
*/
|
||||
async getPendingVerification(ctx: ServiceContext): Promise<Backup[]> {
|
||||
return this.repository.find({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
status: 'completed' as BackupStatus,
|
||||
isVerified: false,
|
||||
} as any,
|
||||
order: { completedAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpiar backups expirados
|
||||
*/
|
||||
async cleanupExpired(ctx: ServiceContext): Promise<number> {
|
||||
const result = await this.repository
|
||||
.createQueryBuilder()
|
||||
.update(Backup)
|
||||
.set({ status: 'expired' as BackupStatus })
|
||||
.where('tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('expires_at < NOW()')
|
||||
.andWhere('status = :status', { status: 'completed' })
|
||||
.execute();
|
||||
|
||||
return result.affected || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener estadísticas
|
||||
*/
|
||||
async getStats(ctx: ServiceContext): Promise<BackupStats> {
|
||||
const all = await this.repository.find({
|
||||
where: { tenantId: ctx.tenantId } as any,
|
||||
});
|
||||
|
||||
const byType = new Map<BackupType, number>();
|
||||
const byStatus = new Map<BackupStatus, number>();
|
||||
let totalSize = 0;
|
||||
let verifiedCount = 0;
|
||||
|
||||
for (const backup of all) {
|
||||
byType.set(backup.backupType, (byType.get(backup.backupType) || 0) + 1);
|
||||
byStatus.set(backup.status, (byStatus.get(backup.status) || 0) + 1);
|
||||
totalSize += Number(backup.fileSize) || 0;
|
||||
if (backup.isVerified) verifiedCount++;
|
||||
}
|
||||
|
||||
const lastBackup = await this.getLastSuccessful(ctx);
|
||||
|
||||
return {
|
||||
total: all.length,
|
||||
totalSizeBytes: totalSize,
|
||||
totalSizeHuman: this.formatBytes(totalSize),
|
||||
verifiedCount,
|
||||
byType: Array.from(byType.entries()).map(([type, count]) => ({ type, count })),
|
||||
byStatus: Array.from(byStatus.entries()).map(([status, count]) => ({ status, count })),
|
||||
lastBackupAt: lastBackup?.completedAt || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatear bytes a formato legible
|
||||
*/
|
||||
private formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
}
|
||||
|
||||
export interface BackupStats {
|
||||
total: number;
|
||||
totalSizeBytes: number;
|
||||
totalSizeHuman: string;
|
||||
verifiedCount: number;
|
||||
byType: { type: BackupType; count: number }[];
|
||||
byStatus: { status: BackupStatus; count: number }[];
|
||||
lastBackupAt: Date | null;
|
||||
}
|
||||
336
src/modules/admin/services/cost-center.service.ts
Normal file
336
src/modules/admin/services/cost-center.service.ts
Normal file
@ -0,0 +1,336 @@
|
||||
/**
|
||||
* CostCenterService - Gestión de Centros de Costo
|
||||
*
|
||||
* Administra centros de costo jerárquicos para imputación de gastos.
|
||||
*
|
||||
* @module Admin
|
||||
*/
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
|
||||
import { CostCenter, CostCenterType, CostCenterLevel } from '../entities/cost-center.entity';
|
||||
|
||||
export interface CreateCostCenterDto {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
costCenterType?: CostCenterType;
|
||||
level?: CostCenterLevel;
|
||||
parentId?: string;
|
||||
fraccionamientoId?: string;
|
||||
responsibleId?: string;
|
||||
budget?: number;
|
||||
distributionPercentage?: number;
|
||||
distributionBase?: string;
|
||||
accountingCode?: string;
|
||||
isBillable?: boolean;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface UpdateCostCenterDto extends Partial<CreateCostCenterDto> {
|
||||
isActive?: boolean;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface CostCenterFilters {
|
||||
costCenterType?: CostCenterType;
|
||||
level?: CostCenterLevel;
|
||||
fraccionamientoId?: string;
|
||||
parentId?: string;
|
||||
responsibleId?: string;
|
||||
isActive?: boolean;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export class CostCenterService extends BaseService<CostCenter> {
|
||||
constructor(repository: Repository<CostCenter>) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* Buscar centros de costo con filtros
|
||||
*/
|
||||
async findWithFilters(
|
||||
ctx: ServiceContext,
|
||||
filters: CostCenterFilters,
|
||||
page = 1,
|
||||
limit = 20
|
||||
): Promise<PaginatedResult<CostCenter>> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('cc')
|
||||
.leftJoinAndSelect('cc.responsible', 'r')
|
||||
.where('cc.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('cc.deleted_at IS NULL');
|
||||
|
||||
if (filters.costCenterType) {
|
||||
qb.andWhere('cc.cost_center_type = :costCenterType', { costCenterType: filters.costCenterType });
|
||||
}
|
||||
if (filters.level) {
|
||||
qb.andWhere('cc.level = :level', { level: filters.level });
|
||||
}
|
||||
if (filters.fraccionamientoId) {
|
||||
qb.andWhere('cc.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId: filters.fraccionamientoId });
|
||||
}
|
||||
if (filters.parentId !== undefined) {
|
||||
if (filters.parentId === null) {
|
||||
qb.andWhere('cc.parent_id IS NULL');
|
||||
} else {
|
||||
qb.andWhere('cc.parent_id = :parentId', { parentId: filters.parentId });
|
||||
}
|
||||
}
|
||||
if (filters.responsibleId) {
|
||||
qb.andWhere('cc.responsible_id = :responsibleId', { responsibleId: filters.responsibleId });
|
||||
}
|
||||
if (filters.isActive !== undefined) {
|
||||
qb.andWhere('cc.is_active = :isActive', { isActive: filters.isActive });
|
||||
}
|
||||
if (filters.search) {
|
||||
qb.andWhere('(cc.name ILIKE :search OR cc.code ILIKE :search OR cc.description ILIKE :search)', {
|
||||
search: `%${filters.search}%`,
|
||||
});
|
||||
}
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
qb.orderBy('cc.sort_order', 'ASC')
|
||||
.addOrderBy('cc.code', 'ASC')
|
||||
.skip(skip)
|
||||
.take(limit);
|
||||
|
||||
const [data, total] = await qb.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Buscar por código
|
||||
*/
|
||||
async findByCode(ctx: ServiceContext, code: string): Promise<CostCenter | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
code,
|
||||
deletedAt: null,
|
||||
} as any,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener árbol de centros de costo
|
||||
*/
|
||||
async getTree(ctx: ServiceContext, fraccionamientoId?: string): Promise<CostCenter[]> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('cc')
|
||||
.where('cc.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('cc.deleted_at IS NULL')
|
||||
.andWhere('cc.parent_id IS NULL');
|
||||
|
||||
if (fraccionamientoId) {
|
||||
qb.andWhere('(cc.fraccionamiento_id = :fraccionamientoId OR cc.fraccionamiento_id IS NULL)', {
|
||||
fraccionamientoId,
|
||||
});
|
||||
}
|
||||
|
||||
const roots = await qb.orderBy('cc.sort_order', 'ASC').getMany();
|
||||
|
||||
// Load children recursively
|
||||
for (const root of roots) {
|
||||
await this.loadChildren(ctx, root, fraccionamientoId);
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cargar hijos recursivamente
|
||||
*/
|
||||
private async loadChildren(
|
||||
ctx: ServiceContext,
|
||||
parent: CostCenter,
|
||||
fraccionamientoId?: string
|
||||
): Promise<void> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('cc')
|
||||
.where('cc.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('cc.deleted_at IS NULL')
|
||||
.andWhere('cc.parent_id = :parentId', { parentId: parent.id });
|
||||
|
||||
if (fraccionamientoId) {
|
||||
qb.andWhere('(cc.fraccionamiento_id = :fraccionamientoId OR cc.fraccionamiento_id IS NULL)', {
|
||||
fraccionamientoId,
|
||||
});
|
||||
}
|
||||
|
||||
parent.children = await qb.orderBy('cc.sort_order', 'ASC').getMany();
|
||||
|
||||
for (const child of parent.children) {
|
||||
await this.loadChildren(ctx, child, fraccionamientoId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener hijos directos
|
||||
*/
|
||||
async getChildren(ctx: ServiceContext, parentId: string): Promise<CostCenter[]> {
|
||||
return this.repository.find({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
parentId,
|
||||
deletedAt: null,
|
||||
} as any,
|
||||
order: { sortOrder: 'ASC', code: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Crear centro de costo
|
||||
*/
|
||||
async createCostCenter(ctx: ServiceContext, data: CreateCostCenterDto): Promise<CostCenter> {
|
||||
const existing = await this.findByCode(ctx, data.code);
|
||||
if (existing) {
|
||||
throw new Error(`Cost center with code ${data.code} already exists`);
|
||||
}
|
||||
|
||||
// Validate parent if provided
|
||||
if (data.parentId) {
|
||||
const parent = await this.findById(ctx, data.parentId);
|
||||
if (!parent) {
|
||||
throw new Error('Parent cost center not found');
|
||||
}
|
||||
}
|
||||
|
||||
return this.create(ctx, {
|
||||
...data,
|
||||
isActive: true,
|
||||
budgetConsumed: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar presupuesto consumido
|
||||
*/
|
||||
async updateBudgetConsumed(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
amount: number
|
||||
): Promise<CostCenter | null> {
|
||||
const cc = await this.findById(ctx, id);
|
||||
if (!cc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.update(ctx, id, {
|
||||
budgetConsumed: Number(cc.budgetConsumed) + amount,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener resumen de presupuesto
|
||||
*/
|
||||
async getBudgetSummary(
|
||||
ctx: ServiceContext,
|
||||
fraccionamientoId?: string
|
||||
): Promise<CostCenterBudgetSummary> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('cc')
|
||||
.select([
|
||||
'cc.cost_center_type as type',
|
||||
'SUM(cc.budget) as total_budget',
|
||||
'SUM(cc.budget_consumed) as total_consumed',
|
||||
'COUNT(*) as count',
|
||||
])
|
||||
.where('cc.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('cc.deleted_at IS NULL')
|
||||
.andWhere('cc.is_active = true')
|
||||
.groupBy('cc.cost_center_type');
|
||||
|
||||
if (fraccionamientoId) {
|
||||
qb.andWhere('cc.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
|
||||
}
|
||||
|
||||
const results = await qb.getRawMany();
|
||||
|
||||
let totalBudget = 0;
|
||||
let totalConsumed = 0;
|
||||
const byType: { type: CostCenterType; budget: number; consumed: number; count: number }[] = [];
|
||||
|
||||
for (const r of results) {
|
||||
const budget = parseFloat(r.total_budget || '0');
|
||||
const consumed = parseFloat(r.total_consumed || '0');
|
||||
totalBudget += budget;
|
||||
totalConsumed += consumed;
|
||||
byType.push({
|
||||
type: r.type,
|
||||
budget,
|
||||
consumed,
|
||||
count: parseInt(r.count),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
totalBudget,
|
||||
totalConsumed,
|
||||
totalAvailable: totalBudget - totalConsumed,
|
||||
utilizationPercentage: totalBudget > 0 ? (totalConsumed / totalBudget) * 100 : 0,
|
||||
byType,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener estadísticas
|
||||
*/
|
||||
async getStats(ctx: ServiceContext): Promise<CostCenterStats> {
|
||||
const all = await this.repository.find({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
deletedAt: null,
|
||||
} as any,
|
||||
});
|
||||
|
||||
const byType = new Map<CostCenterType, number>();
|
||||
const byLevel = new Map<CostCenterLevel, number>();
|
||||
let activeCount = 0;
|
||||
|
||||
for (const cc of all) {
|
||||
byType.set(cc.costCenterType, (byType.get(cc.costCenterType) || 0) + 1);
|
||||
byLevel.set(cc.level, (byLevel.get(cc.level) || 0) + 1);
|
||||
if (cc.isActive) activeCount++;
|
||||
}
|
||||
|
||||
return {
|
||||
total: all.length,
|
||||
active: activeCount,
|
||||
inactive: all.length - activeCount,
|
||||
byType: Array.from(byType.entries()).map(([type, count]) => ({ type, count })),
|
||||
byLevel: Array.from(byLevel.entries()).map(([level, count]) => ({ level, count })),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface CostCenterBudgetSummary {
|
||||
totalBudget: number;
|
||||
totalConsumed: number;
|
||||
totalAvailable: number;
|
||||
utilizationPercentage: number;
|
||||
byType: {
|
||||
type: CostCenterType;
|
||||
budget: number;
|
||||
consumed: number;
|
||||
count: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface CostCenterStats {
|
||||
total: number;
|
||||
active: number;
|
||||
inactive: number;
|
||||
byType: { type: CostCenterType; count: number }[];
|
||||
byLevel: { level: CostCenterLevel; count: number }[];
|
||||
}
|
||||
9
src/modules/admin/services/index.ts
Normal file
9
src/modules/admin/services/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Admin Module - Service Exports
|
||||
* MAI-013: Administración & Seguridad
|
||||
*/
|
||||
|
||||
export * from './cost-center.service';
|
||||
export * from './audit-log.service';
|
||||
export * from './system-setting.service';
|
||||
export * from './backup.service';
|
||||
336
src/modules/admin/services/system-setting.service.ts
Normal file
336
src/modules/admin/services/system-setting.service.ts
Normal file
@ -0,0 +1,336 @@
|
||||
/**
|
||||
* SystemSettingService - Gestión de Configuración del Sistema
|
||||
*
|
||||
* Administra configuraciones del sistema por tenant.
|
||||
*
|
||||
* @module Admin
|
||||
*/
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
|
||||
import { SystemSetting, SettingCategory, SettingDataType } from '../entities/system-setting.entity';
|
||||
|
||||
export interface CreateSettingDto {
|
||||
key: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category?: SettingCategory;
|
||||
dataType?: SettingDataType;
|
||||
value: string;
|
||||
defaultValue?: string;
|
||||
validation?: Record<string, any>;
|
||||
options?: Record<string, any>[];
|
||||
isPublic?: boolean;
|
||||
isEncrypted?: boolean;
|
||||
requiresRestart?: boolean;
|
||||
allowedRoles?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateSettingDto {
|
||||
name?: string;
|
||||
description?: string;
|
||||
value?: string;
|
||||
validation?: Record<string, any>;
|
||||
options?: Record<string, any>[];
|
||||
isPublic?: boolean;
|
||||
allowedRoles?: string[];
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface SettingFilters {
|
||||
category?: SettingCategory;
|
||||
isPublic?: boolean;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export class SystemSettingService extends BaseService<SystemSetting> {
|
||||
constructor(repository: Repository<SystemSetting>) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener valor de configuración
|
||||
*/
|
||||
async getValue(ctx: ServiceContext, key: string): Promise<any> {
|
||||
const setting = await this.findByKey(ctx, key);
|
||||
if (!setting) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.parseValue(setting.value, setting.dataType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener valor con default
|
||||
*/
|
||||
async getValueOrDefault(ctx: ServiceContext, key: string, defaultValue: any): Promise<any> {
|
||||
const value = await this.getValue(ctx, key);
|
||||
return value !== null ? value : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Buscar por key
|
||||
*/
|
||||
async findByKey(ctx: ServiceContext, key: string): Promise<SystemSetting | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
key,
|
||||
} as any,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Buscar configuraciones con filtros
|
||||
*/
|
||||
async findWithFilters(
|
||||
ctx: ServiceContext,
|
||||
filters: SettingFilters,
|
||||
page = 1,
|
||||
limit = 50
|
||||
): Promise<PaginatedResult<SystemSetting>> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('ss')
|
||||
.where('ss.tenant_id = :tenantId', { tenantId: ctx.tenantId });
|
||||
|
||||
if (filters.category) {
|
||||
qb.andWhere('ss.category = :category', { category: filters.category });
|
||||
}
|
||||
if (filters.isPublic !== undefined) {
|
||||
qb.andWhere('ss.is_public = :isPublic', { isPublic: filters.isPublic });
|
||||
}
|
||||
if (filters.search) {
|
||||
qb.andWhere(
|
||||
'(ss.key ILIKE :search OR ss.name ILIKE :search OR ss.description ILIKE :search)',
|
||||
{ search: `%${filters.search}%` }
|
||||
);
|
||||
}
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
qb.orderBy('ss.category', 'ASC')
|
||||
.addOrderBy('ss.sort_order', 'ASC')
|
||||
.addOrderBy('ss.key', 'ASC')
|
||||
.skip(skip)
|
||||
.take(limit);
|
||||
|
||||
const [data, total] = await qb.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener por categoría
|
||||
*/
|
||||
async findByCategory(ctx: ServiceContext, category: SettingCategory): Promise<SystemSetting[]> {
|
||||
return this.repository.find({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
category,
|
||||
} as any,
|
||||
order: { sortOrder: 'ASC', key: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener configuraciones públicas
|
||||
*/
|
||||
async getPublicSettings(ctx: ServiceContext): Promise<Record<string, any>> {
|
||||
const settings = await this.repository.find({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
isPublic: true,
|
||||
} as any,
|
||||
});
|
||||
|
||||
const result: Record<string, any> = {};
|
||||
for (const setting of settings) {
|
||||
result[setting.key] = this.parseValue(setting.value, setting.dataType);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crear configuración
|
||||
*/
|
||||
async createSetting(ctx: ServiceContext, data: CreateSettingDto): Promise<SystemSetting> {
|
||||
const existing = await this.findByKey(ctx, data.key);
|
||||
if (existing) {
|
||||
throw new Error(`Setting with key ${data.key} already exists`);
|
||||
}
|
||||
|
||||
// Validate value against type and validation rules
|
||||
this.validateValue(data.value, data.dataType || 'string', data.validation);
|
||||
|
||||
return this.create(ctx, {
|
||||
...data,
|
||||
isSystem: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar valor
|
||||
*/
|
||||
async updateValue(ctx: ServiceContext, key: string, value: string): Promise<SystemSetting | null> {
|
||||
const setting = await this.findByKey(ctx, key);
|
||||
if (!setting) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate value against type and validation rules
|
||||
this.validateValue(value, setting.dataType, setting.validation);
|
||||
|
||||
return this.update(ctx, setting.id, { value });
|
||||
}
|
||||
|
||||
/**
|
||||
* Restablecer a valor por defecto
|
||||
*/
|
||||
async resetToDefault(ctx: ServiceContext, key: string): Promise<SystemSetting | null> {
|
||||
const setting = await this.findByKey(ctx, key);
|
||||
if (!setting || !setting.defaultValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.update(ctx, setting.id, { value: setting.defaultValue });
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar múltiples configuraciones
|
||||
*/
|
||||
async updateMultiple(
|
||||
ctx: ServiceContext,
|
||||
settings: { key: string; value: string }[]
|
||||
): Promise<{ updated: number; errors: string[] }> {
|
||||
let updated = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const { key, value } of settings) {
|
||||
try {
|
||||
const result = await this.updateValue(ctx, key, value);
|
||||
if (result) {
|
||||
updated++;
|
||||
} else {
|
||||
errors.push(`Setting ${key} not found`);
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`${key}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { updated, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsear valor según tipo
|
||||
*/
|
||||
private parseValue(value: string, dataType: SettingDataType): any {
|
||||
switch (dataType) {
|
||||
case 'number':
|
||||
return parseFloat(value);
|
||||
case 'boolean':
|
||||
return value === 'true' || value === '1';
|
||||
case 'json':
|
||||
case 'array':
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validar valor
|
||||
*/
|
||||
private validateValue(
|
||||
value: string,
|
||||
dataType: SettingDataType,
|
||||
validation?: Record<string, any> | null
|
||||
): void {
|
||||
// Basic type validation
|
||||
switch (dataType) {
|
||||
case 'number':
|
||||
if (isNaN(parseFloat(value))) {
|
||||
throw new Error('Value must be a valid number');
|
||||
}
|
||||
break;
|
||||
case 'boolean':
|
||||
if (!['true', 'false', '1', '0'].includes(value)) {
|
||||
throw new Error('Value must be true/false or 1/0');
|
||||
}
|
||||
break;
|
||||
case 'json':
|
||||
case 'array':
|
||||
try {
|
||||
JSON.parse(value);
|
||||
} catch {
|
||||
throw new Error('Value must be valid JSON');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Custom validation rules
|
||||
if (validation) {
|
||||
if (validation.min !== undefined && parseFloat(value) < validation.min) {
|
||||
throw new Error(`Value must be at least ${validation.min}`);
|
||||
}
|
||||
if (validation.max !== undefined && parseFloat(value) > validation.max) {
|
||||
throw new Error(`Value must be at most ${validation.max}`);
|
||||
}
|
||||
if (validation.pattern && !new RegExp(validation.pattern).test(value)) {
|
||||
throw new Error(`Value does not match required pattern`);
|
||||
}
|
||||
if (validation.options && !validation.options.includes(value)) {
|
||||
throw new Error(`Value must be one of: ${validation.options.join(', ')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener estadísticas
|
||||
*/
|
||||
async getStats(ctx: ServiceContext): Promise<SettingStats> {
|
||||
const all = await this.repository.find({
|
||||
where: { tenantId: ctx.tenantId } as any,
|
||||
});
|
||||
|
||||
const byCategory = new Map<SettingCategory, number>();
|
||||
let publicCount = 0;
|
||||
let systemCount = 0;
|
||||
let encryptedCount = 0;
|
||||
|
||||
for (const setting of all) {
|
||||
byCategory.set(setting.category, (byCategory.get(setting.category) || 0) + 1);
|
||||
if (setting.isPublic) publicCount++;
|
||||
if (setting.isSystem) systemCount++;
|
||||
if (setting.isEncrypted) encryptedCount++;
|
||||
}
|
||||
|
||||
return {
|
||||
total: all.length,
|
||||
publicCount,
|
||||
systemCount,
|
||||
encryptedCount,
|
||||
byCategory: Array.from(byCategory.entries()).map(([category, count]) => ({ category, count })),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface SettingStats {
|
||||
total: number;
|
||||
publicCount: number;
|
||||
systemCount: number;
|
||||
encryptedCount: number;
|
||||
byCategory: { category: SettingCategory; count: number }[];
|
||||
}
|
||||
268
src/modules/auth/controllers/auth.controller.ts
Normal file
268
src/modules/auth/controllers/auth.controller.ts
Normal file
@ -0,0 +1,268 @@
|
||||
/**
|
||||
* AuthController - Controlador de Autenticación
|
||||
*
|
||||
* Endpoints REST para login, register, refresh y logout.
|
||||
* Implementa validación de datos y manejo de errores.
|
||||
*
|
||||
* @module Auth
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import { AuthMiddleware } from '../middleware/auth.middleware';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
import { Tenant } from '../../core/entities/tenant.entity';
|
||||
import { RefreshToken } from '../entities/refresh-token.entity';
|
||||
import {
|
||||
LoginDto,
|
||||
RegisterDto,
|
||||
RefreshTokenDto,
|
||||
ChangePasswordDto,
|
||||
} from '../dto/auth.dto';
|
||||
|
||||
/**
|
||||
* Crear router de autenticación
|
||||
*/
|
||||
export function createAuthController(dataSource: DataSource): Router {
|
||||
const router = Router();
|
||||
|
||||
// Inicializar repositorios
|
||||
const userRepository = dataSource.getRepository(User);
|
||||
const tenantRepository = dataSource.getRepository(Tenant);
|
||||
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
|
||||
|
||||
// Inicializar servicio
|
||||
const authService = new AuthService(
|
||||
userRepository,
|
||||
tenantRepository,
|
||||
refreshTokenRepository as any
|
||||
);
|
||||
|
||||
// Inicializar middleware
|
||||
const authMiddleware = new AuthMiddleware(authService, dataSource);
|
||||
|
||||
/**
|
||||
* POST /auth/login
|
||||
* Login de usuario
|
||||
*/
|
||||
router.post('/login', async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const dto: LoginDto = req.body;
|
||||
|
||||
if (!dto.email || !dto.password) {
|
||||
res.status(400).json({
|
||||
error: 'Bad Request',
|
||||
message: 'Email and password are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await authService.login(dto);
|
||||
res.status(200).json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === 'Invalid credentials') {
|
||||
res.status(401).json({ error: 'Unauthorized', message: 'Invalid email or password' });
|
||||
return;
|
||||
}
|
||||
if (error.message === 'User is not active') {
|
||||
res.status(403).json({ error: 'Forbidden', message: 'User account is disabled' });
|
||||
return;
|
||||
}
|
||||
if (error.message === 'No tenant specified' || error.message === 'Tenant not found or inactive') {
|
||||
res.status(400).json({ error: 'Bad Request', message: error.message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /auth/register
|
||||
* Registro de nuevo usuario
|
||||
*/
|
||||
router.post('/register', async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const dto: RegisterDto = req.body;
|
||||
|
||||
if (!dto.email || !dto.password || !dto.firstName || !dto.lastName || !dto.tenantId) {
|
||||
res.status(400).json({
|
||||
error: 'Bad Request',
|
||||
message: 'Email, password, firstName, lastName and tenantId are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(dto.email)) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Invalid email format' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (dto.password.length < 8) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Password must be at least 8 characters' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await authService.register(dto);
|
||||
res.status(201).json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === 'Email already registered') {
|
||||
res.status(409).json({ error: 'Conflict', message: 'Email is already registered' });
|
||||
return;
|
||||
}
|
||||
if (error.message === 'Tenant not found') {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Invalid tenant ID' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /auth/refresh
|
||||
* Renovar access token usando refresh token
|
||||
*/
|
||||
router.post('/refresh', async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const dto: RefreshTokenDto = req.body;
|
||||
|
||||
if (!dto.refreshToken) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Refresh token is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await authService.refresh(dto);
|
||||
res.status(200).json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === 'Invalid refresh token' || error.message === 'Refresh token expired or revoked') {
|
||||
res.status(401).json({ error: 'Unauthorized', message: error.message });
|
||||
return;
|
||||
}
|
||||
if (error.message === 'User not found or inactive') {
|
||||
res.status(401).json({ error: 'Unauthorized', message: 'User account is disabled or deleted' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /auth/logout
|
||||
* Cerrar sesión (revocar refresh token)
|
||||
*/
|
||||
router.post('/logout', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const { refreshToken } = req.body;
|
||||
if (refreshToken) {
|
||||
await authService.logout(refreshToken);
|
||||
}
|
||||
res.status(200).json({ success: true, message: 'Logged out successfully' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /auth/change-password
|
||||
* Cambiar contraseña (requiere autenticación)
|
||||
*/
|
||||
router.post('/change-password', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const dto: ChangePasswordDto = req.body;
|
||||
const userId = req.user?.sub;
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({ error: 'Unauthorized', message: 'User not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dto.currentPassword || !dto.newPassword) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Current password and new password are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (dto.newPassword.length < 8) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'New password must be at least 8 characters' });
|
||||
return;
|
||||
}
|
||||
|
||||
await authService.changePassword(userId, dto);
|
||||
res.status(200).json({ success: true, message: 'Password changed successfully' });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Current password is incorrect') {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Current password is incorrect' });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /auth/me
|
||||
* Obtener información del usuario autenticado
|
||||
*/
|
||||
router.get('/me', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const userId = req.user?.sub;
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({ error: 'Unauthorized', message: 'User not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await userRepository.findOne({
|
||||
where: { id: userId } as any,
|
||||
select: ['id', 'email', 'firstName', 'lastName', 'isActive', 'createdAt'],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
roles: req.user?.roles || [],
|
||||
tenantId: req.tenantId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /auth/verify
|
||||
* Verificar si el token es válido
|
||||
*/
|
||||
router.get('/verify', authMiddleware.authenticate, (req: Request, res: Response): void => {
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
valid: true,
|
||||
user: {
|
||||
id: req.user?.sub,
|
||||
email: req.user?.email,
|
||||
roles: req.user?.roles,
|
||||
},
|
||||
tenantId: req.tenantId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
export default createAuthController;
|
||||
5
src/modules/auth/controllers/index.ts
Normal file
5
src/modules/auth/controllers/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Auth Controllers - Export
|
||||
*/
|
||||
|
||||
export * from './auth.controller';
|
||||
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;
|
||||
}
|
||||
8
src/modules/auth/entities/index.ts
Normal file
8
src/modules/auth/entities/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Auth Entities - Export
|
||||
*/
|
||||
|
||||
export { RefreshToken } from './refresh-token.entity';
|
||||
export { Role } from './role.entity';
|
||||
export { Permission } from './permission.entity';
|
||||
export { UserRole } from './user-role.entity';
|
||||
34
src/modules/auth/entities/permission.entity.ts
Normal file
34
src/modules/auth/entities/permission.entity.ts
Normal file
@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Permission Entity
|
||||
* Permisos granulares del sistema
|
||||
*
|
||||
* @module Auth
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity({ schema: 'auth', name: 'permissions' })
|
||||
export class Permission {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100, unique: true })
|
||||
code: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 200 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50 })
|
||||
module: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
}
|
||||
73
src/modules/auth/entities/refresh-token.entity.ts
Normal file
73
src/modules/auth/entities/refresh-token.entity.ts
Normal file
@ -0,0 +1,73 @@
|
||||
/**
|
||||
* RefreshToken Entity
|
||||
*
|
||||
* Almacena refresh tokens para autenticación JWT.
|
||||
* Permite revocar tokens y gestionar sesiones.
|
||||
*
|
||||
* @module Auth
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
|
||||
@Entity({ name: 'refresh_tokens', schema: 'auth' })
|
||||
@Index(['userId', 'revokedAt'])
|
||||
@Index(['token'])
|
||||
export class RefreshToken {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'user_id', type: 'uuid' })
|
||||
userId: string;
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user: User;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
token: string;
|
||||
|
||||
@Column({ name: 'expires_at', type: 'timestamptz' })
|
||||
expiresAt: Date;
|
||||
|
||||
@Column({ name: 'revoked_at', type: 'timestamptz', nullable: true })
|
||||
revokedAt: Date | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@Column({ name: 'user_agent', type: 'varchar', length: 500, nullable: true })
|
||||
userAgent: string | null;
|
||||
|
||||
@Column({ name: 'ip_address', type: 'varchar', length: 45, nullable: true })
|
||||
ipAddress: string | null;
|
||||
|
||||
/**
|
||||
* Verificar si el token está expirado
|
||||
*/
|
||||
isExpired(): boolean {
|
||||
return this.expiresAt < new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar si el token está revocado
|
||||
*/
|
||||
isRevoked(): boolean {
|
||||
return this.revokedAt !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar si el token es válido
|
||||
*/
|
||||
isValid(): boolean {
|
||||
return !this.isExpired() && !this.isRevoked();
|
||||
}
|
||||
}
|
||||
58
src/modules/auth/entities/role.entity.ts
Normal file
58
src/modules/auth/entities/role.entity.ts
Normal file
@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Role Entity
|
||||
* Roles del sistema para RBAC
|
||||
*
|
||||
* @module Auth
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
ManyToMany,
|
||||
JoinTable,
|
||||
} from 'typeorm';
|
||||
import { Permission } from './permission.entity';
|
||||
import { UserRole } from './user-role.entity';
|
||||
|
||||
@Entity({ schema: 'auth', name: 'roles' })
|
||||
export class Role {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, unique: true })
|
||||
code: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({ name: 'is_system', type: 'boolean', default: false })
|
||||
isSystem: boolean;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToMany(() => Permission)
|
||||
@JoinTable({
|
||||
name: 'role_permissions',
|
||||
joinColumn: { name: 'role_id', referencedColumnName: 'id' },
|
||||
inverseJoinColumn: { name: 'permission_id', referencedColumnName: 'id' },
|
||||
})
|
||||
permissions: Permission[];
|
||||
|
||||
@OneToMany(() => UserRole, (userRole) => userRole.role)
|
||||
userRoles: UserRole[];
|
||||
}
|
||||
54
src/modules/auth/entities/user-role.entity.ts
Normal file
54
src/modules/auth/entities/user-role.entity.ts
Normal file
@ -0,0 +1,54 @@
|
||||
/**
|
||||
* UserRole Entity
|
||||
* Relación usuarios-roles con soporte multi-tenant
|
||||
*
|
||||
* @module Auth
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
import { Role } from './role.entity';
|
||||
import { Tenant } from '../../core/entities/tenant.entity';
|
||||
|
||||
@Entity({ schema: 'auth', name: 'user_roles' })
|
||||
@Index(['userId', 'roleId', 'tenantId'], { unique: true })
|
||||
export class UserRole {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'user_id', type: 'uuid' })
|
||||
userId: string;
|
||||
|
||||
@Column({ name: 'role_id', type: 'uuid' })
|
||||
roleId: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid', nullable: true })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'assigned_by', type: 'uuid', nullable: true })
|
||||
assignedBy: string;
|
||||
|
||||
@CreateDateColumn({ name: 'assigned_at', type: 'timestamptz' })
|
||||
assignedAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user: User;
|
||||
|
||||
@ManyToOne(() => Role, (role) => role.userRoles, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'role_id' })
|
||||
role: Role;
|
||||
|
||||
@ManyToOne(() => Tenant, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'tenant_id' })
|
||||
tenant: Tenant;
|
||||
}
|
||||
14
src/modules/auth/index.ts
Normal file
14
src/modules/auth/index.ts
Normal file
@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Auth Module - Main Exports
|
||||
*
|
||||
* Módulo de autenticación con JWT y refresh tokens.
|
||||
* Implementa multi-tenancy con RLS.
|
||||
*
|
||||
* @module Auth
|
||||
*/
|
||||
|
||||
export * from './dto/auth.dto';
|
||||
export { RefreshToken } from './entities/refresh-token.entity';
|
||||
export { AuthService } from './services/auth.service';
|
||||
export { AuthMiddleware, createAuthMiddleware } from './middleware/auth.middleware';
|
||||
export { createAuthController } from './controllers/auth.controller';
|
||||
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 as jwt.SignOptions['expiresIn'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generar refresh token
|
||||
*/
|
||||
private async generateRefreshToken(userId: string): Promise<string> {
|
||||
const payload: Partial<TokenPayload> = {
|
||||
sub: userId,
|
||||
type: 'refresh',
|
||||
};
|
||||
|
||||
const token = jwt.sign(payload, this.jwtSecret, {
|
||||
expiresIn: this.jwtRefreshExpiresIn as jwt.SignOptions['expiresIn'],
|
||||
});
|
||||
|
||||
// Almacenar en DB
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 7); // 7 días
|
||||
|
||||
await this.refreshTokenRepository.save(
|
||||
this.refreshTokenRepository.create({
|
||||
userId,
|
||||
token,
|
||||
expiresAt,
|
||||
})
|
||||
);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertir expiresIn a segundos
|
||||
*/
|
||||
private getExpiresInSeconds(expiresIn: string): number {
|
||||
const match = expiresIn.match(/^(\d+)([dhms])$/);
|
||||
if (!match) return 86400; // default 1 día
|
||||
|
||||
const value = parseInt(match[1]);
|
||||
const unit = match[2];
|
||||
|
||||
switch (unit) {
|
||||
case 'd': return value * 86400;
|
||||
case 'h': return value * 3600;
|
||||
case 'm': return value * 60;
|
||||
case 's': return value;
|
||||
default: return 86400;
|
||||
}
|
||||
}
|
||||
}
|
||||
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';
|
||||
175
src/modules/bidding/controllers/bid-analytics.controller.ts
Normal file
175
src/modules/bidding/controllers/bid-analytics.controller.ts
Normal file
@ -0,0 +1,175 @@
|
||||
/**
|
||||
* BidAnalyticsController - Controller de Análisis de Licitaciones
|
||||
*
|
||||
* Endpoints REST para dashboards y análisis de preconstrucción.
|
||||
*
|
||||
* @module Bidding
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { BidAnalyticsService } from '../services/bid-analytics.service';
|
||||
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
|
||||
import { AuthService } from '../../auth/services/auth.service';
|
||||
import { Bid } from '../entities/bid.entity';
|
||||
import { Opportunity } from '../entities/opportunity.entity';
|
||||
import { BidCompetitor } from '../entities/bid-competitor.entity';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
import { Tenant } from '../../core/entities/tenant.entity';
|
||||
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
|
||||
import { ServiceContext } from '../../../shared/services/base.service';
|
||||
|
||||
export function createBidAnalyticsController(dataSource: DataSource): Router {
|
||||
const router = Router();
|
||||
|
||||
// Repositorios
|
||||
const bidRepository = dataSource.getRepository(Bid);
|
||||
const opportunityRepository = dataSource.getRepository(Opportunity);
|
||||
const competitorRepository = dataSource.getRepository(BidCompetitor);
|
||||
const userRepository = dataSource.getRepository(User);
|
||||
const tenantRepository = dataSource.getRepository(Tenant);
|
||||
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
|
||||
|
||||
// Servicios
|
||||
const analyticsService = new BidAnalyticsService(bidRepository, opportunityRepository, competitorRepository);
|
||||
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
|
||||
const authMiddleware = new AuthMiddleware(authService, dataSource);
|
||||
|
||||
// Helper para crear contexto
|
||||
const getContext = (req: Request): ServiceContext => {
|
||||
if (!req.tenantId) {
|
||||
throw new Error('Tenant ID is required');
|
||||
}
|
||||
return {
|
||||
tenantId: req.tenantId,
|
||||
userId: req.user?.sub,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /bid-analytics/dashboard
|
||||
*/
|
||||
router.get('/dashboard', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dashboard = await analyticsService.getDashboard(getContext(req));
|
||||
res.status(200).json({ success: true, data: dashboard });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /bid-analytics/pipeline-by-source
|
||||
*/
|
||||
router.get('/pipeline-by-source', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await analyticsService.getPipelineBySource(getContext(req));
|
||||
res.status(200).json({ success: true, data });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /bid-analytics/win-rate-by-type
|
||||
*/
|
||||
router.get('/win-rate-by-type', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const months = parseInt(req.query.months as string) || 12;
|
||||
const data = await analyticsService.getWinRateByType(getContext(req), months);
|
||||
res.status(200).json({ success: true, data });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /bid-analytics/monthly-trend
|
||||
*/
|
||||
router.get('/monthly-trend', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const months = parseInt(req.query.months as string) || 12;
|
||||
const data = await analyticsService.getMonthlyTrend(getContext(req), months);
|
||||
res.status(200).json({ success: true, data });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /bid-analytics/competitors
|
||||
*/
|
||||
router.get('/competitors', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await analyticsService.getCompetitorAnalysis(getContext(req));
|
||||
res.status(200).json({ success: true, data });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /bid-analytics/funnel
|
||||
*/
|
||||
router.get('/funnel', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const months = parseInt(req.query.months as string) || 12;
|
||||
const data = await analyticsService.getFunnelAnalysis(getContext(req), months);
|
||||
res.status(200).json({ success: true, data });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /bid-analytics/cycle-time
|
||||
*/
|
||||
router.get('/cycle-time', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const months = parseInt(req.query.months as string) || 12;
|
||||
const data = await analyticsService.getCycleTimeAnalysis(getContext(req), months);
|
||||
res.status(200).json({ success: true, data });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
export default createBidAnalyticsController;
|
||||
254
src/modules/bidding/controllers/bid-budget.controller.ts
Normal file
254
src/modules/bidding/controllers/bid-budget.controller.ts
Normal file
@ -0,0 +1,254 @@
|
||||
/**
|
||||
* BidBudgetController - Controller de Presupuestos de Licitación
|
||||
*
|
||||
* Endpoints REST para gestión de propuestas económicas.
|
||||
*
|
||||
* @module Bidding
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { BidBudgetService, CreateBudgetItemDto, UpdateBudgetItemDto, BudgetFilters } from '../services/bid-budget.service';
|
||||
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
|
||||
import { AuthService } from '../../auth/services/auth.service';
|
||||
import { BidBudget } from '../entities/bid-budget.entity';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
import { Tenant } from '../../core/entities/tenant.entity';
|
||||
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
|
||||
import { ServiceContext } from '../../../shared/services/base.service';
|
||||
|
||||
export function createBidBudgetController(dataSource: DataSource): Router {
|
||||
const router = Router();
|
||||
|
||||
// Repositorios
|
||||
const budgetRepository = dataSource.getRepository(BidBudget);
|
||||
const userRepository = dataSource.getRepository(User);
|
||||
const tenantRepository = dataSource.getRepository(Tenant);
|
||||
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
|
||||
|
||||
// Servicios
|
||||
const budgetService = new BidBudgetService(budgetRepository);
|
||||
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
|
||||
const authMiddleware = new AuthMiddleware(authService, dataSource);
|
||||
|
||||
// Helper para crear contexto
|
||||
const getContext = (req: Request): ServiceContext => {
|
||||
if (!req.tenantId) {
|
||||
throw new Error('Tenant ID is required');
|
||||
}
|
||||
return {
|
||||
tenantId: req.tenantId,
|
||||
userId: req.user?.sub,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /bid-budgets
|
||||
*/
|
||||
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const bidId = req.query.bidId as string;
|
||||
if (!bidId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'bidId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 100, 500);
|
||||
|
||||
const filters: BudgetFilters = { bidId };
|
||||
if (req.query.itemType) filters.itemType = req.query.itemType as any;
|
||||
if (req.query.status) filters.status = req.query.status as any;
|
||||
if (req.query.parentId !== undefined) {
|
||||
filters.parentId = req.query.parentId === 'null' ? null : req.query.parentId as string;
|
||||
}
|
||||
if (req.query.isSummary !== undefined) filters.isSummary = req.query.isSummary === 'true';
|
||||
|
||||
const result = await budgetService.findWithFilters(getContext(req), filters, page, limit);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: result.meta,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /bid-budgets/tree
|
||||
*/
|
||||
router.get('/tree', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const bidId = req.query.bidId as string;
|
||||
if (!bidId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'bidId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const tree = await budgetService.getTree(getContext(req), bidId);
|
||||
res.status(200).json({ success: true, data: tree });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /bid-budgets/summary
|
||||
*/
|
||||
router.get('/summary', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const bidId = req.query.bidId as string;
|
||||
if (!bidId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'bidId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = await budgetService.getSummary(getContext(req), bidId);
|
||||
res.status(200).json({ success: true, data: summary });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /bid-budgets/:id
|
||||
*/
|
||||
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const item = await budgetService.findById(getContext(req), req.params.id);
|
||||
if (!item) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Budget item not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: item });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /bid-budgets
|
||||
*/
|
||||
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'costos'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dto: CreateBudgetItemDto = req.body;
|
||||
if (!dto.bidId || !dto.code || !dto.name || !dto.itemType) {
|
||||
res.status(400).json({
|
||||
error: 'Bad Request',
|
||||
message: 'bidId, code, name, and itemType are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const item = await budgetService.create(getContext(req), dto);
|
||||
res.status(201).json({ success: true, data: item });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /bid-budgets/:id
|
||||
*/
|
||||
router.put('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'costos'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dto: UpdateBudgetItemDto = req.body;
|
||||
const item = await budgetService.update(getContext(req), req.params.id, dto);
|
||||
|
||||
if (!item) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Budget item not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: item });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /bid-budgets/status
|
||||
*/
|
||||
router.post('/status', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { bidId, status } = req.body;
|
||||
if (!bidId || !status) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'bidId and status are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await budgetService.changeStatus(getContext(req), bidId, status);
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: `Updated ${updated} budget items`,
|
||||
data: { updated },
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /bid-budgets/:id
|
||||
*/
|
||||
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = await budgetService.softDelete(getContext(req), req.params.id);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Budget item not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, message: 'Budget item deleted' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
export default createBidBudgetController;
|
||||
370
src/modules/bidding/controllers/bid.controller.ts
Normal file
370
src/modules/bidding/controllers/bid.controller.ts
Normal file
@ -0,0 +1,370 @@
|
||||
/**
|
||||
* BidController - Controller de Licitaciones
|
||||
*
|
||||
* Endpoints REST para gestión de licitaciones/propuestas.
|
||||
*
|
||||
* @module Bidding
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { BidService, CreateBidDto, UpdateBidDto, BidFilters } from '../services/bid.service';
|
||||
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
|
||||
import { AuthService } from '../../auth/services/auth.service';
|
||||
import { Bid, BidStatus } from '../entities/bid.entity';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
import { Tenant } from '../../core/entities/tenant.entity';
|
||||
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
|
||||
import { ServiceContext } from '../../../shared/services/base.service';
|
||||
|
||||
export function createBidController(dataSource: DataSource): Router {
|
||||
const router = Router();
|
||||
|
||||
// Repositorios
|
||||
const bidRepository = dataSource.getRepository(Bid);
|
||||
const userRepository = dataSource.getRepository(User);
|
||||
const tenantRepository = dataSource.getRepository(Tenant);
|
||||
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
|
||||
|
||||
// Servicios
|
||||
const bidService = new BidService(bidRepository);
|
||||
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
|
||||
const authMiddleware = new AuthMiddleware(authService, dataSource);
|
||||
|
||||
// Helper para crear contexto
|
||||
const getContext = (req: Request): ServiceContext => {
|
||||
if (!req.tenantId) {
|
||||
throw new Error('Tenant ID is required');
|
||||
}
|
||||
return {
|
||||
tenantId: req.tenantId,
|
||||
userId: req.user?.sub,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /bids
|
||||
*/
|
||||
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||
|
||||
const filters: BidFilters = {};
|
||||
if (req.query.status) {
|
||||
const statuses = (req.query.status as string).split(',') as BidStatus[];
|
||||
filters.status = statuses.length === 1 ? statuses[0] : statuses;
|
||||
}
|
||||
if (req.query.bidType) filters.bidType = req.query.bidType as any;
|
||||
if (req.query.stage) filters.stage = req.query.stage as any;
|
||||
if (req.query.opportunityId) filters.opportunityId = req.query.opportunityId as string;
|
||||
if (req.query.bidManagerId) filters.bidManagerId = req.query.bidManagerId as string;
|
||||
if (req.query.contractingEntity) filters.contractingEntity = req.query.contractingEntity as string;
|
||||
if (req.query.deadlineFrom) filters.deadlineFrom = new Date(req.query.deadlineFrom as string);
|
||||
if (req.query.deadlineTo) filters.deadlineTo = new Date(req.query.deadlineTo as string);
|
||||
if (req.query.minBudget) filters.minBudget = parseFloat(req.query.minBudget as string);
|
||||
if (req.query.maxBudget) filters.maxBudget = parseFloat(req.query.maxBudget as string);
|
||||
if (req.query.search) filters.search = req.query.search as string;
|
||||
|
||||
const result = await bidService.findWithFilters(getContext(req), filters, page, limit);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: result.meta,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /bids/upcoming-deadlines
|
||||
*/
|
||||
router.get('/upcoming-deadlines', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const days = parseInt(req.query.days as string) || 7;
|
||||
const bids = await bidService.getUpcomingDeadlines(getContext(req), days);
|
||||
res.status(200).json({ success: true, data: bids });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /bids/stats
|
||||
*/
|
||||
router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const year = req.query.year ? parseInt(req.query.year as string) : undefined;
|
||||
const stats = await bidService.getStats(getContext(req), year);
|
||||
res.status(200).json({ success: true, data: stats });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /bids/:id
|
||||
*/
|
||||
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const bid = await bidService.findById(getContext(req), req.params.id);
|
||||
if (!bid) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Bid not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: bid });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /bids
|
||||
*/
|
||||
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'comercial'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dto: CreateBidDto = req.body;
|
||||
if (!dto.opportunityId || !dto.code || !dto.name || !dto.bidType || !dto.submissionDeadline) {
|
||||
res.status(400).json({
|
||||
error: 'Bad Request',
|
||||
message: 'opportunityId, code, name, bidType, and submissionDeadline are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const bid = await bidService.create(getContext(req), dto);
|
||||
res.status(201).json({ success: true, data: bid });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /bids/:id
|
||||
*/
|
||||
router.put('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'comercial'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dto: UpdateBidDto = req.body;
|
||||
const bid = await bidService.update(getContext(req), req.params.id, dto);
|
||||
|
||||
if (!bid) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Bid not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: bid });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /bids/:id/status
|
||||
*/
|
||||
router.post('/:id/status', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { status } = req.body;
|
||||
if (!status) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'status is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const bid = await bidService.changeStatus(getContext(req), req.params.id, status);
|
||||
|
||||
if (!bid) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Bid not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: bid });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /bids/:id/stage
|
||||
*/
|
||||
router.post('/:id/stage', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { stage } = req.body;
|
||||
if (!stage) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'stage is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const bid = await bidService.changeStage(getContext(req), req.params.id, stage);
|
||||
|
||||
if (!bid) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Bid not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: bid });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /bids/:id/submit
|
||||
*/
|
||||
router.post('/:id/submit', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { proposalAmount } = req.body;
|
||||
if (!proposalAmount) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'proposalAmount is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const bid = await bidService.submit(getContext(req), req.params.id, proposalAmount);
|
||||
|
||||
if (!bid) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Bid not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: bid });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /bids/:id/result
|
||||
*/
|
||||
router.post('/:id/result', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { won, winnerName, winningAmount, rankingPosition, rejectionReason, lessonsLearned } = req.body;
|
||||
if (won === undefined) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'won is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const bid = await bidService.recordResult(getContext(req), req.params.id, won, {
|
||||
winnerName,
|
||||
winningAmount,
|
||||
rankingPosition,
|
||||
rejectionReason,
|
||||
lessonsLearned,
|
||||
});
|
||||
|
||||
if (!bid) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Bid not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: bid });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /bids/:id/convert
|
||||
*/
|
||||
router.post('/:id/convert', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { projectId } = req.body;
|
||||
if (!projectId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'projectId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const bid = await bidService.convertToProject(getContext(req), req.params.id, projectId);
|
||||
|
||||
if (!bid) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Bid not found or not awarded' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: bid });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /bids/:id
|
||||
*/
|
||||
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = await bidService.softDelete(getContext(req), req.params.id);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Bid not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, message: 'Bid deleted' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
export default createBidController;
|
||||
9
src/modules/bidding/controllers/index.ts
Normal file
9
src/modules/bidding/controllers/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Bidding Controllers Index
|
||||
* @module Bidding
|
||||
*/
|
||||
|
||||
export { createOpportunityController } from './opportunity.controller';
|
||||
export { createBidController } from './bid.controller';
|
||||
export { createBidBudgetController } from './bid-budget.controller';
|
||||
export { createBidAnalyticsController } from './bid-analytics.controller';
|
||||
266
src/modules/bidding/controllers/opportunity.controller.ts
Normal file
266
src/modules/bidding/controllers/opportunity.controller.ts
Normal file
@ -0,0 +1,266 @@
|
||||
/**
|
||||
* OpportunityController - Controller de Oportunidades
|
||||
*
|
||||
* Endpoints REST para gestión del pipeline de oportunidades.
|
||||
*
|
||||
* @module Bidding
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { OpportunityService, CreateOpportunityDto, UpdateOpportunityDto, OpportunityFilters } from '../services/opportunity.service';
|
||||
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
|
||||
import { AuthService } from '../../auth/services/auth.service';
|
||||
import { Opportunity, OpportunityStatus } from '../entities/opportunity.entity';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
import { Tenant } from '../../core/entities/tenant.entity';
|
||||
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
|
||||
import { ServiceContext } from '../../../shared/services/base.service';
|
||||
|
||||
export function createOpportunityController(dataSource: DataSource): Router {
|
||||
const router = Router();
|
||||
|
||||
// Repositorios
|
||||
const opportunityRepository = dataSource.getRepository(Opportunity);
|
||||
const userRepository = dataSource.getRepository(User);
|
||||
const tenantRepository = dataSource.getRepository(Tenant);
|
||||
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
|
||||
|
||||
// Servicios
|
||||
const opportunityService = new OpportunityService(opportunityRepository);
|
||||
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
|
||||
const authMiddleware = new AuthMiddleware(authService, dataSource);
|
||||
|
||||
// Helper para crear contexto
|
||||
const getContext = (req: Request): ServiceContext => {
|
||||
if (!req.tenantId) {
|
||||
throw new Error('Tenant ID is required');
|
||||
}
|
||||
return {
|
||||
tenantId: req.tenantId,
|
||||
userId: req.user?.sub,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /opportunities
|
||||
*/
|
||||
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||
|
||||
const filters: OpportunityFilters = {};
|
||||
if (req.query.status) {
|
||||
const statuses = (req.query.status as string).split(',') as OpportunityStatus[];
|
||||
filters.status = statuses.length === 1 ? statuses[0] : statuses;
|
||||
}
|
||||
if (req.query.source) filters.source = req.query.source as any;
|
||||
if (req.query.projectType) filters.projectType = req.query.projectType as any;
|
||||
if (req.query.priority) filters.priority = req.query.priority as any;
|
||||
if (req.query.assignedToId) filters.assignedToId = req.query.assignedToId as string;
|
||||
if (req.query.clientName) filters.clientName = req.query.clientName as string;
|
||||
if (req.query.state) filters.state = req.query.state as string;
|
||||
if (req.query.dateFrom) filters.dateFrom = new Date(req.query.dateFrom as string);
|
||||
if (req.query.dateTo) filters.dateTo = new Date(req.query.dateTo as string);
|
||||
if (req.query.minValue) filters.minValue = parseFloat(req.query.minValue as string);
|
||||
if (req.query.maxValue) filters.maxValue = parseFloat(req.query.maxValue as string);
|
||||
if (req.query.search) filters.search = req.query.search as string;
|
||||
|
||||
const result = await opportunityService.findWithFilters(getContext(req), filters, page, limit);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: result.meta,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /opportunities/pipeline
|
||||
*/
|
||||
router.get('/pipeline', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const pipeline = await opportunityService.getPipeline(getContext(req));
|
||||
res.status(200).json({ success: true, data: pipeline });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /opportunities/upcoming-deadlines
|
||||
*/
|
||||
router.get('/upcoming-deadlines', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const days = parseInt(req.query.days as string) || 7;
|
||||
const opportunities = await opportunityService.getUpcomingDeadlines(getContext(req), days);
|
||||
res.status(200).json({ success: true, data: opportunities });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /opportunities/stats
|
||||
*/
|
||||
router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const year = req.query.year ? parseInt(req.query.year as string) : undefined;
|
||||
const stats = await opportunityService.getStats(getContext(req), year);
|
||||
res.status(200).json({ success: true, data: stats });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /opportunities/:id
|
||||
*/
|
||||
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const opportunity = await opportunityService.findById(getContext(req), req.params.id);
|
||||
if (!opportunity) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Opportunity not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: opportunity });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /opportunities
|
||||
*/
|
||||
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'comercial'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dto: CreateOpportunityDto = req.body;
|
||||
if (!dto.code || !dto.name || !dto.source || !dto.projectType || !dto.clientName || !dto.identificationDate) {
|
||||
res.status(400).json({
|
||||
error: 'Bad Request',
|
||||
message: 'code, name, source, projectType, clientName, and identificationDate are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const opportunity = await opportunityService.create(getContext(req), dto);
|
||||
res.status(201).json({ success: true, data: opportunity });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /opportunities/:id
|
||||
*/
|
||||
router.put('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'comercial'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dto: UpdateOpportunityDto = req.body;
|
||||
const opportunity = await opportunityService.update(getContext(req), req.params.id, dto);
|
||||
|
||||
if (!opportunity) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Opportunity not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: opportunity });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /opportunities/:id/status
|
||||
*/
|
||||
router.post('/:id/status', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'comercial'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { status, reason } = req.body;
|
||||
if (!status) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'status is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const opportunity = await opportunityService.changeStatus(getContext(req), req.params.id, status, reason);
|
||||
|
||||
if (!opportunity) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Opportunity not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: opportunity });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /opportunities/:id
|
||||
*/
|
||||
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = await opportunityService.softDelete(getContext(req), req.params.id);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Opportunity not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, message: 'Opportunity deleted' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
export default createOpportunityController;
|
||||
256
src/modules/bidding/entities/bid-budget.entity.ts
Normal file
256
src/modules/bidding/entities/bid-budget.entity.ts
Normal file
@ -0,0 +1,256 @@
|
||||
/**
|
||||
* BidBudget Entity - Presupuesto de Licitación
|
||||
*
|
||||
* Desglose del presupuesto para la propuesta económica.
|
||||
*
|
||||
* @module Bidding
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Bid } from './bid.entity';
|
||||
|
||||
export type BudgetItemType =
|
||||
| 'direct_cost'
|
||||
| 'indirect_cost'
|
||||
| 'labor'
|
||||
| 'materials'
|
||||
| 'equipment'
|
||||
| 'subcontract'
|
||||
| 'overhead'
|
||||
| 'profit'
|
||||
| 'contingency'
|
||||
| 'financing'
|
||||
| 'taxes'
|
||||
| 'bonds'
|
||||
| 'other';
|
||||
|
||||
export type BudgetStatus = 'draft' | 'calculated' | 'reviewed' | 'approved' | 'locked';
|
||||
|
||||
@Entity('bid_budget', { schema: 'bidding' })
|
||||
@Index(['tenantId', 'bidId'])
|
||||
@Index(['tenantId', 'itemType'])
|
||||
export class BidBudget {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId!: string;
|
||||
|
||||
// Referencia a licitación
|
||||
@Column({ name: 'bid_id', type: 'uuid' })
|
||||
bidId!: string;
|
||||
|
||||
@ManyToOne(() => Bid, (bid) => bid.budgetItems)
|
||||
@JoinColumn({ name: 'bid_id' })
|
||||
bid?: Bid;
|
||||
|
||||
// Jerarquía
|
||||
@Column({ name: 'parent_id', type: 'uuid', nullable: true })
|
||||
parentId?: string;
|
||||
|
||||
@Column({ name: 'sort_order', type: 'int', default: 0 })
|
||||
sortOrder!: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
level!: number;
|
||||
|
||||
@Column({ length: 50 })
|
||||
code!: string;
|
||||
|
||||
// Información del item
|
||||
@Column({ length: 255 })
|
||||
name!: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description?: string;
|
||||
|
||||
@Column({
|
||||
name: 'item_type',
|
||||
type: 'enum',
|
||||
enum: ['direct_cost', 'indirect_cost', 'labor', 'materials', 'equipment', 'subcontract', 'overhead', 'profit', 'contingency', 'financing', 'taxes', 'bonds', 'other'],
|
||||
enumName: 'bid_budget_item_type',
|
||||
})
|
||||
itemType!: BudgetItemType;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ['draft', 'calculated', 'reviewed', 'approved', 'locked'],
|
||||
enumName: 'bid_budget_status',
|
||||
default: 'draft',
|
||||
})
|
||||
status!: BudgetStatus;
|
||||
|
||||
// Unidad y cantidad
|
||||
@Column({ length: 20, nullable: true })
|
||||
unit?: string;
|
||||
|
||||
@Column({
|
||||
type: 'decimal',
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
default: 0,
|
||||
})
|
||||
quantity!: number;
|
||||
|
||||
// Precios
|
||||
@Column({
|
||||
name: 'unit_price',
|
||||
type: 'decimal',
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
default: 0,
|
||||
})
|
||||
unitPrice!: number;
|
||||
|
||||
@Column({
|
||||
name: 'total_amount',
|
||||
type: 'decimal',
|
||||
precision: 18,
|
||||
scale: 2,
|
||||
default: 0,
|
||||
})
|
||||
totalAmount!: number;
|
||||
|
||||
// Desglose de costos directos
|
||||
@Column({
|
||||
name: 'materials_cost',
|
||||
type: 'decimal',
|
||||
precision: 18,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
})
|
||||
materialsCost?: number;
|
||||
|
||||
@Column({
|
||||
name: 'labor_cost',
|
||||
type: 'decimal',
|
||||
precision: 18,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
})
|
||||
laborCost?: number;
|
||||
|
||||
@Column({
|
||||
name: 'equipment_cost',
|
||||
type: 'decimal',
|
||||
precision: 18,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
})
|
||||
equipmentCost?: number;
|
||||
|
||||
@Column({
|
||||
name: 'subcontract_cost',
|
||||
type: 'decimal',
|
||||
precision: 18,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
})
|
||||
subcontractCost?: number;
|
||||
|
||||
// Porcentajes
|
||||
@Column({
|
||||
name: 'indirect_percentage',
|
||||
type: 'decimal',
|
||||
precision: 5,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
})
|
||||
indirectPercentage?: number;
|
||||
|
||||
@Column({
|
||||
name: 'profit_percentage',
|
||||
type: 'decimal',
|
||||
precision: 5,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
})
|
||||
profitPercentage?: number;
|
||||
|
||||
@Column({
|
||||
name: 'financing_percentage',
|
||||
type: 'decimal',
|
||||
precision: 5,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
})
|
||||
financingPercentage?: number;
|
||||
|
||||
// Comparación con base de licitación
|
||||
@Column({
|
||||
name: 'base_amount',
|
||||
type: 'decimal',
|
||||
precision: 18,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
})
|
||||
baseAmount?: number;
|
||||
|
||||
@Column({
|
||||
name: 'variance_amount',
|
||||
type: 'decimal',
|
||||
precision: 18,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
})
|
||||
varianceAmount?: number;
|
||||
|
||||
@Column({
|
||||
name: 'variance_percentage',
|
||||
type: 'decimal',
|
||||
precision: 8,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
})
|
||||
variancePercentage?: number;
|
||||
|
||||
// Flags
|
||||
@Column({ name: 'is_summary', type: 'boolean', default: false })
|
||||
isSummary!: boolean;
|
||||
|
||||
@Column({ name: 'is_calculated', type: 'boolean', default: false })
|
||||
isCalculated!: boolean;
|
||||
|
||||
@Column({ name: 'is_adjusted', type: 'boolean', default: false })
|
||||
isAdjusted!: boolean;
|
||||
|
||||
@Column({ name: 'adjustment_reason', type: 'text', nullable: true })
|
||||
adjustmentReason?: string;
|
||||
|
||||
// Referencia a concepto de catálogo
|
||||
@Column({ name: 'catalog_concept_id', type: 'uuid', nullable: true })
|
||||
catalogConceptId?: string;
|
||||
|
||||
// Notas y metadatos
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes?: string;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata?: Record<string, any>;
|
||||
|
||||
// Auditoría
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy?: string;
|
||||
|
||||
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||
updatedBy?: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
|
||||
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||
deletedAt?: Date;
|
||||
}
|
||||
188
src/modules/bidding/entities/bid-calendar.entity.ts
Normal file
188
src/modules/bidding/entities/bid-calendar.entity.ts
Normal file
@ -0,0 +1,188 @@
|
||||
/**
|
||||
* BidCalendar Entity - Calendario de Licitación
|
||||
*
|
||||
* Eventos y fechas importantes del proceso de licitación.
|
||||
*
|
||||
* @module Bidding
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
import { Bid } from './bid.entity';
|
||||
|
||||
export type CalendarEventType =
|
||||
| 'publication'
|
||||
| 'site_visit'
|
||||
| 'clarification_meeting'
|
||||
| 'clarification_deadline'
|
||||
| 'submission_deadline'
|
||||
| 'opening'
|
||||
| 'technical_evaluation'
|
||||
| 'economic_evaluation'
|
||||
| 'award_notification'
|
||||
| 'contract_signing'
|
||||
| 'kick_off'
|
||||
| 'milestone'
|
||||
| 'internal_review'
|
||||
| 'team_meeting'
|
||||
| 'reminder'
|
||||
| 'other';
|
||||
|
||||
export type EventPriority = 'low' | 'medium' | 'high' | 'critical';
|
||||
|
||||
export type EventStatus = 'scheduled' | 'in_progress' | 'completed' | 'cancelled' | 'postponed';
|
||||
|
||||
@Entity('bid_calendar', { schema: 'bidding' })
|
||||
@Index(['tenantId', 'bidId'])
|
||||
@Index(['tenantId', 'eventDate'])
|
||||
@Index(['tenantId', 'eventType'])
|
||||
export class BidCalendar {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId!: string;
|
||||
|
||||
// Referencia a licitación
|
||||
@Column({ name: 'bid_id', type: 'uuid' })
|
||||
bidId!: string;
|
||||
|
||||
@ManyToOne(() => Bid, (bid) => bid.calendarEvents)
|
||||
@JoinColumn({ name: 'bid_id' })
|
||||
bid?: Bid;
|
||||
|
||||
// Información del evento
|
||||
@Column({ length: 255 })
|
||||
title!: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description?: string;
|
||||
|
||||
@Column({
|
||||
name: 'event_type',
|
||||
type: 'enum',
|
||||
enum: ['publication', 'site_visit', 'clarification_meeting', 'clarification_deadline', 'submission_deadline', 'opening', 'technical_evaluation', 'economic_evaluation', 'award_notification', 'contract_signing', 'kick_off', 'milestone', 'internal_review', 'team_meeting', 'reminder', 'other'],
|
||||
enumName: 'calendar_event_type',
|
||||
})
|
||||
eventType!: CalendarEventType;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ['low', 'medium', 'high', 'critical'],
|
||||
enumName: 'event_priority',
|
||||
default: 'medium',
|
||||
})
|
||||
priority!: EventPriority;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ['scheduled', 'in_progress', 'completed', 'cancelled', 'postponed'],
|
||||
enumName: 'event_status',
|
||||
default: 'scheduled',
|
||||
})
|
||||
status!: EventStatus;
|
||||
|
||||
// Fechas y hora
|
||||
@Column({ name: 'event_date', type: 'timestamptz' })
|
||||
eventDate!: Date;
|
||||
|
||||
@Column({ name: 'end_date', type: 'timestamptz', nullable: true })
|
||||
endDate?: Date;
|
||||
|
||||
@Column({ name: 'is_all_day', type: 'boolean', default: false })
|
||||
isAllDay!: boolean;
|
||||
|
||||
@Column({ name: 'timezone', length: 50, default: 'America/Mexico_City' })
|
||||
timezone!: string;
|
||||
|
||||
// Ubicación
|
||||
@Column({ length: 255, nullable: true })
|
||||
location?: string;
|
||||
|
||||
@Column({ name: 'is_virtual', type: 'boolean', default: false })
|
||||
isVirtual!: boolean;
|
||||
|
||||
@Column({ name: 'meeting_link', length: 500, nullable: true })
|
||||
meetingLink?: string;
|
||||
|
||||
// Recordatorios
|
||||
@Column({ name: 'reminder_minutes', type: 'int', array: true, nullable: true })
|
||||
reminderMinutes?: number[];
|
||||
|
||||
@Column({ name: 'reminder_sent', type: 'boolean', default: false })
|
||||
reminderSent!: boolean;
|
||||
|
||||
@Column({ name: 'last_reminder_at', type: 'timestamptz', nullable: true })
|
||||
lastReminderAt?: Date;
|
||||
|
||||
// Asignación
|
||||
@Column({ name: 'assigned_to_id', type: 'uuid', nullable: true })
|
||||
assignedToId?: string;
|
||||
|
||||
@ManyToOne(() => User, { nullable: true })
|
||||
@JoinColumn({ name: 'assigned_to_id' })
|
||||
assignedTo?: User;
|
||||
|
||||
@Column({ name: 'attendees', type: 'uuid', array: true, nullable: true })
|
||||
attendees?: string[];
|
||||
|
||||
// Resultado del evento
|
||||
@Column({ name: 'outcome', type: 'text', nullable: true })
|
||||
outcome?: string;
|
||||
|
||||
@Column({ name: 'action_items', type: 'jsonb', nullable: true })
|
||||
actionItems?: Record<string, any>[];
|
||||
|
||||
// Recurrencia
|
||||
@Column({ name: 'is_recurring', type: 'boolean', default: false })
|
||||
isRecurring!: boolean;
|
||||
|
||||
@Column({ name: 'recurrence_rule', length: 255, nullable: true })
|
||||
recurrenceRule?: string;
|
||||
|
||||
@Column({ name: 'parent_event_id', type: 'uuid', nullable: true })
|
||||
parentEventId?: string;
|
||||
|
||||
// Flags
|
||||
@Column({ name: 'is_mandatory', type: 'boolean', default: false })
|
||||
isMandatory!: boolean;
|
||||
|
||||
@Column({ name: 'is_external', type: 'boolean', default: false })
|
||||
isExternal!: boolean;
|
||||
|
||||
@Column({ name: 'requires_preparation', type: 'boolean', default: false })
|
||||
requiresPreparation!: boolean;
|
||||
|
||||
// Notas y metadatos
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes?: string;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata?: Record<string, any>;
|
||||
|
||||
// Auditoría
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy?: string;
|
||||
|
||||
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||
updatedBy?: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
|
||||
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||
deletedAt?: Date;
|
||||
}
|
||||
203
src/modules/bidding/entities/bid-competitor.entity.ts
Normal file
203
src/modules/bidding/entities/bid-competitor.entity.ts
Normal file
@ -0,0 +1,203 @@
|
||||
/**
|
||||
* BidCompetitor Entity - Competidores en Licitación
|
||||
*
|
||||
* Información de competidores en el proceso de licitación.
|
||||
*
|
||||
* @module Bidding
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Bid } from './bid.entity';
|
||||
|
||||
export type CompetitorStatus =
|
||||
| 'identified'
|
||||
| 'registered'
|
||||
| 'qualified'
|
||||
| 'disqualified'
|
||||
| 'withdrew'
|
||||
| 'submitted'
|
||||
| 'winner'
|
||||
| 'loser';
|
||||
|
||||
export type ThreatLevel = 'low' | 'medium' | 'high' | 'critical';
|
||||
|
||||
@Entity('bid_competitors', { schema: 'bidding' })
|
||||
@Index(['tenantId', 'bidId'])
|
||||
export class BidCompetitor {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId!: string;
|
||||
|
||||
// Referencia a licitación
|
||||
@Column({ name: 'bid_id', type: 'uuid' })
|
||||
bidId!: string;
|
||||
|
||||
@ManyToOne(() => Bid, (bid) => bid.competitors)
|
||||
@JoinColumn({ name: 'bid_id' })
|
||||
bid?: Bid;
|
||||
|
||||
// Información del competidor
|
||||
@Column({ name: 'company_name', length: 255 })
|
||||
companyName!: string;
|
||||
|
||||
@Column({ name: 'trade_name', length: 255, nullable: true })
|
||||
tradeName?: string;
|
||||
|
||||
@Column({ name: 'rfc', length: 13, nullable: true })
|
||||
rfc?: string;
|
||||
|
||||
@Column({ name: 'contact_name', length: 255, nullable: true })
|
||||
contactName?: string;
|
||||
|
||||
@Column({ name: 'contact_email', length: 255, nullable: true })
|
||||
contactEmail?: string;
|
||||
|
||||
@Column({ name: 'contact_phone', length: 50, nullable: true })
|
||||
contactPhone?: string;
|
||||
|
||||
@Column({ length: 255, nullable: true })
|
||||
website?: string;
|
||||
|
||||
// Estado y análisis
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ['identified', 'registered', 'qualified', 'disqualified', 'withdrew', 'submitted', 'winner', 'loser'],
|
||||
enumName: 'competitor_status',
|
||||
default: 'identified',
|
||||
})
|
||||
status!: CompetitorStatus;
|
||||
|
||||
@Column({
|
||||
name: 'threat_level',
|
||||
type: 'enum',
|
||||
enum: ['low', 'medium', 'high', 'critical'],
|
||||
enumName: 'competitor_threat_level',
|
||||
default: 'medium',
|
||||
})
|
||||
threatLevel!: ThreatLevel;
|
||||
|
||||
// Capacidades conocidas
|
||||
@Column({
|
||||
name: 'estimated_annual_revenue',
|
||||
type: 'decimal',
|
||||
precision: 18,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
})
|
||||
estimatedAnnualRevenue?: number;
|
||||
|
||||
@Column({ name: 'employee_count', type: 'int', nullable: true })
|
||||
employeeCount?: number;
|
||||
|
||||
@Column({ name: 'years_in_business', type: 'int', nullable: true })
|
||||
yearsInBusiness?: number;
|
||||
|
||||
@Column({ name: 'certifications', type: 'text', array: true, nullable: true })
|
||||
certifications?: string[];
|
||||
|
||||
@Column({ name: 'specializations', type: 'text', array: true, nullable: true })
|
||||
specializations?: string[];
|
||||
|
||||
// Histórico de competencia
|
||||
@Column({ name: 'previous_encounters', type: 'int', default: 0 })
|
||||
previousEncounters!: number;
|
||||
|
||||
@Column({ name: 'wins_against', type: 'int', default: 0 })
|
||||
winsAgainst!: number;
|
||||
|
||||
@Column({ name: 'losses_against', type: 'int', default: 0 })
|
||||
lossesAgainst!: number;
|
||||
|
||||
// Información de propuesta (si es pública)
|
||||
@Column({
|
||||
name: 'proposed_amount',
|
||||
type: 'decimal',
|
||||
precision: 18,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
})
|
||||
proposedAmount?: number;
|
||||
|
||||
@Column({
|
||||
name: 'technical_score',
|
||||
type: 'decimal',
|
||||
precision: 5,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
})
|
||||
technicalScore?: number;
|
||||
|
||||
@Column({
|
||||
name: 'economic_score',
|
||||
type: 'decimal',
|
||||
precision: 5,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
})
|
||||
economicScore?: number;
|
||||
|
||||
@Column({
|
||||
name: 'final_score',
|
||||
type: 'decimal',
|
||||
precision: 5,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
})
|
||||
finalScore?: number;
|
||||
|
||||
@Column({ name: 'ranking_position', type: 'int', nullable: true })
|
||||
rankingPosition?: number;
|
||||
|
||||
// Fortalezas y debilidades
|
||||
@Column({ type: 'text', array: true, nullable: true })
|
||||
strengths?: string[];
|
||||
|
||||
@Column({ type: 'text', array: true, nullable: true })
|
||||
weaknesses?: string[];
|
||||
|
||||
// Análisis FODA resumido
|
||||
@Column({ name: 'competitive_advantage', type: 'text', nullable: true })
|
||||
competitiveAdvantage?: string;
|
||||
|
||||
@Column({ name: 'vulnerability', type: 'text', nullable: true })
|
||||
vulnerability?: string;
|
||||
|
||||
// Razón de descalificación/retiro
|
||||
@Column({ name: 'disqualification_reason', type: 'text', nullable: true })
|
||||
disqualificationReason?: string;
|
||||
|
||||
// Notas y metadatos
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes?: string;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata?: Record<string, any>;
|
||||
|
||||
// Auditoría
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy?: string;
|
||||
|
||||
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||
updatedBy?: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
|
||||
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||
deletedAt?: Date;
|
||||
}
|
||||
170
src/modules/bidding/entities/bid-document.entity.ts
Normal file
170
src/modules/bidding/entities/bid-document.entity.ts
Normal file
@ -0,0 +1,170 @@
|
||||
/**
|
||||
* BidDocument Entity - Documentos de Licitación
|
||||
*
|
||||
* Almacena documentos asociados a una licitación.
|
||||
*
|
||||
* @module Bidding
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
import { Bid } from './bid.entity';
|
||||
|
||||
export type DocumentCategory =
|
||||
| 'tender_bases'
|
||||
| 'clarifications'
|
||||
| 'annexes'
|
||||
| 'technical_proposal'
|
||||
| 'economic_proposal'
|
||||
| 'legal_documents'
|
||||
| 'experience_certificates'
|
||||
| 'financial_statements'
|
||||
| 'bonds'
|
||||
| 'contracts'
|
||||
| 'correspondence'
|
||||
| 'meeting_minutes'
|
||||
| 'other';
|
||||
|
||||
export type DocumentStatus = 'draft' | 'pending_review' | 'approved' | 'rejected' | 'submitted' | 'archived';
|
||||
|
||||
@Entity('bid_documents', { schema: 'bidding' })
|
||||
@Index(['tenantId', 'bidId'])
|
||||
@Index(['tenantId', 'category'])
|
||||
export class BidDocument {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId!: string;
|
||||
|
||||
// Referencia a licitación
|
||||
@Column({ name: 'bid_id', type: 'uuid' })
|
||||
bidId!: string;
|
||||
|
||||
@ManyToOne(() => Bid, (bid) => bid.documents)
|
||||
@JoinColumn({ name: 'bid_id' })
|
||||
bid?: Bid;
|
||||
|
||||
// Información del documento
|
||||
@Column({ length: 255 })
|
||||
name!: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description?: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ['tender_bases', 'clarifications', 'annexes', 'technical_proposal', 'economic_proposal', 'legal_documents', 'experience_certificates', 'financial_statements', 'bonds', 'contracts', 'correspondence', 'meeting_minutes', 'other'],
|
||||
enumName: 'bid_document_category',
|
||||
})
|
||||
category!: DocumentCategory;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ['draft', 'pending_review', 'approved', 'rejected', 'submitted', 'archived'],
|
||||
enumName: 'bid_document_status',
|
||||
default: 'draft',
|
||||
})
|
||||
status!: DocumentStatus;
|
||||
|
||||
// Archivo
|
||||
@Column({ name: 'file_path', length: 500 })
|
||||
filePath!: string;
|
||||
|
||||
@Column({ name: 'file_name', length: 255 })
|
||||
fileName!: string;
|
||||
|
||||
@Column({ name: 'file_type', length: 100 })
|
||||
fileType!: string;
|
||||
|
||||
@Column({ name: 'file_size', type: 'bigint' })
|
||||
fileSize!: number;
|
||||
|
||||
@Column({ name: 'mime_type', length: 100, nullable: true })
|
||||
mimeType?: string;
|
||||
|
||||
// Versión
|
||||
@Column({ type: 'int', default: 1 })
|
||||
version!: number;
|
||||
|
||||
@Column({ name: 'is_current_version', type: 'boolean', default: true })
|
||||
isCurrentVersion!: boolean;
|
||||
|
||||
@Column({ name: 'previous_version_id', type: 'uuid', nullable: true })
|
||||
previousVersionId?: string;
|
||||
|
||||
// Metadatos de revisión
|
||||
@Column({ name: 'reviewed_by_id', type: 'uuid', nullable: true })
|
||||
reviewedById?: string;
|
||||
|
||||
@ManyToOne(() => User, { nullable: true })
|
||||
@JoinColumn({ name: 'reviewed_by_id' })
|
||||
reviewedBy?: User;
|
||||
|
||||
@Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true })
|
||||
reviewedAt?: Date;
|
||||
|
||||
@Column({ name: 'review_comments', type: 'text', nullable: true })
|
||||
reviewComments?: string;
|
||||
|
||||
// Flags
|
||||
@Column({ name: 'is_required', type: 'boolean', default: false })
|
||||
isRequired!: boolean;
|
||||
|
||||
@Column({ name: 'is_confidential', type: 'boolean', default: false })
|
||||
isConfidential!: boolean;
|
||||
|
||||
@Column({ name: 'is_submitted', type: 'boolean', default: false })
|
||||
isSubmitted!: boolean;
|
||||
|
||||
@Column({ name: 'submitted_at', type: 'timestamptz', nullable: true })
|
||||
submittedAt?: Date;
|
||||
|
||||
// Fecha de vencimiento (para documentos con vigencia)
|
||||
@Column({ name: 'expiry_date', type: 'date', nullable: true })
|
||||
expiryDate?: Date;
|
||||
|
||||
// Hash para verificación de integridad
|
||||
@Column({ name: 'file_hash', length: 128, nullable: true })
|
||||
fileHash?: string;
|
||||
|
||||
// Notas y metadatos
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes?: string;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata?: Record<string, any>;
|
||||
|
||||
// Auditoría
|
||||
@Column({ name: 'uploaded_by_id', type: 'uuid', nullable: true })
|
||||
uploadedById?: string;
|
||||
|
||||
@ManyToOne(() => User, { nullable: true })
|
||||
@JoinColumn({ name: 'uploaded_by_id' })
|
||||
uploadedBy?: User;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy?: string;
|
||||
|
||||
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||
updatedBy?: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
|
||||
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||
deletedAt?: Date;
|
||||
}
|
||||
176
src/modules/bidding/entities/bid-team.entity.ts
Normal file
176
src/modules/bidding/entities/bid-team.entity.ts
Normal file
@ -0,0 +1,176 @@
|
||||
/**
|
||||
* BidTeam Entity - Equipo de Licitación
|
||||
*
|
||||
* Miembros del equipo asignados a una licitación.
|
||||
*
|
||||
* @module Bidding
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
import { Bid } from './bid.entity';
|
||||
|
||||
export type TeamRole =
|
||||
| 'bid_manager'
|
||||
| 'technical_lead'
|
||||
| 'cost_engineer'
|
||||
| 'legal_advisor'
|
||||
| 'commercial_manager'
|
||||
| 'project_manager'
|
||||
| 'quality_manager'
|
||||
| 'hse_manager'
|
||||
| 'procurement_lead'
|
||||
| 'design_lead'
|
||||
| 'reviewer'
|
||||
| 'contributor'
|
||||
| 'support';
|
||||
|
||||
export type MemberStatus = 'active' | 'inactive' | 'pending' | 'removed';
|
||||
|
||||
@Entity('bid_team', { schema: 'bidding' })
|
||||
@Index(['tenantId', 'bidId'])
|
||||
@Index(['tenantId', 'userId'])
|
||||
export class BidTeam {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId!: string;
|
||||
|
||||
// Referencia a licitación
|
||||
@Column({ name: 'bid_id', type: 'uuid' })
|
||||
bidId!: string;
|
||||
|
||||
@ManyToOne(() => Bid, (bid) => bid.teamMembers)
|
||||
@JoinColumn({ name: 'bid_id' })
|
||||
bid?: Bid;
|
||||
|
||||
// Referencia a usuario
|
||||
@Column({ name: 'user_id', type: 'uuid' })
|
||||
userId!: string;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user?: User;
|
||||
|
||||
// Rol y responsabilidades
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ['bid_manager', 'technical_lead', 'cost_engineer', 'legal_advisor', 'commercial_manager', 'project_manager', 'quality_manager', 'hse_manager', 'procurement_lead', 'design_lead', 'reviewer', 'contributor', 'support'],
|
||||
enumName: 'bid_team_role',
|
||||
})
|
||||
role!: TeamRole;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ['active', 'inactive', 'pending', 'removed'],
|
||||
enumName: 'bid_team_status',
|
||||
default: 'active',
|
||||
})
|
||||
status!: MemberStatus;
|
||||
|
||||
@Column({ type: 'text', array: true, nullable: true })
|
||||
responsibilities?: string[];
|
||||
|
||||
// Dedicación
|
||||
@Column({
|
||||
name: 'allocation_percentage',
|
||||
type: 'decimal',
|
||||
precision: 5,
|
||||
scale: 2,
|
||||
default: 100,
|
||||
})
|
||||
allocationPercentage!: number;
|
||||
|
||||
@Column({ name: 'estimated_hours', type: 'decimal', precision: 8, scale: 2, nullable: true })
|
||||
estimatedHours?: number;
|
||||
|
||||
@Column({ name: 'actual_hours', type: 'decimal', precision: 8, scale: 2, default: 0 })
|
||||
actualHours!: number;
|
||||
|
||||
// Fechas de participación
|
||||
@Column({ name: 'start_date', type: 'date' })
|
||||
startDate!: Date;
|
||||
|
||||
@Column({ name: 'end_date', type: 'date', nullable: true })
|
||||
endDate?: Date;
|
||||
|
||||
// Permisos específicos
|
||||
@Column({ name: 'can_edit_technical', type: 'boolean', default: false })
|
||||
canEditTechnical!: boolean;
|
||||
|
||||
@Column({ name: 'can_edit_economic', type: 'boolean', default: false })
|
||||
canEditEconomic!: boolean;
|
||||
|
||||
@Column({ name: 'can_approve', type: 'boolean', default: false })
|
||||
canApprove!: boolean;
|
||||
|
||||
@Column({ name: 'can_submit', type: 'boolean', default: false })
|
||||
canSubmit!: boolean;
|
||||
|
||||
// Notificaciones
|
||||
@Column({ name: 'receive_notifications', type: 'boolean', default: true })
|
||||
receiveNotifications!: boolean;
|
||||
|
||||
@Column({ name: 'notification_preferences', type: 'jsonb', nullable: true })
|
||||
notificationPreferences?: Record<string, any>;
|
||||
|
||||
// Evaluación de participación
|
||||
@Column({
|
||||
name: 'performance_rating',
|
||||
type: 'decimal',
|
||||
precision: 3,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
})
|
||||
performanceRating?: number;
|
||||
|
||||
@Column({ name: 'performance_notes', type: 'text', nullable: true })
|
||||
performanceNotes?: string;
|
||||
|
||||
// Información de contacto externa (si no es empleado)
|
||||
@Column({ name: 'is_external', type: 'boolean', default: false })
|
||||
isExternal!: boolean;
|
||||
|
||||
@Column({ name: 'external_company', length: 255, nullable: true })
|
||||
externalCompany?: string;
|
||||
|
||||
@Column({ name: 'external_email', length: 255, nullable: true })
|
||||
externalEmail?: string;
|
||||
|
||||
@Column({ name: 'external_phone', length: 50, nullable: true })
|
||||
externalPhone?: string;
|
||||
|
||||
// Notas y metadatos
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes?: string;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata?: Record<string, any>;
|
||||
|
||||
// Auditoría
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy?: string;
|
||||
|
||||
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||
updatedBy?: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
|
||||
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||
deletedAt?: Date;
|
||||
}
|
||||
311
src/modules/bidding/entities/bid.entity.ts
Normal file
311
src/modules/bidding/entities/bid.entity.ts
Normal file
@ -0,0 +1,311 @@
|
||||
/**
|
||||
* Bid Entity - Licitaciones/Propuestas
|
||||
*
|
||||
* Representa una licitación o propuesta formal vinculada a una oportunidad.
|
||||
*
|
||||
* @module Bidding
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
import { Opportunity } from './opportunity.entity';
|
||||
import { BidDocument } from './bid-document.entity';
|
||||
import { BidCalendar } from './bid-calendar.entity';
|
||||
import { BidBudget } from './bid-budget.entity';
|
||||
import { BidCompetitor } from './bid-competitor.entity';
|
||||
import { BidTeam } from './bid-team.entity';
|
||||
|
||||
export type BidType = 'public' | 'private' | 'invitation' | 'direct_award' | 'framework_agreement';
|
||||
|
||||
export type BidStatus =
|
||||
| 'draft'
|
||||
| 'preparation'
|
||||
| 'review'
|
||||
| 'approved'
|
||||
| 'submitted'
|
||||
| 'clarification'
|
||||
| 'evaluation'
|
||||
| 'awarded'
|
||||
| 'rejected'
|
||||
| 'cancelled'
|
||||
| 'withdrawn';
|
||||
|
||||
export type BidStage =
|
||||
| 'initial'
|
||||
| 'technical_proposal'
|
||||
| 'economic_proposal'
|
||||
| 'final_submission'
|
||||
| 'post_submission';
|
||||
|
||||
@Entity('bids', { schema: 'bidding' })
|
||||
@Index(['tenantId', 'status'])
|
||||
@Index(['tenantId', 'bidType'])
|
||||
@Index(['tenantId', 'opportunityId'])
|
||||
export class Bid {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId!: string;
|
||||
|
||||
// Referencia a oportunidad
|
||||
@Column({ name: 'opportunity_id', type: 'uuid' })
|
||||
opportunityId!: string;
|
||||
|
||||
@ManyToOne(() => Opportunity, (opp) => opp.bids)
|
||||
@JoinColumn({ name: 'opportunity_id' })
|
||||
opportunity?: Opportunity;
|
||||
|
||||
// Información básica
|
||||
@Column({ length: 100 })
|
||||
code!: string;
|
||||
|
||||
@Column({ length: 500 })
|
||||
name!: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description?: string;
|
||||
|
||||
@Column({
|
||||
name: 'bid_type',
|
||||
type: 'enum',
|
||||
enum: ['public', 'private', 'invitation', 'direct_award', 'framework_agreement'],
|
||||
enumName: 'bid_type',
|
||||
})
|
||||
bidType!: BidType;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ['draft', 'preparation', 'review', 'approved', 'submitted', 'clarification', 'evaluation', 'awarded', 'rejected', 'cancelled', 'withdrawn'],
|
||||
enumName: 'bid_status',
|
||||
default: 'draft',
|
||||
})
|
||||
status!: BidStatus;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ['initial', 'technical_proposal', 'economic_proposal', 'final_submission', 'post_submission'],
|
||||
enumName: 'bid_stage',
|
||||
default: 'initial',
|
||||
})
|
||||
stage!: BidStage;
|
||||
|
||||
// Referencia de convocatoria
|
||||
@Column({ name: 'tender_number', length: 100, nullable: true })
|
||||
tenderNumber?: string;
|
||||
|
||||
@Column({ name: 'tender_name', length: 500, nullable: true })
|
||||
tenderName?: string;
|
||||
|
||||
@Column({ name: 'contracting_entity', length: 255, nullable: true })
|
||||
contractingEntity?: string;
|
||||
|
||||
// Fechas clave
|
||||
@Column({ name: 'publication_date', type: 'date', nullable: true })
|
||||
publicationDate?: Date;
|
||||
|
||||
@Column({ name: 'site_visit_date', type: 'timestamptz', nullable: true })
|
||||
siteVisitDate?: Date;
|
||||
|
||||
@Column({ name: 'clarification_deadline', type: 'timestamptz', nullable: true })
|
||||
clarificationDeadline?: Date;
|
||||
|
||||
@Column({ name: 'submission_deadline', type: 'timestamptz' })
|
||||
submissionDeadline!: Date;
|
||||
|
||||
@Column({ name: 'opening_date', type: 'timestamptz', nullable: true })
|
||||
openingDate?: Date;
|
||||
|
||||
@Column({ name: 'award_date', type: 'date', nullable: true })
|
||||
awardDate?: Date;
|
||||
|
||||
@Column({ name: 'contract_signing_date', type: 'date', nullable: true })
|
||||
contractSigningDate?: Date;
|
||||
|
||||
// Montos
|
||||
@Column({
|
||||
name: 'base_budget',
|
||||
type: 'decimal',
|
||||
precision: 18,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
})
|
||||
baseBudget?: number;
|
||||
|
||||
@Column({
|
||||
name: 'our_proposal_amount',
|
||||
type: 'decimal',
|
||||
precision: 18,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
})
|
||||
ourProposalAmount?: number;
|
||||
|
||||
@Column({
|
||||
name: 'winning_amount',
|
||||
type: 'decimal',
|
||||
precision: 18,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
})
|
||||
winningAmount?: number;
|
||||
|
||||
@Column({ name: 'currency', length: 3, default: 'MXN' })
|
||||
currency!: string;
|
||||
|
||||
// Propuesta técnica
|
||||
@Column({
|
||||
name: 'technical_score',
|
||||
type: 'decimal',
|
||||
precision: 5,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
})
|
||||
technicalScore?: number;
|
||||
|
||||
@Column({
|
||||
name: 'technical_weight',
|
||||
type: 'decimal',
|
||||
precision: 5,
|
||||
scale: 2,
|
||||
default: 50,
|
||||
})
|
||||
technicalWeight!: number;
|
||||
|
||||
// Propuesta económica
|
||||
@Column({
|
||||
name: 'economic_score',
|
||||
type: 'decimal',
|
||||
precision: 5,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
})
|
||||
economicScore?: number;
|
||||
|
||||
@Column({
|
||||
name: 'economic_weight',
|
||||
type: 'decimal',
|
||||
precision: 5,
|
||||
scale: 2,
|
||||
default: 50,
|
||||
})
|
||||
economicWeight!: number;
|
||||
|
||||
// Puntuación final
|
||||
@Column({
|
||||
name: 'final_score',
|
||||
type: 'decimal',
|
||||
precision: 5,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
})
|
||||
finalScore?: number;
|
||||
|
||||
@Column({ name: 'ranking_position', type: 'int', nullable: true })
|
||||
rankingPosition?: number;
|
||||
|
||||
// Garantías
|
||||
@Column({
|
||||
name: 'bid_bond_amount',
|
||||
type: 'decimal',
|
||||
precision: 18,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
})
|
||||
bidBondAmount?: number;
|
||||
|
||||
@Column({ name: 'bid_bond_number', length: 100, nullable: true })
|
||||
bidBondNumber?: string;
|
||||
|
||||
@Column({ name: 'bid_bond_expiry', type: 'date', nullable: true })
|
||||
bidBondExpiry?: Date;
|
||||
|
||||
// Asignación
|
||||
@Column({ name: 'bid_manager_id', type: 'uuid', nullable: true })
|
||||
bidManagerId?: string;
|
||||
|
||||
@ManyToOne(() => User, { nullable: true })
|
||||
@JoinColumn({ name: 'bid_manager_id' })
|
||||
bidManager?: User;
|
||||
|
||||
// Resultado
|
||||
@Column({ name: 'winner_name', length: 255, nullable: true })
|
||||
winnerName?: string;
|
||||
|
||||
@Column({ name: 'rejection_reason', type: 'text', nullable: true })
|
||||
rejectionReason?: string;
|
||||
|
||||
@Column({ name: 'lessons_learned', type: 'text', nullable: true })
|
||||
lessonsLearned?: string;
|
||||
|
||||
// Progreso
|
||||
@Column({
|
||||
name: 'completion_percentage',
|
||||
type: 'decimal',
|
||||
precision: 5,
|
||||
scale: 2,
|
||||
default: 0,
|
||||
})
|
||||
completionPercentage!: number;
|
||||
|
||||
// Checklist de documentos
|
||||
@Column({ name: 'checklist', type: 'jsonb', nullable: true })
|
||||
checklist?: Record<string, any>;
|
||||
|
||||
// Notas y metadatos
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes?: string;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata?: Record<string, any>;
|
||||
|
||||
// Relaciones
|
||||
@OneToMany(() => BidDocument, (doc) => doc.bid)
|
||||
documents?: BidDocument[];
|
||||
|
||||
@OneToMany(() => BidCalendar, (event) => event.bid)
|
||||
calendarEvents?: BidCalendar[];
|
||||
|
||||
@OneToMany(() => BidBudget, (budget) => budget.bid)
|
||||
budgetItems?: BidBudget[];
|
||||
|
||||
@OneToMany(() => BidCompetitor, (comp) => comp.bid)
|
||||
competitors?: BidCompetitor[];
|
||||
|
||||
@OneToMany(() => BidTeam, (team) => team.bid)
|
||||
teamMembers?: BidTeam[];
|
||||
|
||||
// Conversión a proyecto
|
||||
@Column({ name: 'converted_to_project_id', type: 'uuid', nullable: true })
|
||||
convertedToProjectId?: string;
|
||||
|
||||
@Column({ name: 'converted_at', type: 'timestamptz', nullable: true })
|
||||
convertedAt?: Date;
|
||||
|
||||
// Auditoría
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy?: string;
|
||||
|
||||
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||
updatedBy?: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
|
||||
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||
deletedAt?: Date;
|
||||
}
|
||||
12
src/modules/bidding/entities/index.ts
Normal file
12
src/modules/bidding/entities/index.ts
Normal file
@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Bidding Entities Index
|
||||
* @module Bidding
|
||||
*/
|
||||
|
||||
export { Opportunity, OpportunitySource, OpportunityStatus, OpportunityPriority, ProjectType } from './opportunity.entity';
|
||||
export { Bid, BidType, BidStatus, BidStage } from './bid.entity';
|
||||
export { BidDocument, DocumentCategory, DocumentStatus } from './bid-document.entity';
|
||||
export { BidCalendar, CalendarEventType, EventPriority, EventStatus } from './bid-calendar.entity';
|
||||
export { BidBudget, BudgetItemType, BudgetStatus } from './bid-budget.entity';
|
||||
export { BidCompetitor, CompetitorStatus, ThreatLevel } from './bid-competitor.entity';
|
||||
export { BidTeam, TeamRole, MemberStatus } from './bid-team.entity';
|
||||
280
src/modules/bidding/entities/opportunity.entity.ts
Normal file
280
src/modules/bidding/entities/opportunity.entity.ts
Normal file
@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Opportunity Entity - Oportunidades de Negocio
|
||||
*
|
||||
* Representa oportunidades de licitación/proyecto en el pipeline comercial.
|
||||
*
|
||||
* @module Bidding
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
import { Bid } from './bid.entity';
|
||||
|
||||
export type OpportunitySource =
|
||||
| 'portal_compranet'
|
||||
| 'portal_state'
|
||||
| 'direct_invitation'
|
||||
| 'referral'
|
||||
| 'public_notice'
|
||||
| 'networking'
|
||||
| 'repeat_client'
|
||||
| 'cold_call'
|
||||
| 'website'
|
||||
| 'other';
|
||||
|
||||
export type OpportunityStatus =
|
||||
| 'identified'
|
||||
| 'qualified'
|
||||
| 'pursuing'
|
||||
| 'bid_submitted'
|
||||
| 'won'
|
||||
| 'lost'
|
||||
| 'cancelled'
|
||||
| 'on_hold';
|
||||
|
||||
export type OpportunityPriority = 'low' | 'medium' | 'high' | 'critical';
|
||||
|
||||
export type ProjectType =
|
||||
| 'residential'
|
||||
| 'commercial'
|
||||
| 'industrial'
|
||||
| 'infrastructure'
|
||||
| 'institutional'
|
||||
| 'mixed_use'
|
||||
| 'renovation'
|
||||
| 'maintenance';
|
||||
|
||||
@Entity('opportunities', { schema: 'bidding' })
|
||||
@Index(['tenantId', 'status'])
|
||||
@Index(['tenantId', 'source'])
|
||||
@Index(['tenantId', 'assignedToId'])
|
||||
export class Opportunity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
@Index()
|
||||
tenantId!: string;
|
||||
|
||||
// Información básica
|
||||
@Column({ length: 100 })
|
||||
code!: string;
|
||||
|
||||
@Column({ length: 500 })
|
||||
name!: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description?: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ['portal_compranet', 'portal_state', 'direct_invitation', 'referral', 'public_notice', 'networking', 'repeat_client', 'cold_call', 'website', 'other'],
|
||||
enumName: 'opportunity_source',
|
||||
})
|
||||
source!: OpportunitySource;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ['identified', 'qualified', 'pursuing', 'bid_submitted', 'won', 'lost', 'cancelled', 'on_hold'],
|
||||
enumName: 'opportunity_status',
|
||||
default: 'identified',
|
||||
})
|
||||
status!: OpportunityStatus;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ['low', 'medium', 'high', 'critical'],
|
||||
enumName: 'opportunity_priority',
|
||||
default: 'medium',
|
||||
})
|
||||
priority!: OpportunityPriority;
|
||||
|
||||
@Column({
|
||||
name: 'project_type',
|
||||
type: 'enum',
|
||||
enum: ['residential', 'commercial', 'industrial', 'infrastructure', 'institutional', 'mixed_use', 'renovation', 'maintenance'],
|
||||
enumName: 'project_type',
|
||||
})
|
||||
projectType!: ProjectType;
|
||||
|
||||
// Cliente/Convocante
|
||||
@Column({ name: 'client_name', length: 255 })
|
||||
clientName!: string;
|
||||
|
||||
@Column({ name: 'client_contact', length: 255, nullable: true })
|
||||
clientContact?: string;
|
||||
|
||||
@Column({ name: 'client_email', length: 255, nullable: true })
|
||||
clientEmail?: string;
|
||||
|
||||
@Column({ name: 'client_phone', length: 50, nullable: true })
|
||||
clientPhone?: string;
|
||||
|
||||
@Column({ name: 'client_type', length: 50, nullable: true })
|
||||
clientType?: string; // 'gobierno_federal', 'gobierno_estatal', 'privado', etc.
|
||||
|
||||
// Ubicación
|
||||
@Column({ length: 255, nullable: true })
|
||||
location?: string;
|
||||
|
||||
@Column({ length: 100, nullable: true })
|
||||
state?: string;
|
||||
|
||||
@Column({ length: 100, nullable: true })
|
||||
city?: string;
|
||||
|
||||
// Montos estimados
|
||||
@Column({
|
||||
name: 'estimated_value',
|
||||
type: 'decimal',
|
||||
precision: 18,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
})
|
||||
estimatedValue?: number;
|
||||
|
||||
@Column({ name: 'currency', length: 3, default: 'MXN' })
|
||||
currency!: string;
|
||||
|
||||
@Column({
|
||||
name: 'construction_area_m2',
|
||||
type: 'decimal',
|
||||
precision: 12,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
})
|
||||
constructionAreaM2?: number;
|
||||
|
||||
@Column({
|
||||
name: 'land_area_m2',
|
||||
type: 'decimal',
|
||||
precision: 12,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
})
|
||||
landAreaM2?: number;
|
||||
|
||||
// Fechas clave
|
||||
@Column({ name: 'identification_date', type: 'date' })
|
||||
identificationDate!: Date;
|
||||
|
||||
@Column({ name: 'deadline_date', type: 'timestamptz', nullable: true })
|
||||
deadlineDate?: Date;
|
||||
|
||||
@Column({ name: 'expected_award_date', type: 'date', nullable: true })
|
||||
expectedAwardDate?: Date;
|
||||
|
||||
@Column({ name: 'expected_start_date', type: 'date', nullable: true })
|
||||
expectedStartDate?: Date;
|
||||
|
||||
@Column({ name: 'expected_duration_months', type: 'int', nullable: true })
|
||||
expectedDurationMonths?: number;
|
||||
|
||||
// Probabilidad y análisis
|
||||
@Column({
|
||||
name: 'win_probability',
|
||||
type: 'decimal',
|
||||
precision: 5,
|
||||
scale: 2,
|
||||
default: 0,
|
||||
})
|
||||
winProbability!: number;
|
||||
|
||||
@Column({
|
||||
name: 'weighted_value',
|
||||
type: 'decimal',
|
||||
precision: 18,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
})
|
||||
weightedValue?: number;
|
||||
|
||||
// Requisitos
|
||||
@Column({ name: 'requires_bond', type: 'boolean', default: false })
|
||||
requiresBond!: boolean;
|
||||
|
||||
@Column({ name: 'requires_experience', type: 'boolean', default: false })
|
||||
requiresExperience!: boolean;
|
||||
|
||||
@Column({
|
||||
name: 'minimum_experience_years',
|
||||
type: 'int',
|
||||
nullable: true,
|
||||
})
|
||||
minimumExperienceYears?: number;
|
||||
|
||||
@Column({
|
||||
name: 'minimum_capital',
|
||||
type: 'decimal',
|
||||
precision: 18,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
})
|
||||
minimumCapital?: number;
|
||||
|
||||
@Column({
|
||||
name: 'required_certifications',
|
||||
type: 'text',
|
||||
array: true,
|
||||
nullable: true,
|
||||
})
|
||||
requiredCertifications?: string[];
|
||||
|
||||
// Asignación
|
||||
@Column({ name: 'assigned_to_id', type: 'uuid', nullable: true })
|
||||
assignedToId?: string;
|
||||
|
||||
@ManyToOne(() => User, { nullable: true })
|
||||
@JoinColumn({ name: 'assigned_to_id' })
|
||||
assignedTo?: User;
|
||||
|
||||
// Razón de resultado
|
||||
@Column({ name: 'loss_reason', type: 'text', nullable: true })
|
||||
lossReason?: string;
|
||||
|
||||
@Column({ name: 'win_factors', type: 'text', nullable: true })
|
||||
winFactors?: string;
|
||||
|
||||
// Notas y metadatos
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes?: string;
|
||||
|
||||
@Column({ name: 'source_url', length: 500, nullable: true })
|
||||
sourceUrl?: string;
|
||||
|
||||
@Column({ name: 'source_reference', length: 255, nullable: true })
|
||||
sourceReference?: string;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata?: Record<string, any>;
|
||||
|
||||
// Relaciones
|
||||
@OneToMany(() => Bid, (bid) => bid.opportunity)
|
||||
bids?: Bid[];
|
||||
|
||||
// Auditoría
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy?: string;
|
||||
|
||||
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||
updatedBy?: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
|
||||
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||
deletedAt?: Date;
|
||||
}
|
||||
385
src/modules/bidding/services/bid-analytics.service.ts
Normal file
385
src/modules/bidding/services/bid-analytics.service.ts
Normal file
@ -0,0 +1,385 @@
|
||||
/**
|
||||
* BidAnalyticsService - Análisis y Reportes de Licitaciones
|
||||
*
|
||||
* Estadísticas, tendencias y análisis de competitividad.
|
||||
*
|
||||
* @module Bidding
|
||||
*/
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import { ServiceContext } from '../../../shared/services/base.service';
|
||||
import { Bid, BidStatus, BidType } from '../entities/bid.entity';
|
||||
import { Opportunity, OpportunitySource, OpportunityStatus } from '../entities/opportunity.entity';
|
||||
import { BidCompetitor } from '../entities/bid-competitor.entity';
|
||||
|
||||
export class BidAnalyticsService {
|
||||
constructor(
|
||||
private readonly bidRepository: Repository<Bid>,
|
||||
private readonly opportunityRepository: Repository<Opportunity>,
|
||||
private readonly competitorRepository: Repository<BidCompetitor>
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Dashboard general de licitaciones
|
||||
*/
|
||||
async getDashboard(ctx: ServiceContext): Promise<BidDashboard> {
|
||||
const now = new Date();
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
// Oportunidades activas
|
||||
const activeOpportunities = await this.opportunityRepository.count({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
deletedAt: undefined,
|
||||
status: 'pursuing' as OpportunityStatus,
|
||||
},
|
||||
});
|
||||
|
||||
// Licitaciones activas
|
||||
const activeBids = await this.bidRepository.count({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
deletedAt: undefined,
|
||||
status: 'preparation' as BidStatus,
|
||||
},
|
||||
});
|
||||
|
||||
// Valor del pipeline
|
||||
const pipelineValue = await this.opportunityRepository
|
||||
.createQueryBuilder('o')
|
||||
.select('SUM(o.weighted_value)', 'value')
|
||||
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('o.deleted_at IS NULL')
|
||||
.andWhere('o.status NOT IN (:...closedStatuses)', { closedStatuses: ['won', 'lost', 'cancelled'] })
|
||||
.getRawOne();
|
||||
|
||||
// Próximas fechas límite
|
||||
const upcomingDeadlines = await this.bidRepository
|
||||
.createQueryBuilder('b')
|
||||
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere('b.status IN (:...activeStatuses)', { activeStatuses: ['draft', 'preparation', 'review', 'approved'] })
|
||||
.andWhere('b.submission_deadline >= :now', { now })
|
||||
.orderBy('b.submission_deadline', 'ASC')
|
||||
.take(5)
|
||||
.getMany();
|
||||
|
||||
// Win rate últimos 12 meses
|
||||
const yearAgo = new Date();
|
||||
yearAgo.setFullYear(yearAgo.getFullYear() - 1);
|
||||
|
||||
const winRateStats = await this.bidRepository
|
||||
.createQueryBuilder('b')
|
||||
.select('b.status', 'status')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere('b.status IN (:...closedStatuses)', { closedStatuses: ['awarded', 'rejected'] })
|
||||
.andWhere('b.award_date >= :yearAgo', { yearAgo })
|
||||
.groupBy('b.status')
|
||||
.getRawMany();
|
||||
|
||||
const awarded = winRateStats.find((s) => s.status === 'awarded')?.count || 0;
|
||||
const rejected = winRateStats.find((s) => s.status === 'rejected')?.count || 0;
|
||||
const totalClosed = parseInt(awarded) + parseInt(rejected);
|
||||
const winRate = totalClosed > 0 ? (parseInt(awarded) / totalClosed) * 100 : 0;
|
||||
|
||||
// Valor ganado este año
|
||||
const startOfYear = new Date(now.getFullYear(), 0, 1);
|
||||
const wonValue = await this.bidRepository
|
||||
.createQueryBuilder('b')
|
||||
.select('SUM(b.winning_amount)', 'value')
|
||||
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere('b.status = :status', { status: 'awarded' })
|
||||
.andWhere('b.award_date >= :startOfYear', { startOfYear })
|
||||
.getRawOne();
|
||||
|
||||
return {
|
||||
activeOpportunities,
|
||||
activeBids,
|
||||
pipelineValue: parseFloat(pipelineValue?.value) || 0,
|
||||
upcomingDeadlines: upcomingDeadlines.map((b) => ({
|
||||
id: b.id,
|
||||
name: b.name,
|
||||
deadline: b.submissionDeadline,
|
||||
status: b.status,
|
||||
})),
|
||||
winRate,
|
||||
wonValueYTD: parseFloat(wonValue?.value) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Análisis de pipeline por fuente
|
||||
*/
|
||||
async getPipelineBySource(ctx: ServiceContext): Promise<PipelineBySource[]> {
|
||||
const result = await this.opportunityRepository
|
||||
.createQueryBuilder('o')
|
||||
.select('o.source', 'source')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.addSelect('SUM(o.estimated_value)', 'totalValue')
|
||||
.addSelect('SUM(o.weighted_value)', 'weightedValue')
|
||||
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('o.deleted_at IS NULL')
|
||||
.andWhere('o.status NOT IN (:...closedStatuses)', { closedStatuses: ['won', 'lost', 'cancelled'] })
|
||||
.groupBy('o.source')
|
||||
.orderBy('SUM(o.weighted_value)', 'DESC')
|
||||
.getRawMany();
|
||||
|
||||
return result.map((r) => ({
|
||||
source: r.source as OpportunitySource,
|
||||
count: parseInt(r.count),
|
||||
totalValue: parseFloat(r.totalValue) || 0,
|
||||
weightedValue: parseFloat(r.weightedValue) || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Análisis de win rate por tipo de licitación
|
||||
*/
|
||||
async getWinRateByType(ctx: ServiceContext, months = 12): Promise<WinRateByType[]> {
|
||||
const fromDate = new Date();
|
||||
fromDate.setMonth(fromDate.getMonth() - months);
|
||||
|
||||
const result = await this.bidRepository
|
||||
.createQueryBuilder('b')
|
||||
.select('b.bid_type', 'bidType')
|
||||
.addSelect('COUNT(*) FILTER (WHERE b.status = \'awarded\')', 'won')
|
||||
.addSelect('COUNT(*) FILTER (WHERE b.status IN (\'awarded\', \'rejected\'))', 'total')
|
||||
.addSelect('SUM(CASE WHEN b.status = \'awarded\' THEN b.winning_amount ELSE 0 END)', 'wonValue')
|
||||
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere('b.award_date >= :fromDate', { fromDate })
|
||||
.groupBy('b.bid_type')
|
||||
.getRawMany();
|
||||
|
||||
return result.map((r) => ({
|
||||
bidType: r.bidType as BidType,
|
||||
won: parseInt(r.won) || 0,
|
||||
total: parseInt(r.total) || 0,
|
||||
winRate: parseInt(r.total) > 0 ? (parseInt(r.won) / parseInt(r.total)) * 100 : 0,
|
||||
wonValue: parseFloat(r.wonValue) || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tendencia mensual de oportunidades
|
||||
*/
|
||||
async getMonthlyTrend(ctx: ServiceContext, months = 12): Promise<MonthlyTrend[]> {
|
||||
const fromDate = new Date();
|
||||
fromDate.setMonth(fromDate.getMonth() - months);
|
||||
|
||||
const result = await this.opportunityRepository
|
||||
.createQueryBuilder('o')
|
||||
.select("TO_CHAR(o.identification_date, 'YYYY-MM')", 'month')
|
||||
.addSelect('COUNT(*)', 'identified')
|
||||
.addSelect('COUNT(*) FILTER (WHERE o.status = \'won\')', 'won')
|
||||
.addSelect('COUNT(*) FILTER (WHERE o.status = \'lost\')', 'lost')
|
||||
.addSelect('SUM(CASE WHEN o.status = \'won\' THEN o.estimated_value ELSE 0 END)', 'wonValue')
|
||||
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('o.deleted_at IS NULL')
|
||||
.andWhere('o.identification_date >= :fromDate', { fromDate })
|
||||
.groupBy("TO_CHAR(o.identification_date, 'YYYY-MM')")
|
||||
.orderBy("TO_CHAR(o.identification_date, 'YYYY-MM')", 'ASC')
|
||||
.getRawMany();
|
||||
|
||||
return result.map((r) => ({
|
||||
month: r.month,
|
||||
identified: parseInt(r.identified) || 0,
|
||||
won: parseInt(r.won) || 0,
|
||||
lost: parseInt(r.lost) || 0,
|
||||
wonValue: parseFloat(r.wonValue) || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Análisis de competidores
|
||||
*/
|
||||
async getCompetitorAnalysis(ctx: ServiceContext): Promise<CompetitorAnalysis[]> {
|
||||
const result = await this.competitorRepository
|
||||
.createQueryBuilder('c')
|
||||
.select('c.company_name', 'companyName')
|
||||
.addSelect('COUNT(*)', 'encounters')
|
||||
.addSelect('SUM(CASE WHEN c.status = \'winner\' THEN 1 ELSE 0 END)', 'theirWins')
|
||||
.addSelect('SUM(CASE WHEN c.status = \'loser\' THEN 1 ELSE 0 END)', 'ourWins')
|
||||
.addSelect('AVG(c.proposed_amount)', 'avgProposedAmount')
|
||||
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('c.deleted_at IS NULL')
|
||||
.groupBy('c.company_name')
|
||||
.having('COUNT(*) >= 2')
|
||||
.orderBy('COUNT(*)', 'DESC')
|
||||
.take(20)
|
||||
.getRawMany();
|
||||
|
||||
return result.map((r) => ({
|
||||
companyName: r.companyName,
|
||||
encounters: parseInt(r.encounters) || 0,
|
||||
theirWins: parseInt(r.theirWins) || 0,
|
||||
ourWins: parseInt(r.ourWins) || 0,
|
||||
winRateAgainst: parseInt(r.encounters) > 0
|
||||
? (parseInt(r.ourWins) / parseInt(r.encounters)) * 100
|
||||
: 0,
|
||||
avgProposedAmount: parseFloat(r.avgProposedAmount) || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Análisis de conversión del funnel
|
||||
*/
|
||||
async getFunnelAnalysis(ctx: ServiceContext, months = 12): Promise<FunnelAnalysis> {
|
||||
const fromDate = new Date();
|
||||
fromDate.setMonth(fromDate.getMonth() - months);
|
||||
|
||||
const baseQuery = this.opportunityRepository
|
||||
.createQueryBuilder('o')
|
||||
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('o.deleted_at IS NULL')
|
||||
.andWhere('o.identification_date >= :fromDate', { fromDate });
|
||||
|
||||
const identified = await baseQuery.clone().getCount();
|
||||
|
||||
const qualified = await baseQuery.clone()
|
||||
.andWhere('o.status NOT IN (:...earlyStatuses)', { earlyStatuses: ['identified'] })
|
||||
.getCount();
|
||||
|
||||
const pursuing = await baseQuery.clone()
|
||||
.andWhere('o.status IN (:...pursuitStatuses)', { pursuitStatuses: ['pursuing', 'bid_submitted', 'won', 'lost'] })
|
||||
.getCount();
|
||||
|
||||
const bidSubmitted = await baseQuery.clone()
|
||||
.andWhere('o.status IN (:...submittedStatuses)', { submittedStatuses: ['bid_submitted', 'won', 'lost'] })
|
||||
.getCount();
|
||||
|
||||
const won = await baseQuery.clone()
|
||||
.andWhere('o.status = :status', { status: 'won' })
|
||||
.getCount();
|
||||
|
||||
return {
|
||||
identified,
|
||||
qualified,
|
||||
pursuing,
|
||||
bidSubmitted,
|
||||
won,
|
||||
conversionRates: {
|
||||
identifiedToQualified: identified > 0 ? (qualified / identified) * 100 : 0,
|
||||
qualifiedToPursuing: qualified > 0 ? (pursuing / qualified) * 100 : 0,
|
||||
pursuingToSubmitted: pursuing > 0 ? (bidSubmitted / pursuing) * 100 : 0,
|
||||
submittedToWon: bidSubmitted > 0 ? (won / bidSubmitted) * 100 : 0,
|
||||
overallConversion: identified > 0 ? (won / identified) * 100 : 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Análisis de tiempos de ciclo
|
||||
*/
|
||||
async getCycleTimeAnalysis(ctx: ServiceContext, months = 12): Promise<CycleTimeAnalysis> {
|
||||
const fromDate = new Date();
|
||||
fromDate.setMonth(fromDate.getMonth() - months);
|
||||
|
||||
const result = await this.opportunityRepository
|
||||
.createQueryBuilder('o')
|
||||
.select('AVG(EXTRACT(DAY FROM (o.updated_at - o.identification_date)))', 'avgDays')
|
||||
.addSelect('MIN(EXTRACT(DAY FROM (o.updated_at - o.identification_date)))', 'minDays')
|
||||
.addSelect('MAX(EXTRACT(DAY FROM (o.updated_at - o.identification_date)))', 'maxDays')
|
||||
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('o.deleted_at IS NULL')
|
||||
.andWhere('o.status IN (:...closedStatuses)', { closedStatuses: ['won', 'lost'] })
|
||||
.andWhere('o.identification_date >= :fromDate', { fromDate })
|
||||
.getRawOne();
|
||||
|
||||
const byOutcome = await this.opportunityRepository
|
||||
.createQueryBuilder('o')
|
||||
.select('o.status', 'outcome')
|
||||
.addSelect('AVG(EXTRACT(DAY FROM (o.updated_at - o.identification_date)))', 'avgDays')
|
||||
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('o.deleted_at IS NULL')
|
||||
.andWhere('o.status IN (:...closedStatuses)', { closedStatuses: ['won', 'lost'] })
|
||||
.andWhere('o.identification_date >= :fromDate', { fromDate })
|
||||
.groupBy('o.status')
|
||||
.getRawMany();
|
||||
|
||||
return {
|
||||
overall: {
|
||||
avgDays: Math.round(parseFloat(result?.avgDays) || 0),
|
||||
minDays: Math.round(parseFloat(result?.minDays) || 0),
|
||||
maxDays: Math.round(parseFloat(result?.maxDays) || 0),
|
||||
},
|
||||
byOutcome: byOutcome.map((r) => ({
|
||||
outcome: r.outcome as 'won' | 'lost',
|
||||
avgDays: Math.round(parseFloat(r.avgDays) || 0),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Types
|
||||
export interface BidDashboard {
|
||||
activeOpportunities: number;
|
||||
activeBids: number;
|
||||
pipelineValue: number;
|
||||
upcomingDeadlines: { id: string; name: string; deadline: Date; status: BidStatus }[];
|
||||
winRate: number;
|
||||
wonValueYTD: number;
|
||||
}
|
||||
|
||||
export interface PipelineBySource {
|
||||
source: OpportunitySource;
|
||||
count: number;
|
||||
totalValue: number;
|
||||
weightedValue: number;
|
||||
}
|
||||
|
||||
export interface WinRateByType {
|
||||
bidType: BidType;
|
||||
won: number;
|
||||
total: number;
|
||||
winRate: number;
|
||||
wonValue: number;
|
||||
}
|
||||
|
||||
export interface MonthlyTrend {
|
||||
month: string;
|
||||
identified: number;
|
||||
won: number;
|
||||
lost: number;
|
||||
wonValue: number;
|
||||
}
|
||||
|
||||
export interface CompetitorAnalysis {
|
||||
companyName: string;
|
||||
encounters: number;
|
||||
theirWins: number;
|
||||
ourWins: number;
|
||||
winRateAgainst: number;
|
||||
avgProposedAmount: number;
|
||||
}
|
||||
|
||||
export interface FunnelAnalysis {
|
||||
identified: number;
|
||||
qualified: number;
|
||||
pursuing: number;
|
||||
bidSubmitted: number;
|
||||
won: number;
|
||||
conversionRates: {
|
||||
identifiedToQualified: number;
|
||||
qualifiedToPursuing: number;
|
||||
pursuingToSubmitted: number;
|
||||
submittedToWon: number;
|
||||
overallConversion: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CycleTimeAnalysis {
|
||||
overall: {
|
||||
avgDays: number;
|
||||
minDays: number;
|
||||
maxDays: number;
|
||||
};
|
||||
byOutcome: {
|
||||
outcome: 'won' | 'lost';
|
||||
avgDays: number;
|
||||
}[];
|
||||
}
|
||||
388
src/modules/bidding/services/bid-budget.service.ts
Normal file
388
src/modules/bidding/services/bid-budget.service.ts
Normal file
@ -0,0 +1,388 @@
|
||||
/**
|
||||
* BidBudgetService - Gestión de Presupuestos de Licitación
|
||||
*
|
||||
* CRUD y cálculos para propuestas económicas.
|
||||
*
|
||||
* @module Bidding
|
||||
*/
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
|
||||
import { BidBudget, BudgetItemType, BudgetStatus } from '../entities/bid-budget.entity';
|
||||
|
||||
export interface CreateBudgetItemDto {
|
||||
bidId: string;
|
||||
parentId?: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
itemType: BudgetItemType;
|
||||
unit?: string;
|
||||
quantity?: number;
|
||||
unitPrice?: number;
|
||||
materialsCost?: number;
|
||||
laborCost?: number;
|
||||
equipmentCost?: number;
|
||||
subcontractCost?: number;
|
||||
indirectPercentage?: number;
|
||||
profitPercentage?: number;
|
||||
financingPercentage?: number;
|
||||
baseAmount?: number;
|
||||
catalogConceptId?: string;
|
||||
notes?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface UpdateBudgetItemDto extends Partial<CreateBudgetItemDto> {
|
||||
status?: BudgetStatus;
|
||||
adjustmentReason?: string;
|
||||
}
|
||||
|
||||
export interface BudgetFilters {
|
||||
bidId: string;
|
||||
itemType?: BudgetItemType;
|
||||
status?: BudgetStatus;
|
||||
parentId?: string | null;
|
||||
isSummary?: boolean;
|
||||
}
|
||||
|
||||
export class BidBudgetService {
|
||||
constructor(private readonly repository: Repository<BidBudget>) {}
|
||||
|
||||
/**
|
||||
* Crear item de presupuesto
|
||||
*/
|
||||
async create(ctx: ServiceContext, data: CreateBudgetItemDto): Promise<BidBudget> {
|
||||
// Calcular nivel jerárquico
|
||||
let level = 0;
|
||||
if (data.parentId) {
|
||||
const parent = await this.repository.findOne({
|
||||
where: { id: data.parentId, tenantId: ctx.tenantId },
|
||||
});
|
||||
if (parent) {
|
||||
level = parent.level + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Calcular orden
|
||||
const lastItem = await this.repository
|
||||
.createQueryBuilder('bb')
|
||||
.where('bb.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('bb.bid_id = :bidId', { bidId: data.bidId })
|
||||
.andWhere(data.parentId ? 'bb.parent_id = :parentId' : 'bb.parent_id IS NULL', { parentId: data.parentId })
|
||||
.orderBy('bb.sort_order', 'DESC')
|
||||
.getOne();
|
||||
|
||||
const sortOrder = lastItem ? lastItem.sortOrder + 1 : 0;
|
||||
|
||||
// Calcular totales
|
||||
const quantity = data.quantity || 0;
|
||||
const unitPrice = data.unitPrice || 0;
|
||||
const totalAmount = quantity * unitPrice;
|
||||
|
||||
// Calcular varianza si hay base
|
||||
let varianceAmount = null;
|
||||
let variancePercentage = null;
|
||||
if (data.baseAmount !== undefined && data.baseAmount > 0) {
|
||||
varianceAmount = totalAmount - data.baseAmount;
|
||||
variancePercentage = (varianceAmount / data.baseAmount) * 100;
|
||||
}
|
||||
|
||||
const item = this.repository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
bidId: data.bidId,
|
||||
parentId: data.parentId,
|
||||
code: data.code,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
itemType: data.itemType,
|
||||
unit: data.unit,
|
||||
quantity: data.quantity || 0,
|
||||
unitPrice: data.unitPrice || 0,
|
||||
materialsCost: data.materialsCost,
|
||||
laborCost: data.laborCost,
|
||||
equipmentCost: data.equipmentCost,
|
||||
subcontractCost: data.subcontractCost,
|
||||
indirectPercentage: data.indirectPercentage,
|
||||
profitPercentage: data.profitPercentage,
|
||||
financingPercentage: data.financingPercentage,
|
||||
baseAmount: data.baseAmount,
|
||||
catalogConceptId: data.catalogConceptId,
|
||||
notes: data.notes,
|
||||
metadata: data.metadata,
|
||||
level,
|
||||
sortOrder,
|
||||
totalAmount,
|
||||
varianceAmount: varianceAmount ?? undefined,
|
||||
variancePercentage: variancePercentage ?? undefined,
|
||||
status: 'draft',
|
||||
isSummary: false,
|
||||
isCalculated: true,
|
||||
createdBy: ctx.userId,
|
||||
updatedBy: ctx.userId,
|
||||
});
|
||||
|
||||
const saved = await this.repository.save(item);
|
||||
|
||||
// Recalcular padres
|
||||
if (data.parentId) {
|
||||
await this.recalculateParent(ctx, data.parentId);
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Buscar por ID
|
||||
*/
|
||||
async findById(ctx: ServiceContext, id: string): Promise<BidBudget | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id, tenantId: ctx.tenantId, deletedAt: undefined },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Buscar items de un presupuesto
|
||||
*/
|
||||
async findByBid(ctx: ServiceContext, bidId: string): Promise<BidBudget[]> {
|
||||
return this.repository.find({
|
||||
where: { bidId, tenantId: ctx.tenantId, deletedAt: undefined },
|
||||
order: { sortOrder: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Buscar items con filtros
|
||||
*/
|
||||
async findWithFilters(
|
||||
ctx: ServiceContext,
|
||||
filters: BudgetFilters,
|
||||
page = 1,
|
||||
limit = 100
|
||||
): Promise<PaginatedResult<BidBudget>> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('bb')
|
||||
.where('bb.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('bb.bid_id = :bidId', { bidId: filters.bidId })
|
||||
.andWhere('bb.deleted_at IS NULL');
|
||||
|
||||
if (filters.itemType) {
|
||||
qb.andWhere('bb.item_type = :itemType', { itemType: filters.itemType });
|
||||
}
|
||||
if (filters.status) {
|
||||
qb.andWhere('bb.status = :status', { status: filters.status });
|
||||
}
|
||||
if (filters.parentId !== undefined) {
|
||||
if (filters.parentId === null) {
|
||||
qb.andWhere('bb.parent_id IS NULL');
|
||||
} else {
|
||||
qb.andWhere('bb.parent_id = :parentId', { parentId: filters.parentId });
|
||||
}
|
||||
}
|
||||
if (filters.isSummary !== undefined) {
|
||||
qb.andWhere('bb.is_summary = :isSummary', { isSummary: filters.isSummary });
|
||||
}
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
qb.orderBy('bb.sort_order', 'ASC').skip(skip).take(limit);
|
||||
|
||||
const [data, total] = await qb.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener árbol jerárquico
|
||||
*/
|
||||
async getTree(ctx: ServiceContext, bidId: string): Promise<BidBudget[]> {
|
||||
const items = await this.findByBid(ctx, bidId);
|
||||
return this.buildTree(items);
|
||||
}
|
||||
|
||||
private buildTree(items: BidBudget[], parentId: string | null = null): (BidBudget & { children?: BidBudget[] })[] {
|
||||
return items
|
||||
.filter((item) => item.parentId === parentId)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
children: this.buildTree(items, item.id),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar item
|
||||
*/
|
||||
async update(ctx: ServiceContext, id: string, data: UpdateBudgetItemDto): Promise<BidBudget | null> {
|
||||
const item = await this.findById(ctx, id);
|
||||
if (!item) return null;
|
||||
|
||||
// Recalcular totales si cambian cantidad o precio
|
||||
const quantity = data.quantity ?? item.quantity;
|
||||
const unitPrice = data.unitPrice ?? item.unitPrice;
|
||||
const totalAmount = quantity * unitPrice;
|
||||
|
||||
// Recalcular varianza
|
||||
const baseAmount = data.baseAmount ?? item.baseAmount;
|
||||
let varianceAmount = item.varianceAmount;
|
||||
let variancePercentage = item.variancePercentage;
|
||||
if (baseAmount !== undefined && baseAmount > 0) {
|
||||
varianceAmount = totalAmount - baseAmount;
|
||||
variancePercentage = (varianceAmount / baseAmount) * 100;
|
||||
}
|
||||
|
||||
// Marcar como ajustado si hay razón
|
||||
const isAdjusted = data.adjustmentReason ? true : item.isAdjusted;
|
||||
|
||||
Object.assign(item, {
|
||||
...data,
|
||||
totalAmount,
|
||||
varianceAmount,
|
||||
variancePercentage,
|
||||
isAdjusted,
|
||||
isCalculated: true,
|
||||
updatedBy: ctx.userId,
|
||||
});
|
||||
|
||||
const saved = await this.repository.save(item);
|
||||
|
||||
// Recalcular padres
|
||||
if (item.parentId) {
|
||||
await this.recalculateParent(ctx, item.parentId);
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalcular item padre
|
||||
*/
|
||||
private async recalculateParent(ctx: ServiceContext, parentId: string): Promise<void> {
|
||||
const parent = await this.findById(ctx, parentId);
|
||||
if (!parent) return;
|
||||
|
||||
const children = await this.repository.find({
|
||||
where: { parentId, tenantId: ctx.tenantId, deletedAt: undefined },
|
||||
});
|
||||
|
||||
const totalAmount = children.reduce((sum, child) => sum + (Number(child.totalAmount) || 0), 0);
|
||||
|
||||
parent.totalAmount = totalAmount;
|
||||
parent.isSummary = children.length > 0;
|
||||
parent.isCalculated = true;
|
||||
parent.updatedBy = ctx.userId;
|
||||
|
||||
// Recalcular varianza
|
||||
if (parent.baseAmount !== undefined && parent.baseAmount > 0) {
|
||||
parent.varianceAmount = totalAmount - Number(parent.baseAmount);
|
||||
parent.variancePercentage = (parent.varianceAmount / Number(parent.baseAmount)) * 100;
|
||||
}
|
||||
|
||||
await this.repository.save(parent);
|
||||
|
||||
// Recursivamente actualizar ancestros
|
||||
if (parent.parentId) {
|
||||
await this.recalculateParent(ctx, parent.parentId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener resumen de presupuesto
|
||||
*/
|
||||
async getSummary(ctx: ServiceContext, bidId: string): Promise<BudgetSummary> {
|
||||
const items = await this.findByBid(ctx, bidId);
|
||||
|
||||
const directCosts = items
|
||||
.filter((i) => i.itemType === 'direct_cost' || i.itemType === 'labor' || i.itemType === 'materials' || i.itemType === 'equipment' || i.itemType === 'subcontract')
|
||||
.reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0);
|
||||
|
||||
const indirectCosts = items
|
||||
.filter((i) => i.itemType === 'indirect_cost' || i.itemType === 'overhead')
|
||||
.reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0);
|
||||
|
||||
const profit = items
|
||||
.filter((i) => i.itemType === 'profit')
|
||||
.reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0);
|
||||
|
||||
const financing = items
|
||||
.filter((i) => i.itemType === 'financing')
|
||||
.reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0);
|
||||
|
||||
const taxes = items
|
||||
.filter((i) => i.itemType === 'taxes')
|
||||
.reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0);
|
||||
|
||||
const bonds = items
|
||||
.filter((i) => i.itemType === 'bonds')
|
||||
.reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0);
|
||||
|
||||
const contingency = items
|
||||
.filter((i) => i.itemType === 'contingency')
|
||||
.reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0);
|
||||
|
||||
const subtotal = directCosts + indirectCosts + profit + financing + contingency + bonds;
|
||||
const total = subtotal + taxes;
|
||||
|
||||
const baseTotal = items.reduce((sum, i) => sum + (Number(i.baseAmount) || 0), 0);
|
||||
|
||||
return {
|
||||
directCosts,
|
||||
indirectCosts,
|
||||
profit,
|
||||
financing,
|
||||
taxes,
|
||||
bonds,
|
||||
contingency,
|
||||
subtotal,
|
||||
total,
|
||||
baseTotal,
|
||||
variance: total - baseTotal,
|
||||
variancePercentage: baseTotal > 0 ? ((total - baseTotal) / baseTotal) * 100 : 0,
|
||||
itemCount: items.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cambiar estado del presupuesto
|
||||
*/
|
||||
async changeStatus(ctx: ServiceContext, bidId: string, status: BudgetStatus): Promise<number> {
|
||||
const result = await this.repository.update(
|
||||
{ bidId, tenantId: ctx.tenantId, deletedAt: undefined },
|
||||
{ status, updatedBy: ctx.userId }
|
||||
);
|
||||
return result.affected || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete
|
||||
*/
|
||||
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
|
||||
const result = await this.repository.update(
|
||||
{ id, tenantId: ctx.tenantId },
|
||||
{ deletedAt: new Date(), updatedBy: ctx.userId }
|
||||
);
|
||||
return (result.affected || 0) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
export interface BudgetSummary {
|
||||
directCosts: number;
|
||||
indirectCosts: number;
|
||||
profit: number;
|
||||
financing: number;
|
||||
taxes: number;
|
||||
bonds: number;
|
||||
contingency: number;
|
||||
subtotal: number;
|
||||
total: number;
|
||||
baseTotal: number;
|
||||
variance: number;
|
||||
variancePercentage: number;
|
||||
itemCount: number;
|
||||
}
|
||||
384
src/modules/bidding/services/bid.service.ts
Normal file
384
src/modules/bidding/services/bid.service.ts
Normal file
@ -0,0 +1,384 @@
|
||||
/**
|
||||
* BidService - Gestión de Licitaciones
|
||||
*
|
||||
* CRUD y lógica de negocio para licitaciones/propuestas.
|
||||
*
|
||||
* @module Bidding
|
||||
*/
|
||||
|
||||
import { Repository, In, Between } from 'typeorm';
|
||||
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
|
||||
import { Bid, BidType, BidStatus, BidStage } from '../entities/bid.entity';
|
||||
|
||||
export interface CreateBidDto {
|
||||
opportunityId: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
bidType: BidType;
|
||||
tenderNumber?: string;
|
||||
tenderName?: string;
|
||||
contractingEntity?: string;
|
||||
publicationDate?: Date;
|
||||
siteVisitDate?: Date;
|
||||
clarificationDeadline?: Date;
|
||||
submissionDeadline: Date;
|
||||
openingDate?: Date;
|
||||
baseBudget?: number;
|
||||
currency?: string;
|
||||
technicalWeight?: number;
|
||||
economicWeight?: number;
|
||||
bidBondAmount?: number;
|
||||
bidManagerId?: string;
|
||||
notes?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface UpdateBidDto extends Partial<CreateBidDto> {
|
||||
status?: BidStatus;
|
||||
stage?: BidStage;
|
||||
ourProposalAmount?: number;
|
||||
technicalScore?: number;
|
||||
economicScore?: number;
|
||||
finalScore?: number;
|
||||
rankingPosition?: number;
|
||||
bidBondNumber?: string;
|
||||
bidBondExpiry?: Date;
|
||||
awardDate?: Date;
|
||||
contractSigningDate?: Date;
|
||||
winnerName?: string;
|
||||
winningAmount?: number;
|
||||
rejectionReason?: string;
|
||||
lessonsLearned?: string;
|
||||
completionPercentage?: number;
|
||||
checklist?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface BidFilters {
|
||||
status?: BidStatus | BidStatus[];
|
||||
bidType?: BidType;
|
||||
stage?: BidStage;
|
||||
opportunityId?: string;
|
||||
bidManagerId?: string;
|
||||
contractingEntity?: string;
|
||||
deadlineFrom?: Date;
|
||||
deadlineTo?: Date;
|
||||
minBudget?: number;
|
||||
maxBudget?: number;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export class BidService {
|
||||
constructor(private readonly repository: Repository<Bid>) {}
|
||||
|
||||
/**
|
||||
* Crear licitación
|
||||
*/
|
||||
async create(ctx: ServiceContext, data: CreateBidDto): Promise<Bid> {
|
||||
const bid = this.repository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
...data,
|
||||
status: 'draft',
|
||||
stage: 'initial',
|
||||
completionPercentage: 0,
|
||||
createdBy: ctx.userId,
|
||||
updatedBy: ctx.userId,
|
||||
});
|
||||
|
||||
return this.repository.save(bid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Buscar por ID
|
||||
*/
|
||||
async findById(ctx: ServiceContext, id: string): Promise<Bid | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id, tenantId: ctx.tenantId, deletedAt: undefined },
|
||||
relations: ['opportunity', 'bidManager', 'documents', 'calendarEvents', 'teamMembers'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Buscar con filtros
|
||||
*/
|
||||
async findWithFilters(
|
||||
ctx: ServiceContext,
|
||||
filters: BidFilters,
|
||||
page = 1,
|
||||
limit = 20
|
||||
): Promise<PaginatedResult<Bid>> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('b')
|
||||
.leftJoinAndSelect('b.opportunity', 'o')
|
||||
.leftJoinAndSelect('b.bidManager', 'm')
|
||||
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('b.deleted_at IS NULL');
|
||||
|
||||
if (filters.status) {
|
||||
if (Array.isArray(filters.status)) {
|
||||
qb.andWhere('b.status IN (:...statuses)', { statuses: filters.status });
|
||||
} else {
|
||||
qb.andWhere('b.status = :status', { status: filters.status });
|
||||
}
|
||||
}
|
||||
if (filters.bidType) {
|
||||
qb.andWhere('b.bid_type = :bidType', { bidType: filters.bidType });
|
||||
}
|
||||
if (filters.stage) {
|
||||
qb.andWhere('b.stage = :stage', { stage: filters.stage });
|
||||
}
|
||||
if (filters.opportunityId) {
|
||||
qb.andWhere('b.opportunity_id = :opportunityId', { opportunityId: filters.opportunityId });
|
||||
}
|
||||
if (filters.bidManagerId) {
|
||||
qb.andWhere('b.bid_manager_id = :bidManagerId', { bidManagerId: filters.bidManagerId });
|
||||
}
|
||||
if (filters.contractingEntity) {
|
||||
qb.andWhere('b.contracting_entity ILIKE :entity', { entity: `%${filters.contractingEntity}%` });
|
||||
}
|
||||
if (filters.deadlineFrom) {
|
||||
qb.andWhere('b.submission_deadline >= :deadlineFrom', { deadlineFrom: filters.deadlineFrom });
|
||||
}
|
||||
if (filters.deadlineTo) {
|
||||
qb.andWhere('b.submission_deadline <= :deadlineTo', { deadlineTo: filters.deadlineTo });
|
||||
}
|
||||
if (filters.minBudget !== undefined) {
|
||||
qb.andWhere('b.base_budget >= :minBudget', { minBudget: filters.minBudget });
|
||||
}
|
||||
if (filters.maxBudget !== undefined) {
|
||||
qb.andWhere('b.base_budget <= :maxBudget', { maxBudget: filters.maxBudget });
|
||||
}
|
||||
if (filters.search) {
|
||||
qb.andWhere(
|
||||
'(b.name ILIKE :search OR b.code ILIKE :search OR b.tender_number ILIKE :search)',
|
||||
{ search: `%${filters.search}%` }
|
||||
);
|
||||
}
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
qb.orderBy('b.submission_deadline', 'ASC').skip(skip).take(limit);
|
||||
|
||||
const [data, total] = await qb.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar licitación
|
||||
*/
|
||||
async update(ctx: ServiceContext, id: string, data: UpdateBidDto): Promise<Bid | null> {
|
||||
const bid = await this.findById(ctx, id);
|
||||
if (!bid) return null;
|
||||
|
||||
// Calcular puntuación final si hay scores
|
||||
let finalScore = data.finalScore ?? bid.finalScore;
|
||||
const techScore = data.technicalScore ?? bid.technicalScore;
|
||||
const econScore = data.economicScore ?? bid.economicScore;
|
||||
const techWeight = data.technicalWeight ?? bid.technicalWeight;
|
||||
const econWeight = data.economicWeight ?? bid.economicWeight;
|
||||
|
||||
if (techScore !== undefined && econScore !== undefined) {
|
||||
finalScore = (techScore * techWeight / 100) + (econScore * econWeight / 100);
|
||||
}
|
||||
|
||||
Object.assign(bid, {
|
||||
...data,
|
||||
finalScore,
|
||||
updatedBy: ctx.userId,
|
||||
});
|
||||
|
||||
return this.repository.save(bid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cambiar estado
|
||||
*/
|
||||
async changeStatus(ctx: ServiceContext, id: string, status: BidStatus): Promise<Bid | null> {
|
||||
const bid = await this.findById(ctx, id);
|
||||
if (!bid) return null;
|
||||
|
||||
bid.status = status;
|
||||
bid.updatedBy = ctx.userId;
|
||||
|
||||
return this.repository.save(bid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cambiar etapa
|
||||
*/
|
||||
async changeStage(ctx: ServiceContext, id: string, stage: BidStage): Promise<Bid | null> {
|
||||
const bid = await this.findById(ctx, id);
|
||||
if (!bid) return null;
|
||||
|
||||
bid.stage = stage;
|
||||
bid.updatedBy = ctx.userId;
|
||||
|
||||
return this.repository.save(bid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marcar como presentada
|
||||
*/
|
||||
async submit(ctx: ServiceContext, id: string, proposalAmount: number): Promise<Bid | null> {
|
||||
const bid = await this.findById(ctx, id);
|
||||
if (!bid) return null;
|
||||
|
||||
bid.status = 'submitted';
|
||||
bid.stage = 'post_submission';
|
||||
bid.ourProposalAmount = proposalAmount;
|
||||
bid.updatedBy = ctx.userId;
|
||||
|
||||
return this.repository.save(bid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrar resultado
|
||||
*/
|
||||
async recordResult(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
won: boolean,
|
||||
details: {
|
||||
winnerName?: string;
|
||||
winningAmount?: number;
|
||||
rankingPosition?: number;
|
||||
rejectionReason?: string;
|
||||
lessonsLearned?: string;
|
||||
}
|
||||
): Promise<Bid | null> {
|
||||
const bid = await this.findById(ctx, id);
|
||||
if (!bid) return null;
|
||||
|
||||
bid.status = won ? 'awarded' : 'rejected';
|
||||
bid.awardDate = new Date();
|
||||
Object.assign(bid, details);
|
||||
bid.updatedBy = ctx.userId;
|
||||
|
||||
return this.repository.save(bid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertir a proyecto
|
||||
*/
|
||||
async convertToProject(ctx: ServiceContext, id: string, projectId: string): Promise<Bid | null> {
|
||||
const bid = await this.findById(ctx, id);
|
||||
if (!bid || bid.status !== 'awarded') return null;
|
||||
|
||||
bid.convertedToProjectId = projectId;
|
||||
bid.convertedAt = new Date();
|
||||
bid.updatedBy = ctx.userId;
|
||||
|
||||
return this.repository.save(bid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener próximas fechas límite
|
||||
*/
|
||||
async getUpcomingDeadlines(ctx: ServiceContext, days = 7): Promise<Bid[]> {
|
||||
const now = new Date();
|
||||
const future = new Date();
|
||||
future.setDate(future.getDate() + days);
|
||||
|
||||
return this.repository.find({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
deletedAt: undefined,
|
||||
status: In(['draft', 'preparation', 'review', 'approved']),
|
||||
submissionDeadline: Between(now, future),
|
||||
},
|
||||
relations: ['opportunity', 'bidManager'],
|
||||
order: { submissionDeadline: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener estadísticas
|
||||
*/
|
||||
async getStats(ctx: ServiceContext, year?: number): Promise<BidStats> {
|
||||
const currentYear = year || new Date().getFullYear();
|
||||
const startDate = new Date(currentYear, 0, 1);
|
||||
const endDate = new Date(currentYear, 11, 31);
|
||||
|
||||
const total = await this.repository
|
||||
.createQueryBuilder('b')
|
||||
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere('b.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
|
||||
.getCount();
|
||||
|
||||
const byStatus = await this.repository
|
||||
.createQueryBuilder('b')
|
||||
.select('b.status', 'status')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere('b.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
|
||||
.groupBy('b.status')
|
||||
.getRawMany();
|
||||
|
||||
const byType = await this.repository
|
||||
.createQueryBuilder('b')
|
||||
.select('b.bid_type', 'bidType')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere('b.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
|
||||
.groupBy('b.bid_type')
|
||||
.getRawMany();
|
||||
|
||||
const valueStats = await this.repository
|
||||
.createQueryBuilder('b')
|
||||
.select('SUM(b.base_budget)', 'totalBudget')
|
||||
.addSelect('SUM(b.our_proposal_amount)', 'totalProposed')
|
||||
.addSelect('SUM(CASE WHEN b.status = \'awarded\' THEN b.winning_amount ELSE 0 END)', 'totalWon')
|
||||
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere('b.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
|
||||
.getRawOne();
|
||||
|
||||
const awardedCount = byStatus.find((s) => s.status === 'awarded')?.count || 0;
|
||||
const rejectedCount = byStatus.find((s) => s.status === 'rejected')?.count || 0;
|
||||
const closedCount = parseInt(awardedCount) + parseInt(rejectedCount);
|
||||
|
||||
return {
|
||||
year: currentYear,
|
||||
total,
|
||||
byStatus: byStatus.map((r) => ({ status: r.status, count: parseInt(r.count) })),
|
||||
byType: byType.map((r) => ({ bidType: r.bidType, count: parseInt(r.count) })),
|
||||
totalBudget: parseFloat(valueStats?.totalBudget) || 0,
|
||||
totalProposed: parseFloat(valueStats?.totalProposed) || 0,
|
||||
totalWon: parseFloat(valueStats?.totalWon) || 0,
|
||||
winRate: closedCount > 0 ? (parseInt(awardedCount) / closedCount) * 100 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete
|
||||
*/
|
||||
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
|
||||
const result = await this.repository.update(
|
||||
{ id, tenantId: ctx.tenantId },
|
||||
{ deletedAt: new Date(), updatedBy: ctx.userId }
|
||||
);
|
||||
return (result.affected || 0) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
export interface BidStats {
|
||||
year: number;
|
||||
total: number;
|
||||
byStatus: { status: BidStatus; count: number }[];
|
||||
byType: { bidType: BidType; count: number }[];
|
||||
totalBudget: number;
|
||||
totalProposed: number;
|
||||
totalWon: number;
|
||||
winRate: number;
|
||||
}
|
||||
9
src/modules/bidding/services/index.ts
Normal file
9
src/modules/bidding/services/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Bidding Services Index
|
||||
* @module Bidding
|
||||
*/
|
||||
|
||||
export { OpportunityService, CreateOpportunityDto, UpdateOpportunityDto, OpportunityFilters, PipelineData, OpportunityStats } from './opportunity.service';
|
||||
export { BidService, CreateBidDto, UpdateBidDto, BidFilters, BidStats } from './bid.service';
|
||||
export { BidBudgetService, CreateBudgetItemDto, UpdateBudgetItemDto, BudgetFilters, BudgetSummary } from './bid-budget.service';
|
||||
export { BidAnalyticsService, BidDashboard, PipelineBySource, WinRateByType, MonthlyTrend, CompetitorAnalysis, FunnelAnalysis, CycleTimeAnalysis } from './bid-analytics.service';
|
||||
392
src/modules/bidding/services/opportunity.service.ts
Normal file
392
src/modules/bidding/services/opportunity.service.ts
Normal file
@ -0,0 +1,392 @@
|
||||
/**
|
||||
* OpportunityService - Gestión de Oportunidades de Negocio
|
||||
*
|
||||
* CRUD y lógica de negocio para el pipeline de oportunidades.
|
||||
*
|
||||
* @module Bidding
|
||||
*/
|
||||
|
||||
import { Repository, In, Between } from 'typeorm';
|
||||
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
|
||||
import { Opportunity, OpportunitySource, OpportunityStatus, OpportunityPriority, ProjectType } from '../entities/opportunity.entity';
|
||||
|
||||
export interface CreateOpportunityDto {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
source: OpportunitySource;
|
||||
projectType: ProjectType;
|
||||
clientName: string;
|
||||
clientContact?: string;
|
||||
clientEmail?: string;
|
||||
clientPhone?: string;
|
||||
clientType?: string;
|
||||
location?: string;
|
||||
state?: string;
|
||||
city?: string;
|
||||
estimatedValue?: number;
|
||||
currency?: string;
|
||||
constructionAreaM2?: number;
|
||||
landAreaM2?: number;
|
||||
identificationDate: Date;
|
||||
deadlineDate?: Date;
|
||||
expectedAwardDate?: Date;
|
||||
expectedStartDate?: Date;
|
||||
expectedDurationMonths?: number;
|
||||
winProbability?: number;
|
||||
requiresBond?: boolean;
|
||||
requiresExperience?: boolean;
|
||||
minimumExperienceYears?: number;
|
||||
minimumCapital?: number;
|
||||
requiredCertifications?: string[];
|
||||
assignedToId?: string;
|
||||
sourceUrl?: string;
|
||||
sourceReference?: string;
|
||||
notes?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface UpdateOpportunityDto extends Partial<CreateOpportunityDto> {
|
||||
status?: OpportunityStatus;
|
||||
priority?: OpportunityPriority;
|
||||
lossReason?: string;
|
||||
winFactors?: string;
|
||||
}
|
||||
|
||||
export interface OpportunityFilters {
|
||||
status?: OpportunityStatus | OpportunityStatus[];
|
||||
source?: OpportunitySource;
|
||||
projectType?: ProjectType;
|
||||
priority?: OpportunityPriority;
|
||||
assignedToId?: string;
|
||||
clientName?: string;
|
||||
state?: string;
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
minValue?: number;
|
||||
maxValue?: number;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export class OpportunityService {
|
||||
constructor(private readonly repository: Repository<Opportunity>) {}
|
||||
|
||||
/**
|
||||
* Crear oportunidad
|
||||
*/
|
||||
async create(ctx: ServiceContext, data: CreateOpportunityDto): Promise<Opportunity> {
|
||||
const weightedValue = data.estimatedValue && data.winProbability
|
||||
? data.estimatedValue * (data.winProbability / 100)
|
||||
: undefined;
|
||||
|
||||
const opportunity = this.repository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
code: data.code,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
source: data.source,
|
||||
projectType: data.projectType,
|
||||
clientName: data.clientName,
|
||||
clientContact: data.clientContact,
|
||||
clientEmail: data.clientEmail,
|
||||
clientPhone: data.clientPhone,
|
||||
clientType: data.clientType,
|
||||
location: data.location,
|
||||
state: data.state,
|
||||
city: data.city,
|
||||
estimatedValue: data.estimatedValue,
|
||||
currency: data.currency || 'MXN',
|
||||
constructionAreaM2: data.constructionAreaM2,
|
||||
landAreaM2: data.landAreaM2,
|
||||
identificationDate: data.identificationDate,
|
||||
deadlineDate: data.deadlineDate,
|
||||
expectedAwardDate: data.expectedAwardDate,
|
||||
expectedStartDate: data.expectedStartDate,
|
||||
expectedDurationMonths: data.expectedDurationMonths,
|
||||
winProbability: data.winProbability || 0,
|
||||
requiresBond: data.requiresBond || false,
|
||||
requiresExperience: data.requiresExperience || false,
|
||||
minimumExperienceYears: data.minimumExperienceYears,
|
||||
minimumCapital: data.minimumCapital,
|
||||
requiredCertifications: data.requiredCertifications,
|
||||
assignedToId: data.assignedToId,
|
||||
sourceUrl: data.sourceUrl,
|
||||
sourceReference: data.sourceReference,
|
||||
notes: data.notes,
|
||||
metadata: data.metadata,
|
||||
status: 'identified',
|
||||
priority: 'medium',
|
||||
weightedValue,
|
||||
createdBy: ctx.userId,
|
||||
updatedBy: ctx.userId,
|
||||
});
|
||||
|
||||
return this.repository.save(opportunity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Buscar por ID
|
||||
*/
|
||||
async findById(ctx: ServiceContext, id: string): Promise<Opportunity | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id, tenantId: ctx.tenantId, deletedAt: undefined },
|
||||
relations: ['assignedTo', 'bids'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Buscar con filtros
|
||||
*/
|
||||
async findWithFilters(
|
||||
ctx: ServiceContext,
|
||||
filters: OpportunityFilters,
|
||||
page = 1,
|
||||
limit = 20
|
||||
): Promise<PaginatedResult<Opportunity>> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('o')
|
||||
.leftJoinAndSelect('o.assignedTo', 'u')
|
||||
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('o.deleted_at IS NULL');
|
||||
|
||||
if (filters.status) {
|
||||
if (Array.isArray(filters.status)) {
|
||||
qb.andWhere('o.status IN (:...statuses)', { statuses: filters.status });
|
||||
} else {
|
||||
qb.andWhere('o.status = :status', { status: filters.status });
|
||||
}
|
||||
}
|
||||
if (filters.source) {
|
||||
qb.andWhere('o.source = :source', { source: filters.source });
|
||||
}
|
||||
if (filters.projectType) {
|
||||
qb.andWhere('o.project_type = :projectType', { projectType: filters.projectType });
|
||||
}
|
||||
if (filters.priority) {
|
||||
qb.andWhere('o.priority = :priority', { priority: filters.priority });
|
||||
}
|
||||
if (filters.assignedToId) {
|
||||
qb.andWhere('o.assigned_to_id = :assignedToId', { assignedToId: filters.assignedToId });
|
||||
}
|
||||
if (filters.clientName) {
|
||||
qb.andWhere('o.client_name ILIKE :clientName', { clientName: `%${filters.clientName}%` });
|
||||
}
|
||||
if (filters.state) {
|
||||
qb.andWhere('o.state = :state', { state: filters.state });
|
||||
}
|
||||
if (filters.dateFrom) {
|
||||
qb.andWhere('o.identification_date >= :dateFrom', { dateFrom: filters.dateFrom });
|
||||
}
|
||||
if (filters.dateTo) {
|
||||
qb.andWhere('o.identification_date <= :dateTo', { dateTo: filters.dateTo });
|
||||
}
|
||||
if (filters.minValue !== undefined) {
|
||||
qb.andWhere('o.estimated_value >= :minValue', { minValue: filters.minValue });
|
||||
}
|
||||
if (filters.maxValue !== undefined) {
|
||||
qb.andWhere('o.estimated_value <= :maxValue', { maxValue: filters.maxValue });
|
||||
}
|
||||
if (filters.search) {
|
||||
qb.andWhere(
|
||||
'(o.name ILIKE :search OR o.code ILIKE :search OR o.client_name ILIKE :search)',
|
||||
{ search: `%${filters.search}%` }
|
||||
);
|
||||
}
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
qb.orderBy('o.created_at', 'DESC').skip(skip).take(limit);
|
||||
|
||||
const [data, total] = await qb.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar oportunidad
|
||||
*/
|
||||
async update(ctx: ServiceContext, id: string, data: UpdateOpportunityDto): Promise<Opportunity | null> {
|
||||
const opportunity = await this.findById(ctx, id);
|
||||
if (!opportunity) return null;
|
||||
|
||||
// Recalcular weighted value si cambian los factores
|
||||
let weightedValue = opportunity.weightedValue;
|
||||
const estimatedValue = data.estimatedValue ?? opportunity.estimatedValue;
|
||||
const winProbability = data.winProbability ?? opportunity.winProbability;
|
||||
if (estimatedValue && winProbability) {
|
||||
weightedValue = estimatedValue * (winProbability / 100);
|
||||
}
|
||||
|
||||
Object.assign(opportunity, {
|
||||
...data,
|
||||
weightedValue,
|
||||
updatedBy: ctx.userId,
|
||||
});
|
||||
|
||||
return this.repository.save(opportunity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cambiar estado
|
||||
*/
|
||||
async changeStatus(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
status: OpportunityStatus,
|
||||
reason?: string
|
||||
): Promise<Opportunity | null> {
|
||||
const opportunity = await this.findById(ctx, id);
|
||||
if (!opportunity) return null;
|
||||
|
||||
opportunity.status = status;
|
||||
if (status === 'lost' && reason) {
|
||||
opportunity.lossReason = reason;
|
||||
} else if (status === 'won' && reason) {
|
||||
opportunity.winFactors = reason;
|
||||
}
|
||||
opportunity.updatedBy = ctx.userId;
|
||||
|
||||
return this.repository.save(opportunity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener pipeline (agrupado por status)
|
||||
*/
|
||||
async getPipeline(ctx: ServiceContext): Promise<PipelineData[]> {
|
||||
const result = await this.repository
|
||||
.createQueryBuilder('o')
|
||||
.select('o.status', 'status')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.addSelect('SUM(o.estimated_value)', 'totalValue')
|
||||
.addSelect('SUM(o.weighted_value)', 'weightedValue')
|
||||
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('o.deleted_at IS NULL')
|
||||
.groupBy('o.status')
|
||||
.getRawMany();
|
||||
|
||||
return result.map((r) => ({
|
||||
status: r.status as OpportunityStatus,
|
||||
count: parseInt(r.count),
|
||||
totalValue: parseFloat(r.totalValue) || 0,
|
||||
weightedValue: parseFloat(r.weightedValue) || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener oportunidades próximas a vencer
|
||||
*/
|
||||
async getUpcomingDeadlines(ctx: ServiceContext, days = 7): Promise<Opportunity[]> {
|
||||
const now = new Date();
|
||||
const future = new Date();
|
||||
future.setDate(future.getDate() + days);
|
||||
|
||||
return this.repository.find({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
deletedAt: undefined,
|
||||
status: In(['identified', 'qualified', 'pursuing']),
|
||||
deadlineDate: Between(now, future),
|
||||
},
|
||||
relations: ['assignedTo'],
|
||||
order: { deadlineDate: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener estadísticas
|
||||
*/
|
||||
async getStats(ctx: ServiceContext, year?: number): Promise<OpportunityStats> {
|
||||
const currentYear = year || new Date().getFullYear();
|
||||
const startDate = new Date(currentYear, 0, 1);
|
||||
const endDate = new Date(currentYear, 11, 31);
|
||||
|
||||
const baseQuery = this.repository
|
||||
.createQueryBuilder('o')
|
||||
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('o.deleted_at IS NULL')
|
||||
.andWhere('o.identification_date BETWEEN :startDate AND :endDate', { startDate, endDate });
|
||||
|
||||
const total = await baseQuery.getCount();
|
||||
|
||||
const byStatus = await this.repository
|
||||
.createQueryBuilder('o')
|
||||
.select('o.status', 'status')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('o.deleted_at IS NULL')
|
||||
.andWhere('o.identification_date BETWEEN :startDate AND :endDate', { startDate, endDate })
|
||||
.groupBy('o.status')
|
||||
.getRawMany();
|
||||
|
||||
const bySource = await this.repository
|
||||
.createQueryBuilder('o')
|
||||
.select('o.source', 'source')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('o.deleted_at IS NULL')
|
||||
.andWhere('o.identification_date BETWEEN :startDate AND :endDate', { startDate, endDate })
|
||||
.groupBy('o.source')
|
||||
.getRawMany();
|
||||
|
||||
const valueStats = await this.repository
|
||||
.createQueryBuilder('o')
|
||||
.select('SUM(o.estimated_value)', 'totalValue')
|
||||
.addSelect('SUM(o.weighted_value)', 'weightedValue')
|
||||
.addSelect('AVG(o.estimated_value)', 'avgValue')
|
||||
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('o.deleted_at IS NULL')
|
||||
.andWhere('o.identification_date BETWEEN :startDate AND :endDate', { startDate, endDate })
|
||||
.getRawOne();
|
||||
|
||||
const wonCount = byStatus.find((s) => s.status === 'won')?.count || 0;
|
||||
const lostCount = byStatus.find((s) => s.status === 'lost')?.count || 0;
|
||||
const closedCount = parseInt(wonCount) + parseInt(lostCount);
|
||||
|
||||
return {
|
||||
year: currentYear,
|
||||
total,
|
||||
byStatus: byStatus.map((r) => ({ status: r.status, count: parseInt(r.count) })),
|
||||
bySource: bySource.map((r) => ({ source: r.source, count: parseInt(r.count) })),
|
||||
totalValue: parseFloat(valueStats?.totalValue) || 0,
|
||||
weightedValue: parseFloat(valueStats?.weightedValue) || 0,
|
||||
avgValue: parseFloat(valueStats?.avgValue) || 0,
|
||||
winRate: closedCount > 0 ? (parseInt(wonCount) / closedCount) * 100 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete
|
||||
*/
|
||||
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
|
||||
const result = await this.repository.update(
|
||||
{ id, tenantId: ctx.tenantId },
|
||||
{ deletedAt: new Date(), updatedBy: ctx.userId }
|
||||
);
|
||||
return (result.affected || 0) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
export interface PipelineData {
|
||||
status: OpportunityStatus;
|
||||
count: number;
|
||||
totalValue: number;
|
||||
weightedValue: number;
|
||||
}
|
||||
|
||||
export interface OpportunityStats {
|
||||
year: number;
|
||||
total: number;
|
||||
byStatus: { status: OpportunityStatus; count: number }[];
|
||||
bySource: { source: OpportunitySource; count: number }[];
|
||||
totalValue: number;
|
||||
weightedValue: number;
|
||||
avgValue: number;
|
||||
winRate: number;
|
||||
}
|
||||
252
src/modules/budgets/controllers/concepto.controller.ts
Normal file
252
src/modules/budgets/controllers/concepto.controller.ts
Normal file
@ -0,0 +1,252 @@
|
||||
/**
|
||||
* ConceptoController - Controller de conceptos de obra
|
||||
*
|
||||
* Endpoints REST para gestión del catálogo de conceptos.
|
||||
*
|
||||
* @module Budgets
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { ConceptoService, CreateConceptoDto, UpdateConceptoDto } from '../services/concepto.service';
|
||||
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
|
||||
import { AuthService } from '../../auth/services/auth.service';
|
||||
import { Concepto } from '../entities/concepto.entity';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
import { Tenant } from '../../core/entities/tenant.entity';
|
||||
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
|
||||
import { ServiceContext } from '../../../shared/services/base.service';
|
||||
|
||||
/**
|
||||
* Crear router de conceptos
|
||||
*/
|
||||
export function createConceptoController(dataSource: DataSource): Router {
|
||||
const router = Router();
|
||||
|
||||
// Repositorios
|
||||
const conceptoRepository = dataSource.getRepository(Concepto);
|
||||
const userRepository = dataSource.getRepository(User);
|
||||
const tenantRepository = dataSource.getRepository(Tenant);
|
||||
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
|
||||
|
||||
// Servicios
|
||||
const conceptoService = new ConceptoService(conceptoRepository);
|
||||
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
|
||||
const authMiddleware = new AuthMiddleware(authService, dataSource);
|
||||
|
||||
// Helper para crear contexto de servicio
|
||||
const getContext = (req: Request): ServiceContext => {
|
||||
if (!req.tenantId) {
|
||||
throw new Error('Tenant ID is required');
|
||||
}
|
||||
return {
|
||||
tenantId: req.tenantId,
|
||||
userId: req.user?.sub,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /conceptos
|
||||
* Listar conceptos raíz
|
||||
*/
|
||||
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
||||
|
||||
const result = await conceptoService.findRootConceptos(getContext(req), page, limit);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: result.meta,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /conceptos/search
|
||||
* Buscar conceptos por código o nombre
|
||||
*/
|
||||
router.get('/search', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const term = req.query.q as string;
|
||||
if (!term || term.length < 2) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Search term must be at least 2 characters' });
|
||||
return;
|
||||
}
|
||||
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
|
||||
const conceptos = await conceptoService.search(getContext(req), term, limit);
|
||||
|
||||
res.status(200).json({ success: true, data: conceptos });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /conceptos/tree
|
||||
* Obtener árbol de conceptos
|
||||
*/
|
||||
router.get('/tree', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const rootId = req.query.rootId as string;
|
||||
const tree = await conceptoService.getConceptoTree(getContext(req), rootId);
|
||||
|
||||
res.status(200).json({ success: true, data: tree });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /conceptos/:id
|
||||
* Obtener concepto por ID
|
||||
*/
|
||||
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const concepto = await conceptoService.findById(getContext(req), req.params.id);
|
||||
if (!concepto) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Concept not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: concepto });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /conceptos/:id/children
|
||||
* Obtener hijos de un concepto
|
||||
*/
|
||||
router.get('/:id/children', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const children = await conceptoService.findChildren(getContext(req), req.params.id);
|
||||
res.status(200).json({ success: true, data: children });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /conceptos
|
||||
* Crear concepto
|
||||
*/
|
||||
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dto: CreateConceptoDto = req.body;
|
||||
|
||||
if (!dto.code || !dto.name) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'code and name are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Verificar código único
|
||||
const exists = await conceptoService.codeExists(getContext(req), dto.code);
|
||||
if (exists) {
|
||||
res.status(409).json({ error: 'Conflict', message: 'Concept code already exists' });
|
||||
return;
|
||||
}
|
||||
|
||||
const concepto = await conceptoService.createConcepto(getContext(req), dto);
|
||||
res.status(201).json({ success: true, data: concepto });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /conceptos/:id
|
||||
* Actualizar concepto
|
||||
*/
|
||||
router.patch('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dto: UpdateConceptoDto = req.body;
|
||||
const concepto = await conceptoService.update(getContext(req), req.params.id, dto);
|
||||
|
||||
if (!concepto) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Concept not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: concepto });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /conceptos/:id
|
||||
* Eliminar concepto (soft delete)
|
||||
*/
|
||||
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = await conceptoService.softDelete(getContext(req), req.params.id);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Concept not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, message: 'Concept deleted' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
export default createConceptoController;
|
||||
7
src/modules/budgets/controllers/index.ts
Normal file
7
src/modules/budgets/controllers/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Budgets Controllers Index
|
||||
* @module Budgets
|
||||
*/
|
||||
|
||||
export { createConceptoController } from './concepto.controller';
|
||||
export { createPresupuestoController } from './presupuesto.controller';
|
||||
287
src/modules/budgets/controllers/presupuesto.controller.ts
Normal file
287
src/modules/budgets/controllers/presupuesto.controller.ts
Normal file
@ -0,0 +1,287 @@
|
||||
/**
|
||||
* PresupuestoController - Controller de presupuestos
|
||||
*
|
||||
* Endpoints REST para gestión de presupuestos de obra.
|
||||
*
|
||||
* @module Budgets
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { PresupuestoService, CreatePresupuestoDto, AddPartidaDto, UpdatePartidaDto } from '../services/presupuesto.service';
|
||||
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
|
||||
import { AuthService } from '../../auth/services/auth.service';
|
||||
import { Presupuesto } from '../entities/presupuesto.entity';
|
||||
import { PresupuestoPartida } from '../entities/presupuesto-partida.entity';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
import { Tenant } from '../../core/entities/tenant.entity';
|
||||
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
|
||||
import { ServiceContext } from '../../../shared/services/base.service';
|
||||
|
||||
/**
|
||||
* Crear router de presupuestos
|
||||
*/
|
||||
export function createPresupuestoController(dataSource: DataSource): Router {
|
||||
const router = Router();
|
||||
|
||||
// Repositorios
|
||||
const presupuestoRepository = dataSource.getRepository(Presupuesto);
|
||||
const partidaRepository = dataSource.getRepository(PresupuestoPartida);
|
||||
const userRepository = dataSource.getRepository(User);
|
||||
const tenantRepository = dataSource.getRepository(Tenant);
|
||||
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
|
||||
|
||||
// Servicios
|
||||
const presupuestoService = new PresupuestoService(presupuestoRepository, partidaRepository);
|
||||
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
|
||||
const authMiddleware = new AuthMiddleware(authService, dataSource);
|
||||
|
||||
// Helper para crear contexto de servicio
|
||||
const getContext = (req: Request): ServiceContext => {
|
||||
if (!req.tenantId) {
|
||||
throw new Error('Tenant ID is required');
|
||||
}
|
||||
return {
|
||||
tenantId: req.tenantId,
|
||||
userId: req.user?.sub,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /presupuestos
|
||||
* Listar presupuestos
|
||||
*/
|
||||
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||
const fraccionamientoId = req.query.fraccionamientoId as string;
|
||||
|
||||
let result;
|
||||
if (fraccionamientoId) {
|
||||
result = await presupuestoService.findByFraccionamiento(getContext(req), fraccionamientoId, page, limit);
|
||||
} else {
|
||||
result = await presupuestoService.findAll(getContext(req), { page, limit });
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: result.meta,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /presupuestos/:id
|
||||
* Obtener presupuesto por ID con sus partidas
|
||||
*/
|
||||
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const presupuesto = await presupuestoService.findWithPartidas(getContext(req), req.params.id);
|
||||
if (!presupuesto) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Budget not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: presupuesto });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /presupuestos
|
||||
* Crear presupuesto
|
||||
*/
|
||||
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dto: CreatePresupuestoDto = req.body;
|
||||
|
||||
if (!dto.code || !dto.name) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'code and name are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const presupuesto = await presupuestoService.createPresupuesto(getContext(req), dto);
|
||||
res.status(201).json({ success: true, data: presupuesto });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /presupuestos/:id/partidas
|
||||
* Agregar partida al presupuesto
|
||||
*/
|
||||
router.post('/:id/partidas', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dto: AddPartidaDto = req.body;
|
||||
|
||||
if (!dto.conceptoId || dto.quantity === undefined || dto.unitPrice === undefined) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'conceptoId, quantity and unitPrice are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const partida = await presupuestoService.addPartida(getContext(req), req.params.id, dto);
|
||||
res.status(201).json({ success: true, data: partida });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Presupuesto not found') {
|
||||
res.status(404).json({ error: 'Not Found', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /presupuestos/:id/partidas/:partidaId
|
||||
* Actualizar partida
|
||||
*/
|
||||
router.patch('/:id/partidas/:partidaId', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dto: UpdatePartidaDto = req.body;
|
||||
const partida = await presupuestoService.updatePartida(getContext(req), req.params.partidaId, dto);
|
||||
|
||||
if (!partida) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Budget item not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: partida });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /presupuestos/:id/partidas/:partidaId
|
||||
* Eliminar partida
|
||||
*/
|
||||
router.delete('/:id/partidas/:partidaId', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = await presupuestoService.removePartida(getContext(req), req.params.partidaId);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Budget item not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, message: 'Budget item deleted' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /presupuestos/:id/version
|
||||
* Crear nueva versión del presupuesto
|
||||
*/
|
||||
router.post('/:id/version', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const newVersion = await presupuestoService.createNewVersion(getContext(req), req.params.id);
|
||||
res.status(201).json({ success: true, data: newVersion });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Presupuesto not found') {
|
||||
res.status(404).json({ error: 'Not Found', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /presupuestos/:id/approve
|
||||
* Aprobar presupuesto
|
||||
*/
|
||||
router.post('/:id/approve', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const presupuesto = await presupuestoService.approve(getContext(req), req.params.id);
|
||||
if (!presupuesto) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Budget not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: presupuesto, message: 'Budget approved' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /presupuestos/:id
|
||||
* Eliminar presupuesto (soft delete)
|
||||
*/
|
||||
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = await presupuestoService.softDelete(getContext(req), req.params.id);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Budget not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, message: 'Budget deleted' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
export default createPresupuestoController;
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
181
src/modules/construction/controllers/etapa.controller.ts
Normal file
181
src/modules/construction/controllers/etapa.controller.ts
Normal file
@ -0,0 +1,181 @@
|
||||
/**
|
||||
* EtapaController - Controller de etapas
|
||||
*
|
||||
* Endpoints REST para gestión de etapas de fraccionamientos.
|
||||
*
|
||||
* @module Construction
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { EtapaService, CreateEtapaDto, UpdateEtapaDto } from '../services/etapa.service';
|
||||
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
|
||||
import { AuthService } from '../../auth/services/auth.service';
|
||||
import { Etapa } from '../entities/etapa.entity';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
import { Tenant } from '../../core/entities/tenant.entity';
|
||||
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
|
||||
|
||||
/**
|
||||
* Crear router de etapas
|
||||
*/
|
||||
export function createEtapaController(dataSource: DataSource): Router {
|
||||
const router = Router();
|
||||
|
||||
// Repositorios
|
||||
const etapaRepository = dataSource.getRepository(Etapa);
|
||||
const userRepository = dataSource.getRepository(User);
|
||||
const tenantRepository = dataSource.getRepository(Tenant);
|
||||
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
|
||||
|
||||
// Servicios
|
||||
const etapaService = new EtapaService(etapaRepository);
|
||||
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
|
||||
const authMiddleware = new AuthMiddleware(authService, dataSource);
|
||||
|
||||
/**
|
||||
* GET /etapas
|
||||
* Listar etapas
|
||||
*/
|
||||
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||
const search = req.query.search as string;
|
||||
const status = req.query.status as string;
|
||||
const fraccionamientoId = req.query.fraccionamientoId as string;
|
||||
|
||||
const result = await etapaService.findAll({ tenantId, page, limit, search, status, fraccionamientoId });
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.items,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: result.total,
|
||||
totalPages: Math.ceil(result.total / limit),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /etapas/:id
|
||||
* Obtener etapa por ID
|
||||
*/
|
||||
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const etapa = await etapaService.findById(req.params.id, tenantId);
|
||||
if (!etapa) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Stage not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: etapa });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /etapas
|
||||
* Crear etapa
|
||||
*/
|
||||
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dto: CreateEtapaDto = req.body;
|
||||
|
||||
if (!dto.fraccionamientoId || !dto.code || !dto.name) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'fraccionamientoId, code and name are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const etapa = await etapaService.create(tenantId, dto, req.user?.sub);
|
||||
res.status(201).json({ success: true, data: etapa });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('already exists')) {
|
||||
res.status(409).json({ error: 'Conflict', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /etapas/:id
|
||||
* Actualizar etapa
|
||||
*/
|
||||
router.patch('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dto: UpdateEtapaDto = req.body;
|
||||
const etapa = await etapaService.update(req.params.id, tenantId, dto, req.user?.sub);
|
||||
res.status(200).json({ success: true, data: etapa });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === 'Stage not found') {
|
||||
res.status(404).json({ error: 'Not Found', message: error.message });
|
||||
return;
|
||||
}
|
||||
if (error.message.includes('already exists')) {
|
||||
res.status(409).json({ error: 'Conflict', message: error.message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /etapas/:id
|
||||
* Eliminar etapa
|
||||
*/
|
||||
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
await etapaService.delete(req.params.id, tenantId, req.user?.sub);
|
||||
res.status(200).json({ success: true, message: 'Stage deleted' });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Stage not found') {
|
||||
res.status(404).json({ error: 'Not Found', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
export default createEtapaController;
|
||||
@ -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;
|
||||
11
src/modules/construction/controllers/index.ts
Normal file
11
src/modules/construction/controllers/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Construction Controllers Index
|
||||
* @module Construction
|
||||
*/
|
||||
|
||||
export { default as proyectoController } from './proyecto.controller';
|
||||
export { default as fraccionamientoController } from './fraccionamiento.controller';
|
||||
export { createEtapaController } from './etapa.controller';
|
||||
export { createManzanaController } from './manzana.controller';
|
||||
export { createLoteController } from './lote.controller';
|
||||
export { createPrototipoController } from './prototipo.controller';
|
||||
273
src/modules/construction/controllers/lote.controller.ts
Normal file
273
src/modules/construction/controllers/lote.controller.ts
Normal file
@ -0,0 +1,273 @@
|
||||
/**
|
||||
* LoteController - Controller de lotes
|
||||
*
|
||||
* Endpoints REST para gestión de lotes/terrenos.
|
||||
*
|
||||
* @module Construction
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { LoteService, CreateLoteDto, UpdateLoteDto } from '../services/lote.service';
|
||||
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
|
||||
import { AuthService } from '../../auth/services/auth.service';
|
||||
import { Lote } from '../entities/lote.entity';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
import { Tenant } from '../../core/entities/tenant.entity';
|
||||
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
|
||||
|
||||
/**
|
||||
* Crear router de lotes
|
||||
*/
|
||||
export function createLoteController(dataSource: DataSource): Router {
|
||||
const router = Router();
|
||||
|
||||
// Repositorios
|
||||
const loteRepository = dataSource.getRepository(Lote);
|
||||
const userRepository = dataSource.getRepository(User);
|
||||
const tenantRepository = dataSource.getRepository(Tenant);
|
||||
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
|
||||
|
||||
// Servicios
|
||||
const loteService = new LoteService(loteRepository);
|
||||
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
|
||||
const authMiddleware = new AuthMiddleware(authService, dataSource);
|
||||
|
||||
/**
|
||||
* GET /lotes
|
||||
* Listar lotes
|
||||
*/
|
||||
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||
const search = req.query.search as string;
|
||||
const status = req.query.status as string;
|
||||
const manzanaId = req.query.manzanaId as string;
|
||||
const prototipoId = req.query.prototipoId as string;
|
||||
|
||||
const result = await loteService.findAll({ tenantId, page, limit, search, status, manzanaId, prototipoId });
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.items,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: result.total,
|
||||
totalPages: Math.ceil(result.total / limit),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /lotes/stats
|
||||
* Estadísticas de lotes por estado
|
||||
*/
|
||||
router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const manzanaId = req.query.manzanaId as string;
|
||||
const stats = await loteService.getStatsByStatus(tenantId, manzanaId);
|
||||
|
||||
res.status(200).json({ success: true, data: stats });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /lotes/:id
|
||||
* Obtener lote por ID
|
||||
*/
|
||||
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const lote = await loteService.findById(req.params.id, tenantId);
|
||||
if (!lote) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Lot not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: lote });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /lotes
|
||||
* Crear lote
|
||||
*/
|
||||
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dto: CreateLoteDto = req.body;
|
||||
|
||||
if (!dto.manzanaId || !dto.code) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'manzanaId and code are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const lote = await loteService.create(tenantId, dto, req.user?.sub);
|
||||
res.status(201).json({ success: true, data: lote });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('already exists')) {
|
||||
res.status(409).json({ error: 'Conflict', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /lotes/:id
|
||||
* Actualizar lote
|
||||
*/
|
||||
router.patch('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dto: UpdateLoteDto = req.body;
|
||||
const lote = await loteService.update(req.params.id, tenantId, dto, req.user?.sub);
|
||||
res.status(200).json({ success: true, data: lote });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === 'Lot not found') {
|
||||
res.status(404).json({ error: 'Not Found', message: error.message });
|
||||
return;
|
||||
}
|
||||
if (error.message.includes('already exists')) {
|
||||
res.status(409).json({ error: 'Conflict', message: error.message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /lotes/:id/prototipo
|
||||
* Asignar prototipo a lote
|
||||
*/
|
||||
router.patch('/:id/prototipo', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { prototipoId } = req.body;
|
||||
if (!prototipoId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'prototipoId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const lote = await loteService.assignPrototipo(req.params.id, tenantId, prototipoId, req.user?.sub);
|
||||
res.status(200).json({ success: true, data: lote });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Lot not found') {
|
||||
res.status(404).json({ error: 'Not Found', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /lotes/:id/status
|
||||
* Cambiar estado del lote
|
||||
*/
|
||||
router.patch('/:id/status', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'finance'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { status } = req.body;
|
||||
if (!status) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'status is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const validStatuses = ['available', 'reserved', 'sold', 'blocked', 'in_construction'];
|
||||
if (!validStatuses.includes(status)) {
|
||||
res.status(400).json({ error: 'Bad Request', message: `Invalid status. Must be one of: ${validStatuses.join(', ')}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const lote = await loteService.changeStatus(req.params.id, tenantId, status, req.user?.sub);
|
||||
res.status(200).json({ success: true, data: lote });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Lot not found') {
|
||||
res.status(404).json({ error: 'Not Found', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /lotes/:id
|
||||
* Eliminar lote
|
||||
*/
|
||||
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
await loteService.delete(req.params.id, tenantId, req.user?.sub);
|
||||
res.status(200).json({ success: true, message: 'Lot deleted' });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === 'Lot not found') {
|
||||
res.status(404).json({ error: 'Not Found', message: error.message });
|
||||
return;
|
||||
}
|
||||
if (error.message === 'Cannot delete a sold lot') {
|
||||
res.status(400).json({ error: 'Bad Request', message: error.message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
export default createLoteController;
|
||||
180
src/modules/construction/controllers/manzana.controller.ts
Normal file
180
src/modules/construction/controllers/manzana.controller.ts
Normal file
@ -0,0 +1,180 @@
|
||||
/**
|
||||
* ManzanaController - Controller de manzanas
|
||||
*
|
||||
* Endpoints REST para gestión de manzanas (bloques).
|
||||
*
|
||||
* @module Construction
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { ManzanaService, CreateManzanaDto, UpdateManzanaDto } from '../services/manzana.service';
|
||||
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
|
||||
import { AuthService } from '../../auth/services/auth.service';
|
||||
import { Manzana } from '../entities/manzana.entity';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
import { Tenant } from '../../core/entities/tenant.entity';
|
||||
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
|
||||
|
||||
/**
|
||||
* Crear router de manzanas
|
||||
*/
|
||||
export function createManzanaController(dataSource: DataSource): Router {
|
||||
const router = Router();
|
||||
|
||||
// Repositorios
|
||||
const manzanaRepository = dataSource.getRepository(Manzana);
|
||||
const userRepository = dataSource.getRepository(User);
|
||||
const tenantRepository = dataSource.getRepository(Tenant);
|
||||
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
|
||||
|
||||
// Servicios
|
||||
const manzanaService = new ManzanaService(manzanaRepository);
|
||||
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
|
||||
const authMiddleware = new AuthMiddleware(authService, dataSource);
|
||||
|
||||
/**
|
||||
* GET /manzanas
|
||||
* Listar manzanas
|
||||
*/
|
||||
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||
const search = req.query.search as string;
|
||||
const etapaId = req.query.etapaId as string;
|
||||
|
||||
const result = await manzanaService.findAll({ tenantId, page, limit, search, etapaId });
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.items,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: result.total,
|
||||
totalPages: Math.ceil(result.total / limit),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /manzanas/:id
|
||||
* Obtener manzana por ID
|
||||
*/
|
||||
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const manzana = await manzanaService.findById(req.params.id, tenantId);
|
||||
if (!manzana) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Block not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: manzana });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /manzanas
|
||||
* Crear manzana
|
||||
*/
|
||||
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dto: CreateManzanaDto = req.body;
|
||||
|
||||
if (!dto.etapaId || !dto.code) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'etapaId and code are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const manzana = await manzanaService.create(tenantId, dto, req.user?.sub);
|
||||
res.status(201).json({ success: true, data: manzana });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('already exists')) {
|
||||
res.status(409).json({ error: 'Conflict', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /manzanas/:id
|
||||
* Actualizar manzana
|
||||
*/
|
||||
router.patch('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dto: UpdateManzanaDto = req.body;
|
||||
const manzana = await manzanaService.update(req.params.id, tenantId, dto, req.user?.sub);
|
||||
res.status(200).json({ success: true, data: manzana });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === 'Block not found') {
|
||||
res.status(404).json({ error: 'Not Found', message: error.message });
|
||||
return;
|
||||
}
|
||||
if (error.message.includes('already exists')) {
|
||||
res.status(409).json({ error: 'Conflict', message: error.message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /manzanas/:id
|
||||
* Eliminar manzana
|
||||
*/
|
||||
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
await manzanaService.delete(req.params.id, tenantId, req.user?.sub);
|
||||
res.status(200).json({ success: true, message: 'Block deleted' });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Block not found') {
|
||||
res.status(404).json({ error: 'Not Found', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
export default createManzanaController;
|
||||
181
src/modules/construction/controllers/prototipo.controller.ts
Normal file
181
src/modules/construction/controllers/prototipo.controller.ts
Normal file
@ -0,0 +1,181 @@
|
||||
/**
|
||||
* PrototipoController - Controller de prototipos
|
||||
*
|
||||
* Endpoints REST para gestión de prototipos de vivienda.
|
||||
*
|
||||
* @module Construction
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { PrototipoService, CreatePrototipoDto, UpdatePrototipoDto } from '../services/prototipo.service';
|
||||
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
|
||||
import { AuthService } from '../../auth/services/auth.service';
|
||||
import { Prototipo } from '../entities/prototipo.entity';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
import { Tenant } from '../../core/entities/tenant.entity';
|
||||
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
|
||||
|
||||
/**
|
||||
* Crear router de prototipos
|
||||
*/
|
||||
export function createPrototipoController(dataSource: DataSource): Router {
|
||||
const router = Router();
|
||||
|
||||
// Repositorios
|
||||
const prototipoRepository = dataSource.getRepository(Prototipo);
|
||||
const userRepository = dataSource.getRepository(User);
|
||||
const tenantRepository = dataSource.getRepository(Tenant);
|
||||
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
|
||||
|
||||
// Servicios
|
||||
const prototipoService = new PrototipoService(prototipoRepository);
|
||||
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
|
||||
const authMiddleware = new AuthMiddleware(authService, dataSource);
|
||||
|
||||
/**
|
||||
* GET /prototipos
|
||||
* Listar prototipos
|
||||
*/
|
||||
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||
const search = req.query.search as string;
|
||||
const type = req.query.type as string;
|
||||
const isActive = req.query.isActive === 'true' ? true : req.query.isActive === 'false' ? false : undefined;
|
||||
|
||||
const result = await prototipoService.findAll({ tenantId, page, limit, search, type, isActive });
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.items,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: result.total,
|
||||
totalPages: Math.ceil(result.total / limit),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /prototipos/:id
|
||||
* Obtener prototipo por ID
|
||||
*/
|
||||
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const prototipo = await prototipoService.findById(req.params.id, tenantId);
|
||||
if (!prototipo) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Prototype not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: prototipo });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /prototipos
|
||||
* Crear prototipo
|
||||
*/
|
||||
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dto: CreatePrototipoDto = req.body;
|
||||
|
||||
if (!dto.code || !dto.name) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Code and name are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const prototipo = await prototipoService.create(tenantId, dto, req.user?.sub);
|
||||
res.status(201).json({ success: true, data: prototipo });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Prototype code already exists') {
|
||||
res.status(409).json({ error: 'Conflict', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /prototipos/:id
|
||||
* Actualizar prototipo
|
||||
*/
|
||||
router.patch('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dto: UpdatePrototipoDto = req.body;
|
||||
const prototipo = await prototipoService.update(req.params.id, tenantId, dto, req.user?.sub);
|
||||
res.status(200).json({ success: true, data: prototipo });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === 'Prototype not found') {
|
||||
res.status(404).json({ error: 'Not Found', message: error.message });
|
||||
return;
|
||||
}
|
||||
if (error.message === 'Prototype code already exists') {
|
||||
res.status(409).json({ error: 'Conflict', message: error.message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /prototipos/:id
|
||||
* Eliminar prototipo
|
||||
*/
|
||||
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
await prototipoService.delete(req.params.id, tenantId, req.user?.sub);
|
||||
res.status(200).json({ success: true, message: 'Prototype deleted' });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Prototype not found') {
|
||||
res.status(404).json({ error: 'Not Found', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
export default createPrototipoController;
|
||||
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;
|
||||
83
src/modules/construction/entities/etapa.entity.ts
Normal file
83
src/modules/construction/entities/etapa.entity.ts
Normal file
@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Etapa Entity
|
||||
* Etapas/Fases de un fraccionamiento
|
||||
*
|
||||
* @module Construction
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Fraccionamiento } from './fraccionamiento.entity';
|
||||
import { Manzana } from './manzana.entity';
|
||||
|
||||
@Entity({ schema: 'construction', name: 'etapas' })
|
||||
@Index(['fraccionamientoId', 'code'], { unique: true })
|
||||
export class Etapa {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'fraccionamiento_id', type: 'uuid' })
|
||||
fraccionamientoId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20 })
|
||||
code: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({ type: 'integer', default: 1 })
|
||||
sequence: number;
|
||||
|
||||
@Column({ name: 'total_lots', type: 'integer', default: 0 })
|
||||
totalLots: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, default: 'draft' })
|
||||
status: string;
|
||||
|
||||
@Column({ name: 'start_date', type: 'date', nullable: true })
|
||||
startDate: Date;
|
||||
|
||||
@Column({ name: 'expected_end_date', type: 'date', nullable: true })
|
||||
expectedEndDate: Date;
|
||||
|
||||
@Column({ name: 'actual_end_date', type: 'date', nullable: true })
|
||||
actualEndDate: Date;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy: string;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true })
|
||||
updatedAt: Date;
|
||||
|
||||
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||
updatedBy: string;
|
||||
|
||||
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||
deletedAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => Fraccionamiento, (f) => f.etapas, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'fraccionamiento_id' })
|
||||
fraccionamiento: Fraccionamiento;
|
||||
|
||||
@OneToMany(() => Manzana, (m) => m.etapa)
|
||||
manzanas: Manzana[];
|
||||
}
|
||||
95
src/modules/construction/entities/fraccionamiento.entity.ts
Normal file
95
src/modules/construction/entities/fraccionamiento.entity.ts
Normal file
@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Fraccionamiento Entity
|
||||
* Obras/fraccionamientos dentro de un proyecto
|
||||
*
|
||||
* @module Construction
|
||||
* @table construction.fraccionamientos
|
||||
* @ddl schemas/01-construction-schema-ddl.sql
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Tenant } from '../../core/entities/tenant.entity';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
import { Proyecto } from './proyecto.entity';
|
||||
import { Etapa } from './etapa.entity';
|
||||
|
||||
export type EstadoFraccionamiento = 'activo' | 'pausado' | 'completado' | 'cancelado';
|
||||
|
||||
@Entity({ schema: 'construction', name: 'fraccionamientos' })
|
||||
@Index(['tenantId', 'codigo'], { unique: true })
|
||||
export class Fraccionamiento {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'proyecto_id', type: 'uuid' })
|
||||
proyectoId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20 })
|
||||
codigo: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 200 })
|
||||
nombre: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
descripcion: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
direccion: string;
|
||||
|
||||
// PostGIS geometry - stored as GeoJSON for TypeORM compatibility
|
||||
@Column({
|
||||
name: 'ubicacion_geo',
|
||||
type: 'geometry',
|
||||
spatialFeatureType: 'Point',
|
||||
srid: 4326,
|
||||
nullable: true
|
||||
})
|
||||
ubicacionGeo: string;
|
||||
|
||||
@Column({ name: 'fecha_inicio', type: 'date', nullable: true })
|
||||
fechaInicio: Date;
|
||||
|
||||
@Column({ name: 'fecha_fin_estimada', type: 'date', nullable: true })
|
||||
fechaFinEstimada: Date;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'activo' })
|
||||
estado: EstadoFraccionamiento;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdById: string;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => Tenant)
|
||||
@JoinColumn({ name: 'tenant_id' })
|
||||
tenant: Tenant;
|
||||
|
||||
@ManyToOne(() => Proyecto, (p) => p.fraccionamientos)
|
||||
@JoinColumn({ name: 'proyecto_id' })
|
||||
proyecto: Proyecto;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'created_by' })
|
||||
createdBy: User;
|
||||
|
||||
@OneToMany(() => Etapa, (e) => e.fraccionamiento)
|
||||
etapas: Etapa[];
|
||||
}
|
||||
11
src/modules/construction/entities/index.ts
Normal file
11
src/modules/construction/entities/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Construction Entities Index
|
||||
* @module Construction
|
||||
*/
|
||||
|
||||
export { Proyecto } from './proyecto.entity';
|
||||
export { Fraccionamiento } from './fraccionamiento.entity';
|
||||
export { Etapa } from './etapa.entity';
|
||||
export { Manzana } from './manzana.entity';
|
||||
export { Lote } from './lote.entity';
|
||||
export { Prototipo } from './prototipo.entity';
|
||||
92
src/modules/construction/entities/lote.entity.ts
Normal file
92
src/modules/construction/entities/lote.entity.ts
Normal file
@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Lote Entity
|
||||
* Lotes/Terrenos individuales
|
||||
*
|
||||
* @module Construction
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Manzana } from './manzana.entity';
|
||||
import { Prototipo } from './prototipo.entity';
|
||||
|
||||
@Entity({ schema: 'construction', name: 'lotes' })
|
||||
@Index(['manzanaId', 'code'], { unique: true })
|
||||
export class Lote {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'manzana_id', type: 'uuid' })
|
||||
manzanaId: string;
|
||||
|
||||
@Column({ name: 'prototipo_id', type: 'uuid', nullable: true })
|
||||
prototipoId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 30 })
|
||||
code: string;
|
||||
|
||||
@Column({ name: 'official_number', type: 'varchar', length: 50, nullable: true })
|
||||
officialNumber: string;
|
||||
|
||||
@Column({ name: 'area_m2', type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||
areaM2: number;
|
||||
|
||||
@Column({ name: 'front_m', type: 'decimal', precision: 8, scale: 2, nullable: true })
|
||||
frontM: number;
|
||||
|
||||
@Column({ name: 'depth_m', type: 'decimal', precision: 8, scale: 2, nullable: true })
|
||||
depthM: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, default: 'available' })
|
||||
status: string;
|
||||
|
||||
@Column({ name: 'price_base', type: 'decimal', precision: 14, scale: 2, nullable: true })
|
||||
priceBase: number;
|
||||
|
||||
@Column({ name: 'price_final', type: 'decimal', precision: 14, scale: 2, nullable: true })
|
||||
priceFinal: number;
|
||||
|
||||
@Column({ name: 'buyer_id', type: 'uuid', nullable: true })
|
||||
buyerId: string;
|
||||
|
||||
@Column({ name: 'sale_date', type: 'date', nullable: true })
|
||||
saleDate: Date;
|
||||
|
||||
@Column({ name: 'delivery_date', type: 'date', nullable: true })
|
||||
deliveryDate: Date;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy: string;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true })
|
||||
updatedAt: Date;
|
||||
|
||||
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||
updatedBy: string;
|
||||
|
||||
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||
deletedAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => Manzana, (m) => m.lotes, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'manzana_id' })
|
||||
manzana: Manzana;
|
||||
|
||||
@ManyToOne(() => Prototipo)
|
||||
@JoinColumn({ name: 'prototipo_id' })
|
||||
prototipo: Prototipo;
|
||||
}
|
||||
65
src/modules/construction/entities/manzana.entity.ts
Normal file
65
src/modules/construction/entities/manzana.entity.ts
Normal file
@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Manzana Entity
|
||||
* Manzanas (bloques) dentro de una etapa
|
||||
*
|
||||
* @module Construction
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Etapa } from './etapa.entity';
|
||||
import { Lote } from './lote.entity';
|
||||
|
||||
@Entity({ schema: 'construction', name: 'manzanas' })
|
||||
@Index(['etapaId', 'code'], { unique: true })
|
||||
export class Manzana {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'etapa_id', type: 'uuid' })
|
||||
etapaId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20 })
|
||||
code: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||
name: string;
|
||||
|
||||
@Column({ name: 'total_lots', type: 'integer', default: 0 })
|
||||
totalLots: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy: string;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true })
|
||||
updatedAt: Date;
|
||||
|
||||
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||
updatedBy: string;
|
||||
|
||||
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||
deletedAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => Etapa, (e) => e.manzanas, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'etapa_id' })
|
||||
etapa: Etapa;
|
||||
|
||||
@OneToMany(() => Lote, (l) => l.manzana)
|
||||
lotes: Lote[];
|
||||
}
|
||||
85
src/modules/construction/entities/prototipo.entity.ts
Normal file
85
src/modules/construction/entities/prototipo.entity.ts
Normal file
@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Prototipo Entity
|
||||
* Prototipos de vivienda (modelos)
|
||||
*
|
||||
* @module Construction
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity({ schema: 'construction', name: 'prototipos' })
|
||||
@Index(['tenantId', 'code'], { unique: true })
|
||||
export class Prototipo {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20 })
|
||||
code: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, default: 'horizontal' })
|
||||
type: string;
|
||||
|
||||
@Column({ name: 'area_construction_m2', type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||
areaConstructionM2: number;
|
||||
|
||||
@Column({ name: 'area_terrain_m2', type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||
areaTerrainM2: number;
|
||||
|
||||
@Column({ type: 'integer', default: 0 })
|
||||
bedrooms: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 3, scale: 1, default: 0 })
|
||||
bathrooms: number;
|
||||
|
||||
@Column({ name: 'parking_spaces', type: 'integer', default: 0 })
|
||||
parkingSpaces: number;
|
||||
|
||||
@Column({ type: 'integer', default: 1 })
|
||||
floors: number;
|
||||
|
||||
@Column({ name: 'base_price', type: 'decimal', precision: 14, scale: 2, nullable: true })
|
||||
basePrice: number;
|
||||
|
||||
@Column({ name: 'blueprint_url', type: 'varchar', length: 500, nullable: true })
|
||||
blueprintUrl: string;
|
||||
|
||||
@Column({ name: 'render_url', type: 'varchar', length: 500, nullable: true })
|
||||
renderUrl: string;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@Column({ type: 'jsonb', default: {} })
|
||||
metadata: Record<string, unknown>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy: string;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true })
|
||||
updatedAt: Date;
|
||||
|
||||
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||
updatedBy: string;
|
||||
|
||||
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||
deletedAt: Date;
|
||||
}
|
||||
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[];
|
||||
}
|
||||
163
src/modules/construction/services/etapa.service.ts
Normal file
163
src/modules/construction/services/etapa.service.ts
Normal file
@ -0,0 +1,163 @@
|
||||
/**
|
||||
* EtapaService - Gestión de etapas de fraccionamientos
|
||||
*
|
||||
* CRUD de etapas con soporte multi-tenant.
|
||||
*
|
||||
* @module Construction
|
||||
*/
|
||||
|
||||
import { Repository, IsNull } from 'typeorm';
|
||||
import { Etapa } from '../entities/etapa.entity';
|
||||
|
||||
export interface CreateEtapaDto {
|
||||
fraccionamientoId: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
sequence?: number;
|
||||
totalLots?: number;
|
||||
status?: string;
|
||||
startDate?: Date;
|
||||
expectedEndDate?: Date;
|
||||
}
|
||||
|
||||
export interface UpdateEtapaDto extends Partial<CreateEtapaDto> {
|
||||
actualEndDate?: Date;
|
||||
}
|
||||
|
||||
export interface EtapaListOptions {
|
||||
tenantId: string;
|
||||
fraccionamientoId?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export class EtapaService {
|
||||
constructor(private readonly repository: Repository<Etapa>) {}
|
||||
|
||||
/**
|
||||
* Listar etapas
|
||||
*/
|
||||
async findAll(options: EtapaListOptions): Promise<{ items: Etapa[]; total: number }> {
|
||||
const { tenantId, fraccionamientoId, page = 1, limit = 20, search, status } = options;
|
||||
|
||||
const query = this.repository
|
||||
.createQueryBuilder('e')
|
||||
.where('e.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('e.deleted_at IS NULL');
|
||||
|
||||
if (fraccionamientoId) {
|
||||
query.andWhere('e.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId });
|
||||
}
|
||||
|
||||
if (search) {
|
||||
query.andWhere('(e.code ILIKE :search OR e.name ILIKE :search)', { search: `%${search}%` });
|
||||
}
|
||||
|
||||
if (status) {
|
||||
query.andWhere('e.status = :status', { status });
|
||||
}
|
||||
|
||||
const total = await query.getCount();
|
||||
const items = await query
|
||||
.skip((page - 1) * limit)
|
||||
.take(limit)
|
||||
.orderBy('e.sequence', 'ASC')
|
||||
.addOrderBy('e.name', 'ASC')
|
||||
.getMany();
|
||||
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener etapa por ID
|
||||
*/
|
||||
async findById(id: string, tenantId: string): Promise<Etapa | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id, tenantId, deletedAt: IsNull() } as any,
|
||||
relations: ['manzanas'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener etapa por código dentro de un fraccionamiento
|
||||
*/
|
||||
async findByCode(code: string, fraccionamientoId: string, tenantId: string): Promise<Etapa | null> {
|
||||
return this.repository.findOne({
|
||||
where: { code, fraccionamientoId, tenantId, deletedAt: IsNull() } as any,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Crear etapa
|
||||
*/
|
||||
async create(tenantId: string, dto: CreateEtapaDto, createdBy?: string): Promise<Etapa> {
|
||||
const existing = await this.findByCode(dto.code, dto.fraccionamientoId, tenantId);
|
||||
if (existing) {
|
||||
throw new Error('Stage code already exists in this fraccionamiento');
|
||||
}
|
||||
|
||||
return this.repository.save(
|
||||
this.repository.create({
|
||||
tenantId,
|
||||
...dto,
|
||||
createdBy,
|
||||
status: dto.status || 'draft',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar etapa
|
||||
*/
|
||||
async update(id: string, tenantId: string, dto: UpdateEtapaDto, updatedBy?: string): Promise<Etapa> {
|
||||
const etapa = await this.findById(id, tenantId);
|
||||
if (!etapa) {
|
||||
throw new Error('Stage not found');
|
||||
}
|
||||
|
||||
// Verificar código único si se está cambiando
|
||||
if (dto.code && dto.code !== etapa.code) {
|
||||
const existing = await this.findByCode(dto.code, etapa.fraccionamientoId, tenantId);
|
||||
if (existing) {
|
||||
throw new Error('Stage code already exists in this fraccionamiento');
|
||||
}
|
||||
}
|
||||
|
||||
await this.repository.update(id, {
|
||||
...dto,
|
||||
updatedBy,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
return this.findById(id, tenantId) as Promise<Etapa>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eliminar etapa (soft delete)
|
||||
*/
|
||||
async delete(id: string, tenantId: string, _deletedBy?: string): Promise<void> {
|
||||
const etapa = await this.findById(id, tenantId);
|
||||
if (!etapa) {
|
||||
throw new Error('Stage not found');
|
||||
}
|
||||
|
||||
// TODO: Verificar si tiene manzanas antes de eliminar
|
||||
|
||||
await this.repository.update(id, {
|
||||
deletedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener etapas por fraccionamiento
|
||||
*/
|
||||
async findByFraccionamiento(fraccionamientoId: string, tenantId: string): Promise<Etapa[]> {
|
||||
return this.repository.find({
|
||||
where: { fraccionamientoId, tenantId, deletedAt: IsNull() } as any,
|
||||
order: { sequence: 'ASC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
11
src/modules/construction/services/index.ts
Normal file
11
src/modules/construction/services/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Construction Services Index
|
||||
* @module Construction
|
||||
*/
|
||||
|
||||
export * from './proyecto.service';
|
||||
export * from './fraccionamiento.service';
|
||||
export * from './etapa.service';
|
||||
export * from './manzana.service';
|
||||
export * from './lote.service';
|
||||
export * from './prototipo.service';
|
||||
230
src/modules/construction/services/lote.service.ts
Normal file
230
src/modules/construction/services/lote.service.ts
Normal file
@ -0,0 +1,230 @@
|
||||
/**
|
||||
* LoteService - Gestión de lotes/terrenos
|
||||
*
|
||||
* CRUD de lotes con soporte multi-tenant.
|
||||
*
|
||||
* @module Construction
|
||||
*/
|
||||
|
||||
import { Repository, IsNull } from 'typeorm';
|
||||
import { Lote } from '../entities/lote.entity';
|
||||
|
||||
export interface CreateLoteDto {
|
||||
manzanaId: string;
|
||||
prototipoId?: string;
|
||||
code: string;
|
||||
officialNumber?: string;
|
||||
areaM2?: number;
|
||||
frontM?: number;
|
||||
depthM?: number;
|
||||
status?: string;
|
||||
priceBase?: number;
|
||||
priceFinal?: number;
|
||||
}
|
||||
|
||||
export interface UpdateLoteDto extends Partial<CreateLoteDto> {
|
||||
buyerId?: string;
|
||||
saleDate?: Date;
|
||||
deliveryDate?: Date;
|
||||
}
|
||||
|
||||
export interface LoteListOptions {
|
||||
tenantId: string;
|
||||
manzanaId?: string;
|
||||
prototipoId?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export class LoteService {
|
||||
constructor(private readonly repository: Repository<Lote>) {}
|
||||
|
||||
/**
|
||||
* Listar lotes
|
||||
*/
|
||||
async findAll(options: LoteListOptions): Promise<{ items: Lote[]; total: number }> {
|
||||
const { tenantId, manzanaId, prototipoId, page = 1, limit = 20, search, status } = options;
|
||||
|
||||
const query = this.repository
|
||||
.createQueryBuilder('l')
|
||||
.where('l.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('l.deleted_at IS NULL');
|
||||
|
||||
if (manzanaId) {
|
||||
query.andWhere('l.manzana_id = :manzanaId', { manzanaId });
|
||||
}
|
||||
|
||||
if (prototipoId) {
|
||||
query.andWhere('l.prototipo_id = :prototipoId', { prototipoId });
|
||||
}
|
||||
|
||||
if (search) {
|
||||
query.andWhere('(l.code ILIKE :search OR l.official_number ILIKE :search)', { search: `%${search}%` });
|
||||
}
|
||||
|
||||
if (status) {
|
||||
query.andWhere('l.status = :status', { status });
|
||||
}
|
||||
|
||||
const total = await query.getCount();
|
||||
const items = await query
|
||||
.leftJoinAndSelect('l.prototipo', 'prototipo')
|
||||
.skip((page - 1) * limit)
|
||||
.take(limit)
|
||||
.orderBy('l.code', 'ASC')
|
||||
.getMany();
|
||||
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener lote por ID
|
||||
*/
|
||||
async findById(id: string, tenantId: string): Promise<Lote | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id, tenantId, deletedAt: IsNull() } as any,
|
||||
relations: ['prototipo', 'manzana'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener lote por código dentro de una manzana
|
||||
*/
|
||||
async findByCode(code: string, manzanaId: string, tenantId: string): Promise<Lote | null> {
|
||||
return this.repository.findOne({
|
||||
where: { code, manzanaId, tenantId, deletedAt: IsNull() } as any,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Crear lote
|
||||
*/
|
||||
async create(tenantId: string, dto: CreateLoteDto, createdBy?: string): Promise<Lote> {
|
||||
const existing = await this.findByCode(dto.code, dto.manzanaId, tenantId);
|
||||
if (existing) {
|
||||
throw new Error('Lot code already exists in this block');
|
||||
}
|
||||
|
||||
return this.repository.save(
|
||||
this.repository.create({
|
||||
tenantId,
|
||||
...dto,
|
||||
createdBy,
|
||||
status: dto.status || 'available',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar lote
|
||||
*/
|
||||
async update(id: string, tenantId: string, dto: UpdateLoteDto, updatedBy?: string): Promise<Lote> {
|
||||
const lote = await this.findById(id, tenantId);
|
||||
if (!lote) {
|
||||
throw new Error('Lot not found');
|
||||
}
|
||||
|
||||
// Verificar código único si se está cambiando
|
||||
if (dto.code && dto.code !== lote.code) {
|
||||
const existing = await this.findByCode(dto.code, lote.manzanaId, tenantId);
|
||||
if (existing) {
|
||||
throw new Error('Lot code already exists in this block');
|
||||
}
|
||||
}
|
||||
|
||||
await this.repository.update(id, {
|
||||
...dto,
|
||||
updatedBy,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
return this.findById(id, tenantId) as Promise<Lote>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eliminar lote (soft delete)
|
||||
*/
|
||||
async delete(id: string, tenantId: string, _deletedBy?: string): Promise<void> {
|
||||
const lote = await this.findById(id, tenantId);
|
||||
if (!lote) {
|
||||
throw new Error('Lot not found');
|
||||
}
|
||||
|
||||
// Verificar que no esté vendido
|
||||
if (lote.status === 'sold') {
|
||||
throw new Error('Cannot delete a sold lot');
|
||||
}
|
||||
|
||||
await this.repository.update(id, {
|
||||
deletedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener lotes por manzana
|
||||
*/
|
||||
async findByManzana(manzanaId: string, tenantId: string): Promise<Lote[]> {
|
||||
return this.repository.find({
|
||||
where: { manzanaId, tenantId, deletedAt: IsNull() } as any,
|
||||
order: { code: 'ASC' },
|
||||
relations: ['prototipo'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Asignar prototipo a lote
|
||||
*/
|
||||
async assignPrototipo(id: string, tenantId: string, prototipoId: string, updatedBy?: string): Promise<Lote> {
|
||||
const lote = await this.findById(id, tenantId);
|
||||
if (!lote) {
|
||||
throw new Error('Lot not found');
|
||||
}
|
||||
|
||||
await this.repository.update(id, {
|
||||
prototipoId,
|
||||
updatedBy,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
return this.findById(id, tenantId) as Promise<Lote>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cambiar estado del lote
|
||||
*/
|
||||
async changeStatus(id: string, tenantId: string, status: string, updatedBy?: string): Promise<Lote> {
|
||||
const lote = await this.findById(id, tenantId);
|
||||
if (!lote) {
|
||||
throw new Error('Lot not found');
|
||||
}
|
||||
|
||||
await this.repository.update(id, {
|
||||
status,
|
||||
updatedBy,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
return this.findById(id, tenantId) as Promise<Lote>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener estadísticas de lotes por estado
|
||||
*/
|
||||
async getStatsByStatus(tenantId: string, manzanaId?: string): Promise<{ status: string; count: number }[]> {
|
||||
const query = this.repository
|
||||
.createQueryBuilder('l')
|
||||
.select('l.status', 'status')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('l.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('l.deleted_at IS NULL')
|
||||
.groupBy('l.status');
|
||||
|
||||
if (manzanaId) {
|
||||
query.andWhere('l.manzana_id = :manzanaId', { manzanaId });
|
||||
}
|
||||
|
||||
return query.getRawMany();
|
||||
}
|
||||
}
|
||||
149
src/modules/construction/services/manzana.service.ts
Normal file
149
src/modules/construction/services/manzana.service.ts
Normal file
@ -0,0 +1,149 @@
|
||||
/**
|
||||
* ManzanaService - Gestión de manzanas (bloques)
|
||||
*
|
||||
* CRUD de manzanas con soporte multi-tenant.
|
||||
*
|
||||
* @module Construction
|
||||
*/
|
||||
|
||||
import { Repository, IsNull } from 'typeorm';
|
||||
import { Manzana } from '../entities/manzana.entity';
|
||||
|
||||
export interface CreateManzanaDto {
|
||||
etapaId: string;
|
||||
code: string;
|
||||
name?: string;
|
||||
totalLots?: number;
|
||||
}
|
||||
|
||||
export interface UpdateManzanaDto extends Partial<CreateManzanaDto> {}
|
||||
|
||||
export interface ManzanaListOptions {
|
||||
tenantId: string;
|
||||
etapaId?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export class ManzanaService {
|
||||
constructor(private readonly repository: Repository<Manzana>) {}
|
||||
|
||||
/**
|
||||
* Listar manzanas
|
||||
*/
|
||||
async findAll(options: ManzanaListOptions): Promise<{ items: Manzana[]; total: number }> {
|
||||
const { tenantId, etapaId, page = 1, limit = 20, search } = options;
|
||||
|
||||
const query = this.repository
|
||||
.createQueryBuilder('m')
|
||||
.where('m.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('m.deleted_at IS NULL');
|
||||
|
||||
if (etapaId) {
|
||||
query.andWhere('m.etapa_id = :etapaId', { etapaId });
|
||||
}
|
||||
|
||||
if (search) {
|
||||
query.andWhere('(m.code ILIKE :search OR m.name ILIKE :search)', { search: `%${search}%` });
|
||||
}
|
||||
|
||||
const total = await query.getCount();
|
||||
const items = await query
|
||||
.skip((page - 1) * limit)
|
||||
.take(limit)
|
||||
.orderBy('m.code', 'ASC')
|
||||
.getMany();
|
||||
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener manzana por ID
|
||||
*/
|
||||
async findById(id: string, tenantId: string): Promise<Manzana | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id, tenantId, deletedAt: IsNull() } as any,
|
||||
relations: ['lotes'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener manzana por código dentro de una etapa
|
||||
*/
|
||||
async findByCode(code: string, etapaId: string, tenantId: string): Promise<Manzana | null> {
|
||||
return this.repository.findOne({
|
||||
where: { code, etapaId, tenantId, deletedAt: IsNull() } as any,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Crear manzana
|
||||
*/
|
||||
async create(tenantId: string, dto: CreateManzanaDto, createdBy?: string): Promise<Manzana> {
|
||||
const existing = await this.findByCode(dto.code, dto.etapaId, tenantId);
|
||||
if (existing) {
|
||||
throw new Error('Block code already exists in this stage');
|
||||
}
|
||||
|
||||
return this.repository.save(
|
||||
this.repository.create({
|
||||
tenantId,
|
||||
...dto,
|
||||
createdBy,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar manzana
|
||||
*/
|
||||
async update(id: string, tenantId: string, dto: UpdateManzanaDto, updatedBy?: string): Promise<Manzana> {
|
||||
const manzana = await this.findById(id, tenantId);
|
||||
if (!manzana) {
|
||||
throw new Error('Block not found');
|
||||
}
|
||||
|
||||
// Verificar código único si se está cambiando
|
||||
if (dto.code && dto.code !== manzana.code) {
|
||||
const existing = await this.findByCode(dto.code, manzana.etapaId, tenantId);
|
||||
if (existing) {
|
||||
throw new Error('Block code already exists in this stage');
|
||||
}
|
||||
}
|
||||
|
||||
await this.repository.update(id, {
|
||||
...dto,
|
||||
updatedBy,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
return this.findById(id, tenantId) as Promise<Manzana>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eliminar manzana (soft delete)
|
||||
*/
|
||||
async delete(id: string, tenantId: string, _deletedBy?: string): Promise<void> {
|
||||
const manzana = await this.findById(id, tenantId);
|
||||
if (!manzana) {
|
||||
throw new Error('Block not found');
|
||||
}
|
||||
|
||||
// TODO: Verificar si tiene lotes antes de eliminar
|
||||
|
||||
await this.repository.update(id, {
|
||||
deletedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener manzanas por etapa
|
||||
*/
|
||||
async findByEtapa(etapaId: string, tenantId: string): Promise<Manzana[]> {
|
||||
return this.repository.find({
|
||||
where: { etapaId, tenantId, deletedAt: IsNull() } as any,
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
173
src/modules/construction/services/prototipo.service.ts
Normal file
173
src/modules/construction/services/prototipo.service.ts
Normal file
@ -0,0 +1,173 @@
|
||||
/**
|
||||
* PrototipoService - Gestión de prototipos de vivienda
|
||||
*
|
||||
* CRUD de prototipos con soporte multi-tenant.
|
||||
*
|
||||
* @module Construction
|
||||
*/
|
||||
|
||||
import { Repository, IsNull } from 'typeorm';
|
||||
import { Prototipo } from '../entities/prototipo.entity';
|
||||
|
||||
export interface CreatePrototipoDto {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
type?: string;
|
||||
areaConstructionM2?: number;
|
||||
areaTerrainM2?: number;
|
||||
bedrooms?: number;
|
||||
bathrooms?: number;
|
||||
parkingSpaces?: number;
|
||||
floors?: number;
|
||||
basePrice?: number;
|
||||
blueprintUrl?: string;
|
||||
renderUrl?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UpdatePrototipoDto extends Partial<CreatePrototipoDto> {
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface PrototipoListOptions {
|
||||
tenantId: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
type?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class PrototipoService {
|
||||
constructor(private readonly repository: Repository<Prototipo>) {}
|
||||
|
||||
/**
|
||||
* Listar prototipos
|
||||
*/
|
||||
async findAll(options: PrototipoListOptions): Promise<{ items: Prototipo[]; total: number }> {
|
||||
const { tenantId, page = 1, limit = 20, search, type, isActive } = options;
|
||||
|
||||
const query = this.repository
|
||||
.createQueryBuilder('p')
|
||||
.where('p.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('p.deleted_at IS NULL');
|
||||
|
||||
if (search) {
|
||||
query.andWhere('(p.code ILIKE :search OR p.name ILIKE :search)', { search: `%${search}%` });
|
||||
}
|
||||
|
||||
if (type) {
|
||||
query.andWhere('p.type = :type', { type });
|
||||
}
|
||||
|
||||
if (isActive !== undefined) {
|
||||
query.andWhere('p.is_active = :isActive', { isActive });
|
||||
}
|
||||
|
||||
const total = await query.getCount();
|
||||
const items = await query
|
||||
.skip((page - 1) * limit)
|
||||
.take(limit)
|
||||
.orderBy('p.name', 'ASC')
|
||||
.getMany();
|
||||
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener prototipo por ID
|
||||
*/
|
||||
async findById(id: string, tenantId: string): Promise<Prototipo | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id, tenantId, deletedAt: IsNull() } as any,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener prototipo por código
|
||||
*/
|
||||
async findByCode(code: string, tenantId: string): Promise<Prototipo | null> {
|
||||
return this.repository.findOne({
|
||||
where: { code, tenantId, deletedAt: IsNull() } as any,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Crear prototipo
|
||||
*/
|
||||
async create(tenantId: string, dto: CreatePrototipoDto, createdBy?: string): Promise<Prototipo> {
|
||||
const existing = await this.findByCode(dto.code, tenantId);
|
||||
if (existing) {
|
||||
throw new Error('Prototype code already exists');
|
||||
}
|
||||
|
||||
return this.repository.save(
|
||||
this.repository.create({
|
||||
tenantId,
|
||||
...dto,
|
||||
createdBy,
|
||||
isActive: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar prototipo
|
||||
*/
|
||||
async update(id: string, tenantId: string, dto: UpdatePrototipoDto, updatedBy?: string): Promise<Prototipo> {
|
||||
const prototipo = await this.findById(id, tenantId);
|
||||
if (!prototipo) {
|
||||
throw new Error('Prototype not found');
|
||||
}
|
||||
|
||||
// Verificar código único si se está cambiando
|
||||
if (dto.code && dto.code !== prototipo.code) {
|
||||
const existing = await this.findByCode(dto.code, tenantId);
|
||||
if (existing) {
|
||||
throw new Error('Prototype code already exists');
|
||||
}
|
||||
}
|
||||
|
||||
// Exclude metadata from spread to avoid TypeORM type issues
|
||||
const { metadata, ...updateData } = dto;
|
||||
await this.repository.update(id, {
|
||||
...updateData,
|
||||
...(metadata && { metadata: metadata as any }),
|
||||
updatedBy,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
return this.findById(id, tenantId) as Promise<Prototipo>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eliminar prototipo (soft delete)
|
||||
*/
|
||||
async delete(id: string, tenantId: string, _deletedBy?: string): Promise<void> {
|
||||
const prototipo = await this.findById(id, tenantId);
|
||||
if (!prototipo) {
|
||||
throw new Error('Prototype not found');
|
||||
}
|
||||
|
||||
// TODO: Verificar si está asignado a lotes antes de eliminar
|
||||
|
||||
await this.repository.update(id, {
|
||||
deletedAt: new Date(),
|
||||
isActive: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Activar/Desactivar prototipo
|
||||
*/
|
||||
async setActive(id: string, tenantId: string, isActive: boolean): Promise<Prototipo> {
|
||||
const prototipo = await this.findById(id, tenantId);
|
||||
if (!prototipo) {
|
||||
throw new Error('Prototype not found');
|
||||
}
|
||||
|
||||
await this.repository.update(id, { isActive });
|
||||
return this.findById(id, tenantId) as Promise<Prototipo>;
|
||||
}
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
415
src/modules/contracts/controllers/contract.controller.ts
Normal file
415
src/modules/contracts/controllers/contract.controller.ts
Normal file
@ -0,0 +1,415 @@
|
||||
/**
|
||||
* ContractController - REST API for contracts
|
||||
*
|
||||
* Endpoints para gestión de contratos.
|
||||
*
|
||||
* @module Contracts
|
||||
* @routes /api/contracts
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { ContractService, ContractFilters } from '../services/contract.service';
|
||||
import { Contract } from '../entities/contract.entity';
|
||||
import { ContractAddendum } from '../entities/contract-addendum.entity';
|
||||
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
|
||||
import { AuthService } from '../../auth/services/auth.service';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
import { Tenant } from '../../core/entities/tenant.entity';
|
||||
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
|
||||
import { ServiceContext } from '../../../shared/services/base.service';
|
||||
|
||||
export function createContractController(dataSource: DataSource): Router {
|
||||
const router = Router();
|
||||
|
||||
// Repositories
|
||||
const contractRepo = dataSource.getRepository(Contract);
|
||||
const addendumRepo = dataSource.getRepository(ContractAddendum);
|
||||
const userRepository = dataSource.getRepository(User);
|
||||
const tenantRepository = dataSource.getRepository(Tenant);
|
||||
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
|
||||
|
||||
// Services
|
||||
const service = new ContractService(contractRepo, addendumRepo);
|
||||
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
|
||||
const authMiddleware = new AuthMiddleware(authService, dataSource);
|
||||
|
||||
// Helper for service context
|
||||
const getContext = (req: Request): ServiceContext => {
|
||||
if (!req.tenantId) {
|
||||
throw new Error('Tenant ID is required');
|
||||
}
|
||||
return {
|
||||
tenantId: req.tenantId,
|
||||
userId: req.user?.sub,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/contracts
|
||||
* List contracts with filters
|
||||
*/
|
||||
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const filters: ContractFilters = {};
|
||||
if (req.query.projectId) filters.projectId = req.query.projectId as string;
|
||||
if (req.query.fraccionamientoId) filters.fraccionamientoId = req.query.fraccionamientoId as string;
|
||||
if (req.query.contractType) filters.contractType = req.query.contractType as ContractFilters['contractType'];
|
||||
if (req.query.subcontractorId) filters.subcontractorId = req.query.subcontractorId as string;
|
||||
if (req.query.status) filters.status = req.query.status as ContractFilters['status'];
|
||||
if (req.query.expiringInDays) filters.expiringInDays = parseInt(req.query.expiringInDays as string);
|
||||
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||
|
||||
const result = await service.findWithFilters(getContext(req), filters, page, limit);
|
||||
res.status(200).json({ success: true, data: result.data, pagination: result.meta });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/contracts/expiring
|
||||
* Get contracts expiring soon
|
||||
*/
|
||||
router.get('/expiring', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const days = parseInt(req.query.days as string) || 30;
|
||||
const contracts = await service.getExpiringContracts(getContext(req), days);
|
||||
res.status(200).json({ success: true, data: contracts });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/contracts/:id
|
||||
* Get contract with details
|
||||
*/
|
||||
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const contract = await service.findWithDetails(getContext(req), req.params.id);
|
||||
if (!contract) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Contract not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: contract });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/contracts
|
||||
* Create new contract
|
||||
*/
|
||||
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'legal', 'contracts'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const contract = await service.create(getContext(req), {
|
||||
projectId: req.body.projectId,
|
||||
fraccionamientoId: req.body.fraccionamientoId,
|
||||
contractType: req.body.contractType,
|
||||
clientContractType: req.body.clientContractType,
|
||||
name: req.body.name,
|
||||
description: req.body.description,
|
||||
clientName: req.body.clientName,
|
||||
clientRfc: req.body.clientRfc,
|
||||
clientAddress: req.body.clientAddress,
|
||||
subcontractorId: req.body.subcontractorId,
|
||||
specialty: req.body.specialty,
|
||||
startDate: new Date(req.body.startDate),
|
||||
endDate: new Date(req.body.endDate),
|
||||
contractAmount: req.body.contractAmount,
|
||||
currency: req.body.currency,
|
||||
paymentTerms: req.body.paymentTerms,
|
||||
retentionPercentage: req.body.retentionPercentage,
|
||||
advancePercentage: req.body.advancePercentage,
|
||||
notes: req.body.notes,
|
||||
});
|
||||
|
||||
res.status(201).json({ success: true, data: contract });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/contracts/:id/submit
|
||||
* Submit contract for review
|
||||
*/
|
||||
router.post('/:id/submit', authMiddleware.authenticate, authMiddleware.authorize('admin', 'legal', 'contracts'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const contract = await service.submitForReview(getContext(req), req.params.id);
|
||||
if (!contract) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Contract not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: contract });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/contracts/:id/approve-legal
|
||||
* Legal approval
|
||||
*/
|
||||
router.post('/:id/approve-legal', authMiddleware.authenticate, authMiddleware.authorize('admin', 'legal'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const contract = await service.approveLegal(getContext(req), req.params.id);
|
||||
if (!contract) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Contract not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: contract });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/contracts/:id/approve
|
||||
* Final approval
|
||||
*/
|
||||
router.post('/:id/approve', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const contract = await service.approve(getContext(req), req.params.id);
|
||||
if (!contract) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Contract not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: contract });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/contracts/:id/activate
|
||||
* Activate signed contract
|
||||
*/
|
||||
router.post('/:id/activate', authMiddleware.authenticate, authMiddleware.authorize('admin', 'legal', 'contracts'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const contract = await service.activate(getContext(req), req.params.id, req.body.signedDocumentUrl);
|
||||
if (!contract) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Contract not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: contract });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/contracts/:id/complete
|
||||
* Mark contract as completed
|
||||
*/
|
||||
router.post('/:id/complete', authMiddleware.authenticate, authMiddleware.authorize('admin', 'contracts'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const contract = await service.complete(getContext(req), req.params.id);
|
||||
if (!contract) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Contract not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: contract });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/contracts/:id/terminate
|
||||
* Terminate contract
|
||||
*/
|
||||
router.post('/:id/terminate', authMiddleware.authenticate, authMiddleware.authorize('admin', 'legal', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const contract = await service.terminate(getContext(req), req.params.id, req.body.reason);
|
||||
if (!contract) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Contract not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: contract });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/contracts/:id/progress
|
||||
* Update contract progress
|
||||
*/
|
||||
router.put('/:id/progress', authMiddleware.authenticate, authMiddleware.authorize('admin', 'contracts', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const contract = await service.updateProgress(
|
||||
getContext(req),
|
||||
req.params.id,
|
||||
req.body.progressPercentage,
|
||||
req.body.invoicedAmount,
|
||||
req.body.paidAmount
|
||||
);
|
||||
|
||||
if (!contract) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Contract not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: contract });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/contracts/:id/addendums
|
||||
* Create contract addendum
|
||||
*/
|
||||
router.post('/:id/addendums', authMiddleware.authenticate, authMiddleware.authorize('admin', 'legal', 'contracts'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const addendum = await service.createAddendum(getContext(req), req.params.id, {
|
||||
addendumType: req.body.addendumType,
|
||||
title: req.body.title,
|
||||
description: req.body.description,
|
||||
effectiveDate: new Date(req.body.effectiveDate),
|
||||
newEndDate: req.body.newEndDate ? new Date(req.body.newEndDate) : undefined,
|
||||
amountChange: req.body.amountChange,
|
||||
scopeChanges: req.body.scopeChanges,
|
||||
notes: req.body.notes,
|
||||
});
|
||||
|
||||
res.status(201).json({ success: true, data: addendum });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/contracts/addendums/:addendumId/approve
|
||||
* Approve addendum
|
||||
*/
|
||||
router.post('/addendums/:addendumId/approve', authMiddleware.authenticate, authMiddleware.authorize('admin', 'legal', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const addendum = await service.approveAddendum(getContext(req), req.params.addendumId);
|
||||
if (!addendum) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Addendum not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: addendum });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/contracts/:id
|
||||
* Soft delete contract
|
||||
*/
|
||||
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = await service.softDelete(getContext(req), req.params.id);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Contract not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
7
src/modules/contracts/controllers/index.ts
Normal file
7
src/modules/contracts/controllers/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Contracts Controllers Index
|
||||
* @module Contracts
|
||||
*/
|
||||
|
||||
export * from './contract.controller';
|
||||
export * from './subcontractor.controller';
|
||||
257
src/modules/contracts/controllers/subcontractor.controller.ts
Normal file
257
src/modules/contracts/controllers/subcontractor.controller.ts
Normal file
@ -0,0 +1,257 @@
|
||||
/**
|
||||
* SubcontractorController - REST API for subcontractors
|
||||
*
|
||||
* Endpoints para gestión de subcontratistas.
|
||||
*
|
||||
* @module Contracts
|
||||
* @routes /api/subcontractors
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { SubcontractorService, SubcontractorFilters } from '../services/subcontractor.service';
|
||||
import { Subcontractor } from '../entities/subcontractor.entity';
|
||||
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
|
||||
import { AuthService } from '../../auth/services/auth.service';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
import { Tenant } from '../../core/entities/tenant.entity';
|
||||
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
|
||||
import { ServiceContext } from '../../../shared/services/base.service';
|
||||
|
||||
export function createSubcontractorController(dataSource: DataSource): Router {
|
||||
const router = Router();
|
||||
|
||||
// Repositories
|
||||
const subcontractorRepo = dataSource.getRepository(Subcontractor);
|
||||
const userRepository = dataSource.getRepository(User);
|
||||
const tenantRepository = dataSource.getRepository(Tenant);
|
||||
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
|
||||
|
||||
// Services
|
||||
const service = new SubcontractorService(subcontractorRepo);
|
||||
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
|
||||
const authMiddleware = new AuthMiddleware(authService, dataSource);
|
||||
|
||||
// Helper for service context
|
||||
const getContext = (req: Request): ServiceContext => {
|
||||
if (!req.tenantId) {
|
||||
throw new Error('Tenant ID is required');
|
||||
}
|
||||
return {
|
||||
tenantId: req.tenantId,
|
||||
userId: req.user?.sub,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/subcontractors
|
||||
* List subcontractors with filters
|
||||
*/
|
||||
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const filters: SubcontractorFilters = {};
|
||||
if (req.query.specialty) filters.specialty = req.query.specialty as SubcontractorFilters['specialty'];
|
||||
if (req.query.status) filters.status = req.query.status as SubcontractorFilters['status'];
|
||||
if (req.query.search) filters.search = req.query.search as string;
|
||||
if (req.query.minRating) filters.minRating = parseFloat(req.query.minRating as string);
|
||||
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||
|
||||
const result = await service.findWithFilters(getContext(req), filters, page, limit);
|
||||
res.status(200).json({ success: true, data: result.data, pagination: result.meta });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/subcontractors/specialty/:specialty
|
||||
* Get subcontractors by specialty
|
||||
*/
|
||||
router.get('/specialty/:specialty', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const subcontractors = await service.getBySpecialty(getContext(req), req.params.specialty as any);
|
||||
res.status(200).json({ success: true, data: subcontractors });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/subcontractors/:id
|
||||
* Get subcontractor by ID
|
||||
*/
|
||||
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const subcontractor = await service.findById(getContext(req), req.params.id);
|
||||
if (!subcontractor) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Subcontractor not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: subcontractor });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/subcontractors
|
||||
* Create new subcontractor
|
||||
*/
|
||||
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'contracts'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const subcontractor = await service.create(getContext(req), req.body);
|
||||
res.status(201).json({ success: true, data: subcontractor });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/subcontractors/:id
|
||||
* Update subcontractor
|
||||
*/
|
||||
router.put('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'contracts'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const subcontractor = await service.update(getContext(req), req.params.id, req.body);
|
||||
if (!subcontractor) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Subcontractor not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: subcontractor });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/subcontractors/:id/rate
|
||||
* Rate subcontractor
|
||||
*/
|
||||
router.post('/:id/rate', authMiddleware.authenticate, authMiddleware.authorize('admin', 'contracts', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const subcontractor = await service.updateRating(getContext(req), req.params.id, req.body.rating);
|
||||
if (!subcontractor) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Subcontractor not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: subcontractor });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/subcontractors/:id/deactivate
|
||||
* Deactivate subcontractor
|
||||
*/
|
||||
router.post('/:id/deactivate', authMiddleware.authenticate, authMiddleware.authorize('admin', 'contracts'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const subcontractor = await service.deactivate(getContext(req), req.params.id);
|
||||
if (!subcontractor) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Subcontractor not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: subcontractor });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/subcontractors/:id/blacklist
|
||||
* Blacklist subcontractor
|
||||
*/
|
||||
router.post('/:id/blacklist', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const subcontractor = await service.blacklist(getContext(req), req.params.id, req.body.reason);
|
||||
if (!subcontractor) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Subcontractor not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: subcontractor });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/subcontractors/:id
|
||||
* Soft delete subcontractor
|
||||
*/
|
||||
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = await service.softDelete(getContext(req), req.params.id);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Subcontractor not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
114
src/modules/contracts/entities/contract-addendum.entity.ts
Normal file
114
src/modules/contracts/entities/contract-addendum.entity.ts
Normal file
@ -0,0 +1,114 @@
|
||||
/**
|
||||
* ContractAddendum Entity
|
||||
* Addendas y modificaciones a contratos
|
||||
*
|
||||
* @module Contracts
|
||||
* @table contracts.contract_addendums
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Tenant } from '../../core/entities/tenant.entity';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
import { Contract } from './contract.entity';
|
||||
|
||||
export type AddendumType = 'extension' | 'amount_increase' | 'amount_decrease' | 'scope_change' | 'termination' | 'other';
|
||||
export type AddendumStatus = 'draft' | 'review' | 'approved' | 'rejected';
|
||||
|
||||
@Entity({ schema: 'contracts', name: 'contract_addendums' })
|
||||
@Index(['tenantId', 'addendumNumber'], { unique: true })
|
||||
export class ContractAddendum {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'contract_id', type: 'uuid' })
|
||||
contractId: string;
|
||||
|
||||
@Column({ name: 'addendum_number', type: 'varchar', length: 50 })
|
||||
addendumNumber: string;
|
||||
|
||||
@Column({ name: 'addendum_type', type: 'varchar', length: 30 })
|
||||
addendumType: AddendumType;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
title: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
description: string;
|
||||
|
||||
@Column({ name: 'effective_date', type: 'date' })
|
||||
effectiveDate: Date;
|
||||
|
||||
// Changes
|
||||
@Column({ name: 'new_end_date', type: 'date', nullable: true })
|
||||
newEndDate: Date;
|
||||
|
||||
@Column({ name: 'amount_change', type: 'decimal', precision: 16, scale: 2, default: 0 })
|
||||
amountChange: number;
|
||||
|
||||
@Column({ name: 'new_contract_amount', type: 'decimal', precision: 16, scale: 2, nullable: true })
|
||||
newContractAmount: number;
|
||||
|
||||
@Column({ name: 'scope_changes', type: 'text', nullable: true })
|
||||
scopeChanges: string;
|
||||
|
||||
// Status
|
||||
@Column({ type: 'varchar', length: 20, default: 'draft' })
|
||||
status: AddendumStatus;
|
||||
|
||||
@Column({ name: 'approved_at', type: 'timestamptz', nullable: true })
|
||||
approvedAt: Date;
|
||||
|
||||
@Column({ name: 'approved_by', type: 'uuid', nullable: true })
|
||||
approvedById: string;
|
||||
|
||||
@Column({ name: 'rejection_reason', type: 'text', nullable: true })
|
||||
rejectionReason: string;
|
||||
|
||||
// Document
|
||||
@Column({ name: 'document_url', type: 'varchar', length: 500, nullable: true })
|
||||
documentUrl: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdById: string;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||
updatedById: string;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => Tenant)
|
||||
@JoinColumn({ name: 'tenant_id' })
|
||||
tenant: Tenant;
|
||||
|
||||
@ManyToOne(() => Contract, (c) => c.addendums, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'contract_id' })
|
||||
contract: Contract;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'created_by' })
|
||||
createdBy: User;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'approved_by' })
|
||||
approvedBy: User;
|
||||
}
|
||||
192
src/modules/contracts/entities/contract.entity.ts
Normal file
192
src/modules/contracts/entities/contract.entity.ts
Normal file
@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Contract Entity
|
||||
* Contratos con clientes y subcontratistas
|
||||
*
|
||||
* @module Contracts
|
||||
* @table contracts.contracts
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Tenant } from '../../core/entities/tenant.entity';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
import { ContractAddendum } from './contract-addendum.entity';
|
||||
|
||||
export type ContractType = 'client' | 'subcontractor';
|
||||
export type ContractStatus = 'draft' | 'review' | 'approved' | 'active' | 'completed' | 'terminated';
|
||||
export type ClientContractType = 'desarrollo' | 'llave_en_mano' | 'administracion';
|
||||
|
||||
@Entity({ schema: 'contracts', name: 'contracts' })
|
||||
@Index(['tenantId', 'contractNumber'], { unique: true })
|
||||
export class Contract {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'project_id', type: 'uuid', nullable: true })
|
||||
projectId: string;
|
||||
|
||||
@Column({ name: 'fraccionamiento_id', type: 'uuid', nullable: true })
|
||||
fraccionamientoId: string;
|
||||
|
||||
@Column({ name: 'contract_number', type: 'varchar', length: 50 })
|
||||
contractNumber: string;
|
||||
|
||||
@Column({ name: 'contract_type', type: 'varchar', length: 20 })
|
||||
contractType: ContractType;
|
||||
|
||||
@Column({ name: 'client_contract_type', type: 'varchar', length: 30, nullable: true })
|
||||
clientContractType: ClientContractType;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
|
||||
// Client info (for client contracts)
|
||||
@Column({ name: 'client_name', type: 'varchar', length: 255, nullable: true })
|
||||
clientName: string;
|
||||
|
||||
@Column({ name: 'client_rfc', type: 'varchar', length: 13, nullable: true })
|
||||
clientRfc: string;
|
||||
|
||||
@Column({ name: 'client_address', type: 'text', nullable: true })
|
||||
clientAddress: string;
|
||||
|
||||
// Subcontractor info (for subcontractor contracts)
|
||||
@Column({ name: 'subcontractor_id', type: 'uuid', nullable: true })
|
||||
subcontractorId: string;
|
||||
|
||||
@Column({ name: 'specialty', type: 'varchar', length: 50, nullable: true })
|
||||
specialty: string;
|
||||
|
||||
// Contract terms
|
||||
@Column({ name: 'start_date', type: 'date' })
|
||||
startDate: Date;
|
||||
|
||||
@Column({ name: 'end_date', type: 'date' })
|
||||
endDate: Date;
|
||||
|
||||
@Column({ name: 'contract_amount', type: 'decimal', precision: 16, scale: 2 })
|
||||
contractAmount: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 3, default: 'MXN' })
|
||||
currency: string;
|
||||
|
||||
@Column({ name: 'payment_terms', type: 'text', nullable: true })
|
||||
paymentTerms: string;
|
||||
|
||||
@Column({ name: 'retention_percentage', type: 'decimal', precision: 5, scale: 2, default: 5 })
|
||||
retentionPercentage: number;
|
||||
|
||||
@Column({ name: 'advance_percentage', type: 'decimal', precision: 5, scale: 2, default: 0 })
|
||||
advancePercentage: number;
|
||||
|
||||
// Status and workflow
|
||||
@Column({ type: 'varchar', length: 20, default: 'draft' })
|
||||
status: ContractStatus;
|
||||
|
||||
@Column({ name: 'submitted_at', type: 'timestamptz', nullable: true })
|
||||
submittedAt: Date;
|
||||
|
||||
@Column({ name: 'submitted_by', type: 'uuid', nullable: true })
|
||||
submittedById: string;
|
||||
|
||||
@Column({ name: 'legal_approved_at', type: 'timestamptz', nullable: true })
|
||||
legalApprovedAt: Date;
|
||||
|
||||
@Column({ name: 'legal_approved_by', type: 'uuid', nullable: true })
|
||||
legalApprovedById: string;
|
||||
|
||||
@Column({ name: 'approved_at', type: 'timestamptz', nullable: true })
|
||||
approvedAt: Date;
|
||||
|
||||
@Column({ name: 'approved_by', type: 'uuid', nullable: true })
|
||||
approvedById: string;
|
||||
|
||||
@Column({ name: 'signed_at', type: 'timestamptz', nullable: true })
|
||||
signedAt: Date;
|
||||
|
||||
@Column({ name: 'terminated_at', type: 'timestamptz', nullable: true })
|
||||
terminatedAt: Date;
|
||||
|
||||
@Column({ name: 'termination_reason', type: 'text', nullable: true })
|
||||
terminationReason: string;
|
||||
|
||||
// Documents
|
||||
@Column({ name: 'document_url', type: 'varchar', length: 500, nullable: true })
|
||||
documentUrl: string;
|
||||
|
||||
@Column({ name: 'signed_document_url', type: 'varchar', length: 500, nullable: true })
|
||||
signedDocumentUrl: string;
|
||||
|
||||
// Progress tracking
|
||||
@Column({ name: 'progress_percentage', type: 'decimal', precision: 5, scale: 2, default: 0 })
|
||||
progressPercentage: number;
|
||||
|
||||
@Column({ name: 'invoiced_amount', type: 'decimal', precision: 16, scale: 2, default: 0 })
|
||||
invoicedAmount: number;
|
||||
|
||||
@Column({ name: 'paid_amount', type: 'decimal', precision: 16, scale: 2, default: 0 })
|
||||
paidAmount: number;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdById: string;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||
updatedById: string;
|
||||
|
||||
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||
deletedAt: Date;
|
||||
|
||||
@Column({ name: 'deleted_by', type: 'uuid', nullable: true })
|
||||
deletedById: string;
|
||||
|
||||
// Computed properties
|
||||
get remainingAmount(): number {
|
||||
return Number(this.contractAmount) - Number(this.invoicedAmount);
|
||||
}
|
||||
|
||||
get isExpiring(): boolean {
|
||||
const thirtyDaysFromNow = new Date();
|
||||
thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30);
|
||||
return this.endDate <= thirtyDaysFromNow && this.status === 'active';
|
||||
}
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => Tenant)
|
||||
@JoinColumn({ name: 'tenant_id' })
|
||||
tenant: Tenant;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'created_by' })
|
||||
createdBy: User;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'approved_by' })
|
||||
approvedBy: User;
|
||||
|
||||
@OneToMany(() => ContractAddendum, (a) => a.contract)
|
||||
addendums: ContractAddendum[];
|
||||
}
|
||||
10
src/modules/contracts/entities/index.ts
Normal file
10
src/modules/contracts/entities/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Contracts Entities Index
|
||||
* @module Contracts
|
||||
*
|
||||
* Gestión de contratos y subcontratos (MAI-012)
|
||||
*/
|
||||
|
||||
export * from './contract.entity';
|
||||
export * from './subcontractor.entity';
|
||||
export * from './contract-addendum.entity';
|
||||
123
src/modules/contracts/entities/subcontractor.entity.ts
Normal file
123
src/modules/contracts/entities/subcontractor.entity.ts
Normal file
@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Subcontractor Entity
|
||||
* Catálogo de subcontratistas
|
||||
*
|
||||
* @module Contracts
|
||||
* @table contracts.subcontractors
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Tenant } from '../../core/entities/tenant.entity';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
|
||||
export type SubcontractorSpecialty = 'cimentacion' | 'estructura' | 'instalaciones_electricas' | 'instalaciones_hidraulicas' | 'acabados' | 'urbanizacion' | 'carpinteria' | 'herreria' | 'otros';
|
||||
export type SubcontractorStatus = 'active' | 'inactive' | 'blacklisted';
|
||||
|
||||
@Entity({ schema: 'contracts', name: 'subcontractors' })
|
||||
@Index(['tenantId', 'code'], { unique: true })
|
||||
@Index(['tenantId', 'rfc'], { unique: true })
|
||||
export class Subcontractor {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 30 })
|
||||
code: string;
|
||||
|
||||
@Column({ name: 'business_name', type: 'varchar', length: 255 })
|
||||
businessName: string;
|
||||
|
||||
@Column({ name: 'trade_name', type: 'varchar', length: 255, nullable: true })
|
||||
tradeName: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 13 })
|
||||
rfc: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
address: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||
phone: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
email: string;
|
||||
|
||||
@Column({ name: 'contact_name', type: 'varchar', length: 200, nullable: true })
|
||||
contactName: string;
|
||||
|
||||
@Column({ name: 'contact_phone', type: 'varchar', length: 20, nullable: true })
|
||||
contactPhone: string;
|
||||
|
||||
@Column({ name: 'primary_specialty', type: 'varchar', length: 50 })
|
||||
primarySpecialty: SubcontractorSpecialty;
|
||||
|
||||
@Column({ name: 'secondary_specialties', type: 'simple-array', nullable: true })
|
||||
secondarySpecialties: string[];
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'active' })
|
||||
status: SubcontractorStatus;
|
||||
|
||||
// Performance tracking
|
||||
@Column({ name: 'total_contracts', type: 'integer', default: 0 })
|
||||
totalContracts: number;
|
||||
|
||||
@Column({ name: 'completed_contracts', type: 'integer', default: 0 })
|
||||
completedContracts: number;
|
||||
|
||||
@Column({ name: 'average_rating', type: 'decimal', precision: 3, scale: 2, default: 0 })
|
||||
averageRating: number;
|
||||
|
||||
@Column({ name: 'total_incidents', type: 'integer', default: 0 })
|
||||
totalIncidents: number;
|
||||
|
||||
// Financial info
|
||||
@Column({ name: 'bank_name', type: 'varchar', length: 100, nullable: true })
|
||||
bankName: string;
|
||||
|
||||
@Column({ name: 'bank_account', type: 'varchar', length: 30, nullable: true })
|
||||
bankAccount: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 18, nullable: true })
|
||||
clabe: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdById: string;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||
updatedById: string;
|
||||
|
||||
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||
deletedAt: Date;
|
||||
|
||||
@Column({ name: 'deleted_by', type: 'uuid', nullable: true })
|
||||
deletedById: string;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => Tenant)
|
||||
@JoinColumn({ name: 'tenant_id' })
|
||||
tenant: Tenant;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'created_by' })
|
||||
createdBy: User;
|
||||
}
|
||||
422
src/modules/contracts/services/contract.service.ts
Normal file
422
src/modules/contracts/services/contract.service.ts
Normal file
@ -0,0 +1,422 @@
|
||||
/**
|
||||
* ContractService - Servicio de gestión de contratos
|
||||
*
|
||||
* Gestión de contratos con workflow de aprobación.
|
||||
*
|
||||
* @module Contracts
|
||||
*/
|
||||
|
||||
import { Repository, FindOptionsWhere, LessThan } from 'typeorm';
|
||||
import { Contract, ContractStatus, ContractType } from '../entities/contract.entity';
|
||||
import { ContractAddendum } from '../entities/contract-addendum.entity';
|
||||
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
|
||||
|
||||
export interface CreateContractDto {
|
||||
projectId?: string;
|
||||
fraccionamientoId?: string;
|
||||
contractType: ContractType;
|
||||
clientContractType?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
clientName?: string;
|
||||
clientRfc?: string;
|
||||
clientAddress?: string;
|
||||
subcontractorId?: string;
|
||||
specialty?: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
contractAmount: number;
|
||||
currency?: string;
|
||||
paymentTerms?: string;
|
||||
retentionPercentage?: number;
|
||||
advancePercentage?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface CreateAddendumDto {
|
||||
addendumType: string;
|
||||
title: string;
|
||||
description: string;
|
||||
effectiveDate: Date;
|
||||
newEndDate?: Date;
|
||||
amountChange?: number;
|
||||
scopeChanges?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface ContractFilters {
|
||||
projectId?: string;
|
||||
fraccionamientoId?: string;
|
||||
contractType?: ContractType;
|
||||
subcontractorId?: string;
|
||||
status?: ContractStatus;
|
||||
expiringInDays?: number;
|
||||
}
|
||||
|
||||
export class ContractService {
|
||||
constructor(
|
||||
private readonly contractRepository: Repository<Contract>,
|
||||
private readonly addendumRepository: Repository<ContractAddendum>
|
||||
) {}
|
||||
|
||||
private generateContractNumber(type: ContractType): string {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear().toString().slice(-2);
|
||||
const month = (now.getMonth() + 1).toString().padStart(2, '0');
|
||||
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||
const prefix = type === 'client' ? 'CTR' : 'SUB';
|
||||
return `${prefix}-${year}${month}-${random}`;
|
||||
}
|
||||
|
||||
private generateAddendumNumber(contractNumber: string, sequence: number): string {
|
||||
return `${contractNumber}-ADD${sequence.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
async findWithFilters(
|
||||
ctx: ServiceContext,
|
||||
filters: ContractFilters = {},
|
||||
page: number = 1,
|
||||
limit: number = 20
|
||||
): Promise<PaginatedResult<Contract>> {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const queryBuilder = this.contractRepository
|
||||
.createQueryBuilder('c')
|
||||
.leftJoinAndSelect('c.createdBy', 'createdBy')
|
||||
.leftJoinAndSelect('c.approvedBy', 'approvedBy')
|
||||
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('c.deleted_at IS NULL');
|
||||
|
||||
if (filters.projectId) {
|
||||
queryBuilder.andWhere('c.project_id = :projectId', { projectId: filters.projectId });
|
||||
}
|
||||
|
||||
if (filters.fraccionamientoId) {
|
||||
queryBuilder.andWhere('c.fraccionamiento_id = :fraccionamientoId', {
|
||||
fraccionamientoId: filters.fraccionamientoId,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.contractType) {
|
||||
queryBuilder.andWhere('c.contract_type = :contractType', { contractType: filters.contractType });
|
||||
}
|
||||
|
||||
if (filters.subcontractorId) {
|
||||
queryBuilder.andWhere('c.subcontractor_id = :subcontractorId', {
|
||||
subcontractorId: filters.subcontractorId,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.status) {
|
||||
queryBuilder.andWhere('c.status = :status', { status: filters.status });
|
||||
}
|
||||
|
||||
if (filters.expiringInDays) {
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + filters.expiringInDays);
|
||||
queryBuilder.andWhere('c.end_date <= :futureDate', { futureDate });
|
||||
queryBuilder.andWhere('c.end_date >= :today', { today: new Date() });
|
||||
queryBuilder.andWhere('c.status = :activeStatus', { activeStatus: 'active' });
|
||||
}
|
||||
|
||||
queryBuilder
|
||||
.orderBy('c.created_at', 'DESC')
|
||||
.skip(skip)
|
||||
.take(limit);
|
||||
|
||||
const [data, total] = await queryBuilder.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async findById(ctx: ServiceContext, id: string): Promise<Contract | null> {
|
||||
return this.contractRepository.findOne({
|
||||
where: {
|
||||
id,
|
||||
tenantId: ctx.tenantId,
|
||||
deletedAt: null,
|
||||
} as unknown as FindOptionsWhere<Contract>,
|
||||
});
|
||||
}
|
||||
|
||||
async findWithDetails(ctx: ServiceContext, id: string): Promise<Contract | null> {
|
||||
return this.contractRepository.findOne({
|
||||
where: {
|
||||
id,
|
||||
tenantId: ctx.tenantId,
|
||||
deletedAt: null,
|
||||
} as unknown as FindOptionsWhere<Contract>,
|
||||
relations: ['createdBy', 'approvedBy', 'addendums'],
|
||||
});
|
||||
}
|
||||
|
||||
async create(ctx: ServiceContext, dto: CreateContractDto): Promise<Contract> {
|
||||
const contract = this.contractRepository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
createdById: ctx.userId,
|
||||
contractNumber: this.generateContractNumber(dto.contractType),
|
||||
projectId: dto.projectId,
|
||||
fraccionamientoId: dto.fraccionamientoId,
|
||||
contractType: dto.contractType,
|
||||
clientContractType: dto.clientContractType as any,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
clientName: dto.clientName,
|
||||
clientRfc: dto.clientRfc?.toUpperCase(),
|
||||
clientAddress: dto.clientAddress,
|
||||
subcontractorId: dto.subcontractorId,
|
||||
specialty: dto.specialty,
|
||||
startDate: dto.startDate,
|
||||
endDate: dto.endDate,
|
||||
contractAmount: dto.contractAmount,
|
||||
currency: dto.currency || 'MXN',
|
||||
paymentTerms: dto.paymentTerms,
|
||||
retentionPercentage: dto.retentionPercentage || 5,
|
||||
advancePercentage: dto.advancePercentage || 0,
|
||||
notes: dto.notes,
|
||||
status: 'draft',
|
||||
});
|
||||
|
||||
return this.contractRepository.save(contract);
|
||||
}
|
||||
|
||||
async submitForReview(ctx: ServiceContext, id: string): Promise<Contract | null> {
|
||||
const contract = await this.findById(ctx, id);
|
||||
if (!contract) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (contract.status !== 'draft') {
|
||||
throw new Error('Can only submit draft contracts for review');
|
||||
}
|
||||
|
||||
contract.status = 'review';
|
||||
contract.submittedAt = new Date();
|
||||
contract.submittedById = ctx.userId || '';
|
||||
contract.updatedById = ctx.userId || '';
|
||||
|
||||
return this.contractRepository.save(contract);
|
||||
}
|
||||
|
||||
async approveLegal(ctx: ServiceContext, id: string): Promise<Contract | null> {
|
||||
const contract = await this.findById(ctx, id);
|
||||
if (!contract) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (contract.status !== 'review') {
|
||||
throw new Error('Can only approve contracts in review');
|
||||
}
|
||||
|
||||
contract.legalApprovedAt = new Date();
|
||||
contract.legalApprovedById = ctx.userId || '';
|
||||
contract.updatedById = ctx.userId || '';
|
||||
|
||||
return this.contractRepository.save(contract);
|
||||
}
|
||||
|
||||
async approve(ctx: ServiceContext, id: string): Promise<Contract | null> {
|
||||
const contract = await this.findById(ctx, id);
|
||||
if (!contract) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (contract.status !== 'review') {
|
||||
throw new Error('Can only approve contracts in review');
|
||||
}
|
||||
|
||||
contract.status = 'approved';
|
||||
contract.approvedAt = new Date();
|
||||
contract.approvedById = ctx.userId || '';
|
||||
contract.updatedById = ctx.userId || '';
|
||||
|
||||
return this.contractRepository.save(contract);
|
||||
}
|
||||
|
||||
async activate(ctx: ServiceContext, id: string, signedDocumentUrl?: string): Promise<Contract | null> {
|
||||
const contract = await this.findById(ctx, id);
|
||||
if (!contract) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (contract.status !== 'approved') {
|
||||
throw new Error('Can only activate approved contracts');
|
||||
}
|
||||
|
||||
contract.status = 'active';
|
||||
contract.signedAt = new Date();
|
||||
if (signedDocumentUrl) {
|
||||
contract.signedDocumentUrl = signedDocumentUrl;
|
||||
}
|
||||
contract.updatedById = ctx.userId || '';
|
||||
|
||||
return this.contractRepository.save(contract);
|
||||
}
|
||||
|
||||
async complete(ctx: ServiceContext, id: string): Promise<Contract | null> {
|
||||
const contract = await this.findById(ctx, id);
|
||||
if (!contract) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (contract.status !== 'active') {
|
||||
throw new Error('Can only complete active contracts');
|
||||
}
|
||||
|
||||
contract.status = 'completed';
|
||||
contract.updatedById = ctx.userId || '';
|
||||
|
||||
return this.contractRepository.save(contract);
|
||||
}
|
||||
|
||||
async terminate(ctx: ServiceContext, id: string, reason: string): Promise<Contract | null> {
|
||||
const contract = await this.findById(ctx, id);
|
||||
if (!contract) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (contract.status !== 'active') {
|
||||
throw new Error('Can only terminate active contracts');
|
||||
}
|
||||
|
||||
contract.status = 'terminated';
|
||||
contract.terminatedAt = new Date();
|
||||
contract.terminationReason = reason;
|
||||
contract.updatedById = ctx.userId || '';
|
||||
|
||||
return this.contractRepository.save(contract);
|
||||
}
|
||||
|
||||
async updateProgress(
|
||||
ctx: ServiceContext,
|
||||
id: string,
|
||||
progressPercentage: number,
|
||||
invoicedAmount?: number,
|
||||
paidAmount?: number
|
||||
): Promise<Contract | null> {
|
||||
const contract = await this.findById(ctx, id);
|
||||
if (!contract) {
|
||||
return null;
|
||||
}
|
||||
|
||||
contract.progressPercentage = progressPercentage;
|
||||
if (invoicedAmount !== undefined) {
|
||||
contract.invoicedAmount = invoicedAmount;
|
||||
}
|
||||
if (paidAmount !== undefined) {
|
||||
contract.paidAmount = paidAmount;
|
||||
}
|
||||
contract.updatedById = ctx.userId || '';
|
||||
|
||||
return this.contractRepository.save(contract);
|
||||
}
|
||||
|
||||
async createAddendum(ctx: ServiceContext, contractId: string, dto: CreateAddendumDto): Promise<ContractAddendum> {
|
||||
const contract = await this.findWithDetails(ctx, contractId);
|
||||
if (!contract) {
|
||||
throw new Error('Contract not found');
|
||||
}
|
||||
|
||||
if (contract.status !== 'active') {
|
||||
throw new Error('Can only add addendums to active contracts');
|
||||
}
|
||||
|
||||
const sequence = (contract.addendums?.length || 0) + 1;
|
||||
|
||||
const addendum = this.addendumRepository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
createdById: ctx.userId,
|
||||
contractId,
|
||||
addendumNumber: this.generateAddendumNumber(contract.contractNumber, sequence),
|
||||
addendumType: dto.addendumType as any,
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
effectiveDate: dto.effectiveDate,
|
||||
newEndDate: dto.newEndDate,
|
||||
amountChange: dto.amountChange || 0,
|
||||
newContractAmount: dto.amountChange
|
||||
? Number(contract.contractAmount) + Number(dto.amountChange)
|
||||
: undefined,
|
||||
scopeChanges: dto.scopeChanges,
|
||||
notes: dto.notes,
|
||||
status: 'draft',
|
||||
});
|
||||
|
||||
return this.addendumRepository.save(addendum);
|
||||
}
|
||||
|
||||
async approveAddendum(ctx: ServiceContext, addendumId: string): Promise<ContractAddendum | null> {
|
||||
const addendum = await this.addendumRepository.findOne({
|
||||
where: {
|
||||
id: addendumId,
|
||||
tenantId: ctx.tenantId,
|
||||
} as FindOptionsWhere<ContractAddendum>,
|
||||
relations: ['contract'],
|
||||
});
|
||||
|
||||
if (!addendum) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (addendum.status !== 'draft' && addendum.status !== 'review') {
|
||||
throw new Error('Can only approve draft or review addendums');
|
||||
}
|
||||
|
||||
addendum.status = 'approved';
|
||||
addendum.approvedAt = new Date();
|
||||
addendum.approvedById = ctx.userId || '';
|
||||
addendum.updatedById = ctx.userId || '';
|
||||
|
||||
// Apply changes to contract
|
||||
if (addendum.newEndDate) {
|
||||
addendum.contract.endDate = addendum.newEndDate;
|
||||
}
|
||||
if (addendum.newContractAmount) {
|
||||
addendum.contract.contractAmount = addendum.newContractAmount;
|
||||
}
|
||||
await this.contractRepository.save(addendum.contract);
|
||||
|
||||
return this.addendumRepository.save(addendum);
|
||||
}
|
||||
|
||||
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
|
||||
const contract = await this.findById(ctx, id);
|
||||
if (!contract) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (contract.status === 'active') {
|
||||
throw new Error('Cannot delete active contracts');
|
||||
}
|
||||
|
||||
await this.contractRepository.update(
|
||||
{ id, tenantId: ctx.tenantId } as FindOptionsWhere<Contract>,
|
||||
{ deletedAt: new Date(), deletedById: ctx.userId || '' }
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async getExpiringContracts(ctx: ServiceContext, days: number = 30): Promise<Contract[]> {
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + days);
|
||||
|
||||
return this.contractRepository.find({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
status: 'active' as ContractStatus,
|
||||
endDate: LessThan(futureDate),
|
||||
deletedAt: null,
|
||||
} as unknown as FindOptionsWhere<Contract>,
|
||||
order: { endDate: 'ASC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
7
src/modules/contracts/services/index.ts
Normal file
7
src/modules/contracts/services/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Contracts Services Index
|
||||
* @module Contracts
|
||||
*/
|
||||
|
||||
export * from './contract.service';
|
||||
export * from './subcontractor.service';
|
||||
270
src/modules/contracts/services/subcontractor.service.ts
Normal file
270
src/modules/contracts/services/subcontractor.service.ts
Normal file
@ -0,0 +1,270 @@
|
||||
/**
|
||||
* SubcontractorService - Servicio de gestión de subcontratistas
|
||||
*
|
||||
* Catálogo de subcontratistas con evaluaciones.
|
||||
*
|
||||
* @module Contracts
|
||||
*/
|
||||
|
||||
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||
import { Subcontractor, SubcontractorStatus, SubcontractorSpecialty } from '../entities/subcontractor.entity';
|
||||
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
|
||||
|
||||
export interface CreateSubcontractorDto {
|
||||
businessName: string;
|
||||
tradeName?: string;
|
||||
rfc: string;
|
||||
address?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
contactName?: string;
|
||||
contactPhone?: string;
|
||||
primarySpecialty: SubcontractorSpecialty;
|
||||
secondarySpecialties?: string[];
|
||||
bankName?: string;
|
||||
bankAccount?: string;
|
||||
clabe?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateSubcontractorDto {
|
||||
tradeName?: string;
|
||||
address?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
contactName?: string;
|
||||
contactPhone?: string;
|
||||
secondarySpecialties?: string[];
|
||||
bankName?: string;
|
||||
bankAccount?: string;
|
||||
clabe?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface SubcontractorFilters {
|
||||
specialty?: SubcontractorSpecialty;
|
||||
status?: SubcontractorStatus;
|
||||
search?: string;
|
||||
minRating?: number;
|
||||
}
|
||||
|
||||
export class SubcontractorService {
|
||||
constructor(private readonly subcontractorRepository: Repository<Subcontractor>) {}
|
||||
|
||||
private generateCode(): string {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear().toString().slice(-2);
|
||||
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||
return `SC-${year}-${random}`;
|
||||
}
|
||||
|
||||
async findWithFilters(
|
||||
ctx: ServiceContext,
|
||||
filters: SubcontractorFilters = {},
|
||||
page: number = 1,
|
||||
limit: number = 20
|
||||
): Promise<PaginatedResult<Subcontractor>> {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const queryBuilder = this.subcontractorRepository
|
||||
.createQueryBuilder('sc')
|
||||
.leftJoinAndSelect('sc.createdBy', 'createdBy')
|
||||
.where('sc.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('sc.deleted_at IS NULL');
|
||||
|
||||
if (filters.specialty) {
|
||||
queryBuilder.andWhere('sc.primary_specialty = :specialty', { specialty: filters.specialty });
|
||||
}
|
||||
|
||||
if (filters.status) {
|
||||
queryBuilder.andWhere('sc.status = :status', { status: filters.status });
|
||||
}
|
||||
|
||||
if (filters.search) {
|
||||
queryBuilder.andWhere(
|
||||
'(sc.business_name ILIKE :search OR sc.trade_name ILIKE :search OR sc.rfc ILIKE :search)',
|
||||
{ search: `%${filters.search}%` }
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.minRating !== undefined) {
|
||||
queryBuilder.andWhere('sc.average_rating >= :minRating', { minRating: filters.minRating });
|
||||
}
|
||||
|
||||
queryBuilder
|
||||
.orderBy('sc.business_name', 'ASC')
|
||||
.skip(skip)
|
||||
.take(limit);
|
||||
|
||||
const [data, total] = await queryBuilder.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async findById(ctx: ServiceContext, id: string): Promise<Subcontractor | null> {
|
||||
return this.subcontractorRepository.findOne({
|
||||
where: {
|
||||
id,
|
||||
tenantId: ctx.tenantId,
|
||||
deletedAt: null,
|
||||
} as unknown as FindOptionsWhere<Subcontractor>,
|
||||
});
|
||||
}
|
||||
|
||||
async findByRfc(ctx: ServiceContext, rfc: string): Promise<Subcontractor | null> {
|
||||
return this.subcontractorRepository.findOne({
|
||||
where: {
|
||||
rfc: rfc.toUpperCase(),
|
||||
tenantId: ctx.tenantId,
|
||||
deletedAt: null,
|
||||
} as unknown as FindOptionsWhere<Subcontractor>,
|
||||
});
|
||||
}
|
||||
|
||||
async create(ctx: ServiceContext, dto: CreateSubcontractorDto): Promise<Subcontractor> {
|
||||
// Check for existing RFC
|
||||
const existing = await this.findByRfc(ctx, dto.rfc);
|
||||
if (existing) {
|
||||
throw new Error('A subcontractor with this RFC already exists');
|
||||
}
|
||||
|
||||
const subcontractor = this.subcontractorRepository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
createdById: ctx.userId,
|
||||
code: this.generateCode(),
|
||||
businessName: dto.businessName,
|
||||
tradeName: dto.tradeName,
|
||||
rfc: dto.rfc.toUpperCase(),
|
||||
address: dto.address,
|
||||
phone: dto.phone,
|
||||
email: dto.email,
|
||||
contactName: dto.contactName,
|
||||
contactPhone: dto.contactPhone,
|
||||
primarySpecialty: dto.primarySpecialty,
|
||||
secondarySpecialties: dto.secondarySpecialties,
|
||||
bankName: dto.bankName,
|
||||
bankAccount: dto.bankAccount,
|
||||
clabe: dto.clabe,
|
||||
notes: dto.notes,
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
return this.subcontractorRepository.save(subcontractor);
|
||||
}
|
||||
|
||||
async update(ctx: ServiceContext, id: string, dto: UpdateSubcontractorDto): Promise<Subcontractor | null> {
|
||||
const subcontractor = await this.findById(ctx, id);
|
||||
if (!subcontractor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Object.assign(subcontractor, {
|
||||
...dto,
|
||||
updatedById: ctx.userId || '',
|
||||
});
|
||||
|
||||
return this.subcontractorRepository.save(subcontractor);
|
||||
}
|
||||
|
||||
async updateRating(ctx: ServiceContext, id: string, rating: number): Promise<Subcontractor | null> {
|
||||
const subcontractor = await this.findById(ctx, id);
|
||||
if (!subcontractor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate new average rating
|
||||
const totalRatings = subcontractor.completedContracts;
|
||||
const currentTotal = subcontractor.averageRating * totalRatings;
|
||||
const newTotal = currentTotal + rating;
|
||||
subcontractor.averageRating = newTotal / (totalRatings + 1);
|
||||
subcontractor.updatedById = ctx.userId || '';
|
||||
|
||||
return this.subcontractorRepository.save(subcontractor);
|
||||
}
|
||||
|
||||
async incrementContracts(ctx: ServiceContext, id: string, completed: boolean = false): Promise<Subcontractor | null> {
|
||||
const subcontractor = await this.findById(ctx, id);
|
||||
if (!subcontractor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
subcontractor.totalContracts += 1;
|
||||
if (completed) {
|
||||
subcontractor.completedContracts += 1;
|
||||
}
|
||||
subcontractor.updatedById = ctx.userId || '';
|
||||
|
||||
return this.subcontractorRepository.save(subcontractor);
|
||||
}
|
||||
|
||||
async incrementIncidents(ctx: ServiceContext, id: string): Promise<Subcontractor | null> {
|
||||
const subcontractor = await this.findById(ctx, id);
|
||||
if (!subcontractor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
subcontractor.totalIncidents += 1;
|
||||
subcontractor.updatedById = ctx.userId || '';
|
||||
|
||||
return this.subcontractorRepository.save(subcontractor);
|
||||
}
|
||||
|
||||
async deactivate(ctx: ServiceContext, id: string): Promise<Subcontractor | null> {
|
||||
const subcontractor = await this.findById(ctx, id);
|
||||
if (!subcontractor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
subcontractor.status = 'inactive';
|
||||
subcontractor.updatedById = ctx.userId || '';
|
||||
|
||||
return this.subcontractorRepository.save(subcontractor);
|
||||
}
|
||||
|
||||
async blacklist(ctx: ServiceContext, id: string, reason: string): Promise<Subcontractor | null> {
|
||||
const subcontractor = await this.findById(ctx, id);
|
||||
if (!subcontractor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
subcontractor.status = 'blacklisted';
|
||||
subcontractor.notes = `${subcontractor.notes || ''}\n[BLACKLISTED] ${reason}`;
|
||||
subcontractor.updatedById = ctx.userId || '';
|
||||
|
||||
return this.subcontractorRepository.save(subcontractor);
|
||||
}
|
||||
|
||||
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
|
||||
const subcontractor = await this.findById(ctx, id);
|
||||
if (!subcontractor) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.subcontractorRepository.update(
|
||||
{ id, tenantId: ctx.tenantId } as FindOptionsWhere<Subcontractor>,
|
||||
{ deletedAt: new Date(), deletedById: ctx.userId || '' }
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async getBySpecialty(ctx: ServiceContext, specialty: SubcontractorSpecialty): Promise<Subcontractor[]> {
|
||||
return this.subcontractorRepository.find({
|
||||
where: {
|
||||
tenantId: ctx.tenantId,
|
||||
primarySpecialty: specialty,
|
||||
status: 'active' as SubcontractorStatus,
|
||||
deletedAt: null,
|
||||
} as unknown as FindOptionsWhere<Subcontractor>,
|
||||
order: { averageRating: 'DESC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
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';
|
||||
50
src/modules/core/entities/tenant.entity.ts
Normal file
50
src/modules/core/entities/tenant.entity.ts
Normal file
@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Tenant Entity
|
||||
* Entidad para multi-tenancy
|
||||
*
|
||||
* @module Core
|
||||
* @table core.tenants
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { User } from './user.entity';
|
||||
|
||||
@Entity({ schema: 'auth', name: 'tenants' })
|
||||
export class Tenant {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, unique: true })
|
||||
@Index()
|
||||
code: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 200 })
|
||||
name: string;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@Column({ type: 'jsonb', default: {} })
|
||||
settings: Record<string, unknown>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||
deletedAt: Date;
|
||||
|
||||
// Relations
|
||||
@OneToMany(() => User, (user) => user.tenant)
|
||||
users: User[];
|
||||
}
|
||||
78
src/modules/core/entities/user.entity.ts
Normal file
78
src/modules/core/entities/user.entity.ts
Normal file
@ -0,0 +1,78 @@
|
||||
/**
|
||||
* User Entity
|
||||
* Entidad de usuarios del sistema
|
||||
*
|
||||
* @module Core
|
||||
* @table core.users
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Tenant } from './tenant.entity';
|
||||
|
||||
@Entity({ schema: 'auth', name: 'users' })
|
||||
@Index(['tenantId', 'email'], { unique: true })
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid', nullable: true })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
email: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||
username: string;
|
||||
|
||||
@Column({ name: 'password_hash', type: 'varchar', length: 255, select: false })
|
||||
passwordHash: string;
|
||||
|
||||
@Column({ name: 'first_name', type: 'varchar', length: 100, nullable: true })
|
||||
firstName: string;
|
||||
|
||||
@Column({ name: 'last_name', type: 'varchar', length: 100, nullable: true })
|
||||
lastName: string;
|
||||
|
||||
@Column({ type: 'varchar', array: true, default: ['viewer'] })
|
||||
roles: string[];
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@Column({ name: 'last_login_at', type: 'timestamptz', nullable: true })
|
||||
lastLoginAt: Date;
|
||||
|
||||
@Column({ name: 'default_tenant_id', type: 'uuid', nullable: true })
|
||||
defaultTenantId: string;
|
||||
|
||||
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||
deletedAt: Date;
|
||||
|
||||
// Placeholder para relación de roles (se implementará en ST-004)
|
||||
userRoles?: { role: { code: string } }[];
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => Tenant, (tenant) => tenant.users)
|
||||
@JoinColumn({ name: 'tenant_id' })
|
||||
tenant: Tenant;
|
||||
|
||||
// Computed property
|
||||
get fullName(): string {
|
||||
return [this.firstName, this.lastName].filter(Boolean).join(' ') || this.email;
|
||||
}
|
||||
}
|
||||
324
src/modules/estimates/controllers/anticipo.controller.ts
Normal file
324
src/modules/estimates/controllers/anticipo.controller.ts
Normal file
@ -0,0 +1,324 @@
|
||||
/**
|
||||
* AnticipoController - Controller de anticipos de obra
|
||||
*
|
||||
* Endpoints REST para gestión de anticipos de contratos de construcción.
|
||||
*
|
||||
* @module Estimates
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AnticipoService, CreateAnticipoDto, AnticipoFilters } from '../services/anticipo.service';
|
||||
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
|
||||
import { AuthService } from '../../auth/services/auth.service';
|
||||
import { Anticipo } from '../entities/anticipo.entity';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
import { Tenant } from '../../core/entities/tenant.entity';
|
||||
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
|
||||
import { ServiceContext } from '../../../shared/services/base.service';
|
||||
|
||||
export function createAnticipoController(dataSource: DataSource): Router {
|
||||
const router = Router();
|
||||
|
||||
// Repositorios
|
||||
const anticipoRepository = dataSource.getRepository(Anticipo);
|
||||
const userRepository = dataSource.getRepository(User);
|
||||
const tenantRepository = dataSource.getRepository(Tenant);
|
||||
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
|
||||
|
||||
// Servicios
|
||||
const anticipoService = new AnticipoService(anticipoRepository);
|
||||
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
|
||||
const authMiddleware = new AuthMiddleware(authService, dataSource);
|
||||
|
||||
// Helper para crear contexto de servicio
|
||||
const getContext = (req: Request): ServiceContext => {
|
||||
if (!req.tenantId) {
|
||||
throw new Error('Tenant ID is required');
|
||||
}
|
||||
return {
|
||||
tenantId: req.tenantId,
|
||||
userId: req.user?.sub,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /anticipos
|
||||
*/
|
||||
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||
|
||||
const filters: AnticipoFilters = {};
|
||||
if (req.query.contratoId) filters.contratoId = req.query.contratoId as string;
|
||||
if (req.query.advanceType) filters.advanceType = req.query.advanceType as any;
|
||||
if (req.query.isFullyAmortized !== undefined) {
|
||||
filters.isFullyAmortized = req.query.isFullyAmortized === 'true';
|
||||
}
|
||||
if (req.query.startDate) filters.startDate = new Date(req.query.startDate as string);
|
||||
if (req.query.endDate) filters.endDate = new Date(req.query.endDate as string);
|
||||
|
||||
const result = await anticipoService.findWithFilters(getContext(req), filters, page, limit);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: result.meta,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /anticipos/stats
|
||||
*/
|
||||
router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const filters: AnticipoFilters = {};
|
||||
if (req.query.contratoId) filters.contratoId = req.query.contratoId as string;
|
||||
if (req.query.advanceType) filters.advanceType = req.query.advanceType as any;
|
||||
|
||||
const stats = await anticipoService.getStats(getContext(req), filters);
|
||||
res.status(200).json({ success: true, data: stats });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /anticipos/contrato/:contratoId
|
||||
*/
|
||||
router.get('/contrato/:contratoId', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||
|
||||
const result = await anticipoService.findByContrato(
|
||||
getContext(req),
|
||||
req.params.contratoId,
|
||||
page,
|
||||
limit
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: result.meta,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /anticipos/:id
|
||||
*/
|
||||
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const anticipo = await anticipoService.findById(getContext(req), req.params.id);
|
||||
if (!anticipo) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Anticipo not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: anticipo });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /anticipos
|
||||
*/
|
||||
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dto: CreateAnticipoDto = req.body;
|
||||
|
||||
if (!dto.contratoId || !dto.advanceType || !dto.advanceNumber) {
|
||||
res.status(400).json({
|
||||
error: 'Bad Request',
|
||||
message: 'contratoId, advanceType, and advanceNumber are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const anticipo = await anticipoService.createAnticipo(getContext(req), dto);
|
||||
res.status(201).json({ success: true, data: anticipo });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('no coincide')) {
|
||||
res.status(400).json({ error: 'Bad Request', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /anticipos/:id/approve
|
||||
*/
|
||||
router.post('/:id/approve', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const approvedById = req.user?.sub;
|
||||
if (!approvedById) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'User ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const anticipo = await anticipoService.approveAnticipo(
|
||||
getContext(req),
|
||||
req.params.id,
|
||||
approvedById,
|
||||
req.body.notes
|
||||
);
|
||||
|
||||
res.status(200).json({ success: true, data: anticipo });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === 'Anticipo no encontrado') {
|
||||
res.status(404).json({ error: 'Not Found', message: error.message });
|
||||
return;
|
||||
}
|
||||
if (error.message.includes('aprobado')) {
|
||||
res.status(400).json({ error: 'Bad Request', message: error.message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /anticipos/:id/pay
|
||||
*/
|
||||
router.post('/:id/pay', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'accountant'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { paymentReference, paidAt } = req.body;
|
||||
if (!paymentReference) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'paymentReference is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const anticipo = await anticipoService.markPaid(
|
||||
getContext(req),
|
||||
req.params.id,
|
||||
paymentReference,
|
||||
paidAt ? new Date(paidAt) : undefined
|
||||
);
|
||||
|
||||
res.status(200).json({ success: true, data: anticipo });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === 'Anticipo no encontrado') {
|
||||
res.status(404).json({ error: 'Not Found', message: error.message });
|
||||
return;
|
||||
}
|
||||
if (error.message.includes('aprobado') || error.message.includes('pagado')) {
|
||||
res.status(400).json({ error: 'Bad Request', message: error.message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /anticipos/:id/amortize
|
||||
*/
|
||||
router.post('/:id/amortize', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { amount } = req.body;
|
||||
if (amount === undefined || amount <= 0) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Valid amount is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const anticipo = await anticipoService.updateAmortization(
|
||||
getContext(req),
|
||||
req.params.id,
|
||||
amount
|
||||
);
|
||||
|
||||
res.status(200).json({ success: true, data: anticipo });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === 'Anticipo no encontrado') {
|
||||
res.status(404).json({ error: 'Not Found', message: error.message });
|
||||
return;
|
||||
}
|
||||
if (error.message.includes('amortizado') || error.message.includes('excede')) {
|
||||
res.status(400).json({ error: 'Bad Request', message: error.message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /anticipos/:id
|
||||
*/
|
||||
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = await anticipoService.softDelete(getContext(req), req.params.id);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Anticipo not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, message: 'Anticipo deleted' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
export default createAnticipoController;
|
||||
408
src/modules/estimates/controllers/estimacion.controller.ts
Normal file
408
src/modules/estimates/controllers/estimacion.controller.ts
Normal file
@ -0,0 +1,408 @@
|
||||
/**
|
||||
* EstimacionController - Controller de estimaciones de obra
|
||||
*
|
||||
* Endpoints REST para gestión de estimaciones periódicas.
|
||||
* Incluye workflow de aprobación: draft -> submitted -> reviewed -> approved -> invoiced -> paid
|
||||
*
|
||||
* @module Estimates
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import {
|
||||
EstimacionService,
|
||||
CreateEstimacionDto,
|
||||
AddConceptoDto,
|
||||
AddGeneradorDto,
|
||||
EstimacionFilters,
|
||||
} from '../services/estimacion.service';
|
||||
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
|
||||
import { AuthService } from '../../auth/services/auth.service';
|
||||
import { Estimacion } from '../entities/estimacion.entity';
|
||||
import { EstimacionConcepto } from '../entities/estimacion-concepto.entity';
|
||||
import { Generador } from '../entities/generador.entity';
|
||||
import { EstimacionWorkflow } from '../entities/estimacion-workflow.entity';
|
||||
import { User } from '../../core/entities/user.entity';
|
||||
import { Tenant } from '../../core/entities/tenant.entity';
|
||||
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
|
||||
import { ServiceContext } from '../../../shared/services/base.service';
|
||||
|
||||
/**
|
||||
* Crear router de estimaciones
|
||||
*/
|
||||
export function createEstimacionController(dataSource: DataSource): Router {
|
||||
const router = Router();
|
||||
|
||||
// Repositorios
|
||||
const estimacionRepository = dataSource.getRepository(Estimacion);
|
||||
const conceptoRepository = dataSource.getRepository(EstimacionConcepto);
|
||||
const generadorRepository = dataSource.getRepository(Generador);
|
||||
const workflowRepository = dataSource.getRepository(EstimacionWorkflow);
|
||||
const userRepository = dataSource.getRepository(User);
|
||||
const tenantRepository = dataSource.getRepository(Tenant);
|
||||
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
|
||||
|
||||
// Servicios
|
||||
const estimacionService = new EstimacionService(
|
||||
estimacionRepository,
|
||||
conceptoRepository,
|
||||
generadorRepository,
|
||||
workflowRepository,
|
||||
dataSource
|
||||
);
|
||||
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
|
||||
const authMiddleware = new AuthMiddleware(authService, dataSource);
|
||||
|
||||
// Helper para crear contexto de servicio
|
||||
const getContext = (req: Request): ServiceContext => {
|
||||
if (!req.tenantId) {
|
||||
throw new Error('Tenant ID is required');
|
||||
}
|
||||
return {
|
||||
tenantId: req.tenantId,
|
||||
userId: req.user?.sub,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /estimaciones
|
||||
* Listar estimaciones con filtros
|
||||
*/
|
||||
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||
|
||||
const filters: EstimacionFilters = {
|
||||
contratoId: req.query.contratoId as string,
|
||||
fraccionamientoId: req.query.fraccionamientoId as string,
|
||||
status: req.query.status as any,
|
||||
periodFrom: req.query.periodFrom ? new Date(req.query.periodFrom as string) : undefined,
|
||||
periodTo: req.query.periodTo ? new Date(req.query.periodTo as string) : undefined,
|
||||
};
|
||||
|
||||
const result = await estimacionService.findWithFilters(getContext(req), filters, page, limit);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: result.meta,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /estimaciones/summary/:contratoId
|
||||
* Obtener resumen de estimaciones por contrato
|
||||
*/
|
||||
router.get('/summary/:contratoId', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = await estimacionService.getContractSummary(getContext(req), req.params.contratoId);
|
||||
res.status(200).json({ success: true, data: summary });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /estimaciones/:id
|
||||
* Obtener estimación con detalles completos
|
||||
*/
|
||||
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const estimacion = await estimacionService.findWithDetails(getContext(req), req.params.id);
|
||||
if (!estimacion) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Estimate not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: estimacion });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /estimaciones
|
||||
* Crear estimación
|
||||
*/
|
||||
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dto: CreateEstimacionDto = req.body;
|
||||
|
||||
if (!dto.contratoId || !dto.fraccionamientoId || !dto.periodStart || !dto.periodEnd) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'contratoId, fraccionamientoId, periodStart and periodEnd are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const estimacion = await estimacionService.createEstimacion(getContext(req), dto);
|
||||
res.status(201).json({ success: true, data: estimacion });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /estimaciones/:id/conceptos
|
||||
* Agregar concepto a estimación
|
||||
*/
|
||||
router.post('/:id/conceptos', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dto: AddConceptoDto = req.body;
|
||||
|
||||
if (!dto.conceptoId || dto.quantityCurrent === undefined || dto.unitPrice === undefined) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'conceptoId, quantityCurrent and unitPrice are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const concepto = await estimacionService.addConcepto(getContext(req), req.params.id, dto);
|
||||
res.status(201).json({ success: true, data: concepto });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('non-draft')) {
|
||||
res.status(400).json({ error: 'Bad Request', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /estimaciones/conceptos/:conceptoId/generadores
|
||||
* Agregar generador a concepto de estimación
|
||||
*/
|
||||
router.post('/conceptos/:conceptoId/generadores', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dto: AddGeneradorDto = req.body;
|
||||
|
||||
if (!dto.generatorNumber || dto.quantity === undefined) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'generatorNumber and quantity are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const generador = await estimacionService.addGenerador(getContext(req), req.params.conceptoId, dto);
|
||||
res.status(201).json({ success: true, data: generador });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Concepto not found') {
|
||||
res.status(404).json({ error: 'Not Found', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /estimaciones/:id/submit
|
||||
* Enviar estimación para revisión
|
||||
*/
|
||||
router.post('/:id/submit', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const estimacion = await estimacionService.submit(getContext(req), req.params.id);
|
||||
if (!estimacion) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Cannot submit this estimate' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: estimacion, message: 'Estimate submitted for review' });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('Invalid status')) {
|
||||
res.status(400).json({ error: 'Bad Request', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /estimaciones/:id/review
|
||||
* Revisar estimación
|
||||
*/
|
||||
router.post('/:id/review', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const estimacion = await estimacionService.review(getContext(req), req.params.id);
|
||||
if (!estimacion) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Cannot review this estimate' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: estimacion, message: 'Estimate reviewed' });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('Invalid status')) {
|
||||
res.status(400).json({ error: 'Bad Request', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /estimaciones/:id/approve
|
||||
* Aprobar estimación
|
||||
*/
|
||||
router.post('/:id/approve', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const estimacion = await estimacionService.approve(getContext(req), req.params.id);
|
||||
if (!estimacion) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Cannot approve this estimate' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: estimacion, message: 'Estimate approved' });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('Invalid status')) {
|
||||
res.status(400).json({ error: 'Bad Request', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /estimaciones/:id/reject
|
||||
* Rechazar estimación
|
||||
*/
|
||||
router.post('/:id/reject', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { reason } = req.body;
|
||||
if (!reason) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'reason is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const estimacion = await estimacionService.reject(getContext(req), req.params.id, reason);
|
||||
if (!estimacion) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Cannot reject this estimate' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: estimacion, message: 'Estimate rejected' });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('Invalid status')) {
|
||||
res.status(400).json({ error: 'Bad Request', message: error.message });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /estimaciones/:id/recalculate
|
||||
* Recalcular totales de estimación
|
||||
*/
|
||||
router.post('/:id/recalculate', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
await estimacionService.recalculateTotals(getContext(req), req.params.id);
|
||||
const estimacion = await estimacionService.findWithDetails(getContext(req), req.params.id);
|
||||
|
||||
res.status(200).json({ success: true, data: estimacion, message: 'Totals recalculated' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /estimaciones/:id
|
||||
* Eliminar estimación (solo draft)
|
||||
*/
|
||||
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const estimacion = await estimacionService.findById(getContext(req), req.params.id);
|
||||
if (!estimacion) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Estimate not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (estimacion.status !== 'draft') {
|
||||
res.status(400).json({ error: 'Bad Request', message: 'Only draft estimates can be deleted' });
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = await estimacionService.softDelete(getContext(req), req.params.id);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Not Found', message: 'Estimate not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, message: 'Estimate deleted' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
export default createEstimacionController;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user