Sistema NEXUS v3.4 migrado con: Estructura principal: - core/orchestration: Sistema SIMCO + CAPVED (27 directivas, 28 perfiles) - core/catalog: Catalogo de funcionalidades reutilizables - shared/knowledge-base: Base de conocimiento compartida - devtools/scripts: Herramientas de desarrollo - control-plane/registries: Control de servicios y CI/CD - orchestration/: Configuracion de orquestacion de agentes Proyectos incluidos (11): - gamilit (submodule -> GitHub) - trading-platform (OrbiquanTIA) - erp-suite con 5 verticales: - erp-core, construccion, vidrio-templado - mecanicas-diesel, retail, clinicas - betting-analytics - inmobiliaria-analytics - platform_marketing_content - pos-micro, erp-basico Configuracion: - .gitignore completo para Node.js/Python/Docker - gamilit como submodule (git@github.com:rckrdmrd/gamilit-workspace.git) - Sistema de puertos estandarizado (3005-3199) Generated with NEXUS v3.4 Migration System EPIC-010: Configuracion Git y Repositorios
648 lines
14 KiB
Markdown
648 lines
14 KiB
Markdown
# Arquitectura Tecnica - ERP Construccion
|
|
|
|
**Version:** 1.0.0
|
|
**Fecha:** 2025-12-12
|
|
**Para:** Desarrolladores
|
|
|
|
---
|
|
|
|
## Stack Tecnologico
|
|
|
|
| Capa | Tecnologia | Version |
|
|
|------|------------|---------|
|
|
| **Runtime** | Node.js | 20 LTS |
|
|
| **Backend Framework** | Express.js | 4.x |
|
|
| **ORM** | TypeORM | 0.3.x |
|
|
| **Base de Datos** | PostgreSQL + PostGIS | 15 |
|
|
| **Cache** | Redis | 7 |
|
|
| **Frontend** | React + Vite | 18.x / 5.x |
|
|
| **Lenguaje** | TypeScript | 5.x |
|
|
| **Validacion** | class-validator | 0.14.x |
|
|
| **Autenticacion** | JWT + Refresh Tokens | - |
|
|
|
|
---
|
|
|
|
## Estructura de Directorios
|
|
|
|
```
|
|
construccion/
|
|
├── backend/
|
|
│ ├── src/
|
|
│ │ ├── modules/ # Modulos de dominio
|
|
│ │ │ ├── auth/ # Autenticacion JWT
|
|
│ │ │ ├── budgets/ # MAI-003 Presupuestos
|
|
│ │ │ ├── progress/ # MAI-005 Control de Obra
|
|
│ │ │ ├── estimates/ # MAI-008 Estimaciones
|
|
│ │ │ ├── construction/ # MAI-002 Proyectos
|
|
│ │ │ ├── hr/ # MAI-007 RRHH
|
|
│ │ │ ├── hse/ # MAA-017 HSE
|
|
│ │ │ └── core/ # User, Tenant
|
|
│ │ │
|
|
│ │ ├── shared/
|
|
│ │ │ ├── constants/ # SSOT - Constantes centralizadas
|
|
│ │ │ │ ├── database.constants.ts
|
|
│ │ │ │ ├── api.constants.ts
|
|
│ │ │ │ ├── enums.constants.ts
|
|
│ │ │ │ └── index.ts
|
|
│ │ │ ├── services/
|
|
│ │ │ │ └── base.service.ts
|
|
│ │ │ └── database/
|
|
│ │ │ └── typeorm.config.ts
|
|
│ │ │
|
|
│ │ ├── config/ # Configuracion de app
|
|
│ │ ├── types/ # Tipos globales
|
|
│ │ └── server.ts # Entry point
|
|
│ │
|
|
│ ├── package.json
|
|
│ └── tsconfig.json
|
|
│
|
|
├── frontend/
|
|
│ └── web/ # App React
|
|
│
|
|
├── database/
|
|
│ └── schemas/ # DDL SQL
|
|
│ ├── 01-construction-schema-ddl.sql
|
|
│ ├── 02-hr-schema-ddl.sql
|
|
│ ├── 03-hse-schema-ddl.sql
|
|
│ ├── 04-estimates-schema-ddl.sql
|
|
│ ├── 05-infonavit-schema-ddl.sql
|
|
│ ├── 06-inventory-ext-schema-ddl.sql
|
|
│ └── 07-purchase-ext-schema-ddl.sql
|
|
│
|
|
├── docs/ # Documentacion
|
|
├── devops/ # Scripts CI/CD
|
|
└── docker-compose.yml
|
|
```
|
|
|
|
---
|
|
|
|
## Arquitectura Multi-tenant
|
|
|
|
### Row Level Security (RLS)
|
|
|
|
El sistema utiliza RLS de PostgreSQL para aislamiento de datos por tenant.
|
|
|
|
```sql
|
|
-- Ejemplo de politica RLS
|
|
CREATE POLICY tenant_isolation ON construction.fraccionamientos
|
|
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
|
|
```
|
|
|
|
### Contexto de Tenant
|
|
|
|
```typescript
|
|
// El middleware establece el contexto RLS
|
|
await dataSource.query(`
|
|
SELECT set_config('app.current_tenant_id', $1, true)
|
|
`, [tenantId]);
|
|
```
|
|
|
|
### ServiceContext
|
|
|
|
Todas las operaciones de servicio requieren un contexto:
|
|
|
|
```typescript
|
|
interface ServiceContext {
|
|
tenantId: string; // UUID del tenant actual
|
|
userId: string; // UUID del usuario autenticado
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Patrones de Codigo
|
|
|
|
### 1. Entity Pattern
|
|
|
|
```typescript
|
|
// Convencion de entidades TypeORM
|
|
@Entity({ schema: 'construction', name: 'conceptos' })
|
|
@Index(['tenantId', 'code'], { unique: true })
|
|
export class Concepto {
|
|
// Primary key UUID
|
|
@PrimaryGeneratedColumn('uuid')
|
|
id: string;
|
|
|
|
// Tenant discriminador (siempre presente)
|
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
|
tenantId: string;
|
|
|
|
// Columnas con snake_case en DB
|
|
@Column({ name: 'created_at', type: 'timestamptz', default: () => 'NOW()' })
|
|
createdAt: Date;
|
|
|
|
// Soft delete
|
|
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
|
deletedAt: Date | null;
|
|
|
|
// Relaciones
|
|
@ManyToOne(() => Tenant)
|
|
@JoinColumn({ name: 'tenant_id' })
|
|
tenant: Tenant;
|
|
}
|
|
```
|
|
|
|
### 2. Service Pattern
|
|
|
|
```typescript
|
|
// Extender BaseService para CRUD automatico
|
|
export class ConceptoService extends BaseService<Concepto> {
|
|
constructor(
|
|
repository: Repository<Concepto>,
|
|
private readonly presupuestoRepo: Repository<Presupuesto>
|
|
) {
|
|
super(repository);
|
|
}
|
|
|
|
// Metodos especificos del dominio
|
|
async findRootConceptos(ctx: ServiceContext): Promise<Concepto[]> {
|
|
return this.repository.find({
|
|
where: {
|
|
tenantId: ctx.tenantId,
|
|
parentId: IsNull(),
|
|
deletedAt: IsNull(),
|
|
},
|
|
order: { code: 'ASC' },
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
### 3. BaseService
|
|
|
|
```typescript
|
|
// Metodos disponibles en BaseService<T>
|
|
abstract class BaseService<T> {
|
|
// Lectura
|
|
findAll(ctx, options?): Promise<PaginatedResult<T>>
|
|
findById(ctx, id): Promise<T | null>
|
|
findOne(ctx, where): Promise<T | null>
|
|
find(ctx, options): Promise<T[]>
|
|
count(ctx, where?): Promise<number>
|
|
exists(ctx, where): Promise<boolean>
|
|
|
|
// Escritura
|
|
create(ctx, data): Promise<T>
|
|
update(ctx, id, data): Promise<T | null>
|
|
softDelete(ctx, id): Promise<boolean>
|
|
hardDelete(ctx, id): Promise<boolean>
|
|
}
|
|
```
|
|
|
|
### 4. DTO Pattern
|
|
|
|
```typescript
|
|
// DTOs con class-validator
|
|
export class CreateConceptoDto {
|
|
@IsString()
|
|
@MinLength(1)
|
|
@MaxLength(20)
|
|
code: string;
|
|
|
|
@IsString()
|
|
@MinLength(1)
|
|
@MaxLength(200)
|
|
name: string;
|
|
|
|
@IsOptional()
|
|
@IsUUID()
|
|
parentId?: string;
|
|
|
|
@IsOptional()
|
|
@IsNumber({ maxDecimalPlaces: 4 })
|
|
@Min(0)
|
|
unitPrice?: number;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Workflows de Estado
|
|
|
|
### Patron de Workflow
|
|
|
|
```typescript
|
|
// Entidad con estado y workflow
|
|
@Entity({ schema: 'estimates', name: 'estimaciones' })
|
|
export class Estimacion {
|
|
@Column({
|
|
type: 'enum',
|
|
enum: EstimacionStatus,
|
|
default: EstimacionStatus.DRAFT,
|
|
})
|
|
status: EstimacionStatus;
|
|
|
|
// Timestamps de workflow
|
|
@Column({ name: 'submitted_at', type: 'timestamptz', nullable: true })
|
|
submittedAt: Date | null;
|
|
|
|
@Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true })
|
|
reviewedAt: Date | null;
|
|
|
|
@Column({ name: 'approved_at', type: 'timestamptz', nullable: true })
|
|
approvedAt: Date | null;
|
|
|
|
@Column({ name: 'rejected_at', type: 'timestamptz', nullable: true })
|
|
rejectedAt: Date | null;
|
|
}
|
|
```
|
|
|
|
### Transiciones Validas
|
|
|
|
```typescript
|
|
// Servicio con metodos de transicion
|
|
export class EstimacionService extends BaseService<Estimacion> {
|
|
async submit(ctx: ServiceContext, id: string): Promise<Estimacion | null> {
|
|
const estimacion = await this.findById(ctx, id);
|
|
if (!estimacion || estimacion.status !== EstimacionStatus.DRAFT) {
|
|
return null;
|
|
}
|
|
|
|
return this.update(ctx, id, {
|
|
status: EstimacionStatus.SUBMITTED,
|
|
submittedAt: new Date(),
|
|
submittedById: ctx.userId,
|
|
});
|
|
}
|
|
|
|
async approve(ctx: ServiceContext, id: string): Promise<Estimacion | null> {
|
|
const estimacion = await this.findById(ctx, id);
|
|
if (!estimacion || estimacion.status !== EstimacionStatus.REVIEWED) {
|
|
return null;
|
|
}
|
|
|
|
return this.update(ctx, id, {
|
|
status: EstimacionStatus.APPROVED,
|
|
approvedAt: new Date(),
|
|
approvedById: ctx.userId,
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Autenticacion y Autorizacion
|
|
|
|
### JWT Flow
|
|
|
|
```
|
|
1. Cliente envia credenciales → POST /auth/login
|
|
2. Backend valida y genera:
|
|
- Access Token (15min)
|
|
- Refresh Token (7 dias)
|
|
3. Cliente almacena tokens
|
|
4. Requests incluyen: Authorization: Bearer <access_token>
|
|
5. Al expirar access token: POST /auth/refresh
|
|
```
|
|
|
|
### Middleware de Auth
|
|
|
|
```typescript
|
|
// Uso en rutas
|
|
router.get('/conceptos',
|
|
AuthMiddleware.authenticate, // Requerido: validar JWT
|
|
AuthMiddleware.authorize('admin', 'engineer'), // Opcional: roles
|
|
conceptosController.findAll
|
|
);
|
|
|
|
// Para rutas publicas
|
|
router.get('/health',
|
|
AuthMiddleware.optionalAuthenticate,
|
|
healthController.check
|
|
);
|
|
```
|
|
|
|
### Roles del Sistema
|
|
|
|
| Rol | Permisos |
|
|
|-----|----------|
|
|
| `super_admin` | Todo el sistema |
|
|
| `admin` | Todo dentro del tenant |
|
|
| `project_manager` | Proyectos asignados |
|
|
| `supervisor` | Control de obra |
|
|
| `engineer` | Operaciones de campo |
|
|
| `accountant` | Estimaciones y finanzas |
|
|
| `viewer` | Solo lectura |
|
|
|
|
---
|
|
|
|
## Base de Datos
|
|
|
|
### Schemas PostgreSQL
|
|
|
|
| Schema | Tablas | Descripcion |
|
|
|--------|--------|-------------|
|
|
| `auth` | 10 | Usuarios, roles, tokens |
|
|
| `construction` | 24 | Proyectos, presupuestos |
|
|
| `hr` | 8 | Empleados, asistencias |
|
|
| `hse` | 58 | Seguridad industrial |
|
|
| `estimates` | 8 | Estimaciones |
|
|
| `infonavit` | 8 | Integracion INFONAVIT |
|
|
| `inventory` | 4 | Inventarios |
|
|
|
|
### Convenciones de Tablas
|
|
|
|
- Nombres en **snake_case** plural: `conceptos`, `presupuestos`
|
|
- Todas tienen `tenant_id` (excepto auth global)
|
|
- Todas tienen `deleted_at` para soft delete
|
|
- Indices en `(tenant_id, ...)` para performance
|
|
|
|
### Tipos Especiales PostgreSQL
|
|
|
|
```sql
|
|
-- Columnas generadas (calculated)
|
|
total_amount DECIMAL(18,2) GENERATED ALWAYS AS (quantity * unit_price) STORED
|
|
|
|
-- Tipos geometricos (PostGIS)
|
|
location GEOGRAPHY(POINT, 4326)
|
|
|
|
-- Enums
|
|
status estimate_status_enum NOT NULL DEFAULT 'draft'
|
|
```
|
|
|
|
---
|
|
|
|
## SSOT (Single Source of Truth)
|
|
|
|
### database.constants.ts
|
|
|
|
```typescript
|
|
// Schemas
|
|
export const DB_SCHEMAS = {
|
|
AUTH: 'auth',
|
|
CONSTRUCTION: 'construction',
|
|
HR: 'hr',
|
|
HSE: 'hse',
|
|
ESTIMATES: 'estimates',
|
|
} as const;
|
|
|
|
// Tablas
|
|
export const DB_TABLES = {
|
|
construction: {
|
|
CONCEPTOS: 'conceptos',
|
|
PRESUPUESTOS: 'presupuestos',
|
|
FRACCIONAMIENTOS: 'fraccionamientos',
|
|
},
|
|
// ...
|
|
} as const;
|
|
|
|
// Referencias completas
|
|
export const TABLE_REFS = {
|
|
CONCEPTOS: 'construction.conceptos',
|
|
PRESUPUESTOS: 'construction.presupuestos',
|
|
} as const;
|
|
```
|
|
|
|
### api.constants.ts
|
|
|
|
```typescript
|
|
export const API_ROUTES = {
|
|
AUTH: {
|
|
BASE: '/api/v1/auth',
|
|
LOGIN: '/api/v1/auth/login',
|
|
REGISTER: '/api/v1/auth/register',
|
|
REFRESH: '/api/v1/auth/refresh',
|
|
},
|
|
PRESUPUESTOS: {
|
|
BASE: '/api/v1/presupuestos',
|
|
BY_ID: (id: string) => `/api/v1/presupuestos/${id}`,
|
|
},
|
|
} as const;
|
|
```
|
|
|
|
### enums.constants.ts
|
|
|
|
```typescript
|
|
export const ROLES = {
|
|
SUPER_ADMIN: 'super_admin',
|
|
ADMIN: 'admin',
|
|
PROJECT_MANAGER: 'project_manager',
|
|
// ...
|
|
} as const;
|
|
|
|
export const ESTIMATE_STATUS = {
|
|
DRAFT: 'draft',
|
|
SUBMITTED: 'submitted',
|
|
REVIEWED: 'reviewed',
|
|
APPROVED: 'approved',
|
|
REJECTED: 'rejected',
|
|
} as const;
|
|
```
|
|
|
|
---
|
|
|
|
## Testing
|
|
|
|
### Estructura de Tests
|
|
|
|
```
|
|
backend/
|
|
├── src/
|
|
│ └── modules/
|
|
│ └── budgets/
|
|
│ └── services/
|
|
│ └── concepto.service.spec.ts
|
|
└── test/
|
|
├── setup.ts # Jest setup
|
|
└── fixtures/ # Datos de prueba
|
|
```
|
|
|
|
### Ejemplo de Test
|
|
|
|
```typescript
|
|
describe('ConceptoService', () => {
|
|
let service: ConceptoService;
|
|
let mockRepo: jest.Mocked<Repository<Concepto>>;
|
|
const ctx: ServiceContext = {
|
|
tenantId: 'tenant-uuid',
|
|
userId: 'user-uuid',
|
|
};
|
|
|
|
beforeEach(() => {
|
|
mockRepo = createMockRepository();
|
|
service = new ConceptoService(mockRepo);
|
|
});
|
|
|
|
it('should create concepto with level 0 for root', async () => {
|
|
const dto = { code: '001', name: 'Test' };
|
|
mockRepo.save.mockResolvedValue({
|
|
id: 'uuid',
|
|
...dto,
|
|
level: 0,
|
|
tenantId: ctx.tenantId,
|
|
});
|
|
|
|
const result = await service.createConcepto(ctx, dto);
|
|
|
|
expect(result.level).toBe(0);
|
|
expect(result.tenantId).toBe(ctx.tenantId);
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Docker
|
|
|
|
### docker-compose.yml
|
|
|
|
```yaml
|
|
services:
|
|
postgres:
|
|
image: postgis/postgis:15-3.3
|
|
ports:
|
|
- "5432:5432"
|
|
environment:
|
|
POSTGRES_DB: erp_construccion
|
|
POSTGRES_USER: construccion
|
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
|
|
|
redis:
|
|
image: redis:7-alpine
|
|
ports:
|
|
- "6379:6379"
|
|
|
|
backend:
|
|
build: ./backend
|
|
ports:
|
|
- "3000:3000"
|
|
depends_on:
|
|
- postgres
|
|
- redis
|
|
environment:
|
|
NODE_ENV: development
|
|
DB_HOST: postgres
|
|
```
|
|
|
|
### Comandos Docker
|
|
|
|
```bash
|
|
# Desarrollo
|
|
docker-compose up -d
|
|
|
|
# Con herramientas dev (Adminer, Mailhog)
|
|
docker-compose --profile dev up -d
|
|
|
|
# Logs
|
|
docker-compose logs -f backend
|
|
|
|
# Rebuild
|
|
docker-compose build --no-cache backend
|
|
```
|
|
|
|
---
|
|
|
|
## CI/CD
|
|
|
|
### GitHub Actions Pipeline
|
|
|
|
```yaml
|
|
# .github/workflows/ci.yml
|
|
jobs:
|
|
lint:
|
|
- npm run lint
|
|
- npm run validate:constants
|
|
|
|
test:
|
|
- npm run test:coverage
|
|
|
|
build:
|
|
- npm run build
|
|
- docker build
|
|
```
|
|
|
|
### Scripts de Validacion
|
|
|
|
```bash
|
|
# Validar que no hay hardcoding de constantes
|
|
npm run validate:constants
|
|
|
|
# Sincronizar enums backend → frontend
|
|
npm run sync:enums
|
|
|
|
# Pre-commit hook
|
|
npm run precommit # lint + validate
|
|
```
|
|
|
|
---
|
|
|
|
## Performance
|
|
|
|
### Indices Recomendados
|
|
|
|
```sql
|
|
-- Siempre indexar tenant_id
|
|
CREATE INDEX idx_conceptos_tenant ON construction.conceptos(tenant_id);
|
|
|
|
-- Indices compuestos para queries frecuentes
|
|
CREATE INDEX idx_conceptos_tenant_code
|
|
ON construction.conceptos(tenant_id, code);
|
|
|
|
-- Indices parciales para soft delete
|
|
CREATE INDEX idx_conceptos_active
|
|
ON construction.conceptos(tenant_id)
|
|
WHERE deleted_at IS NULL;
|
|
```
|
|
|
|
### Query Optimization
|
|
|
|
```typescript
|
|
// Eager loading para evitar N+1
|
|
const presupuesto = await this.repository.findOne({
|
|
where: { id, tenantId: ctx.tenantId },
|
|
relations: ['partidas', 'partidas.concepto'],
|
|
});
|
|
|
|
// Select especifico
|
|
const totals = await this.repository
|
|
.createQueryBuilder('p')
|
|
.select('SUM(p.totalAmount)', 'total')
|
|
.where('p.tenantId = :tenantId', { tenantId: ctx.tenantId })
|
|
.getRawOne();
|
|
```
|
|
|
|
---
|
|
|
|
## Debugging
|
|
|
|
### VS Code Launch Config
|
|
|
|
```json
|
|
{
|
|
"type": "node",
|
|
"request": "launch",
|
|
"name": "Debug Backend",
|
|
"runtimeArgs": ["-r", "ts-node/register"],
|
|
"args": ["${workspaceFolder}/backend/src/server.ts"],
|
|
"env": {
|
|
"NODE_ENV": "development",
|
|
"LOG_LEVEL": "debug"
|
|
}
|
|
}
|
|
```
|
|
|
|
### Logs
|
|
|
|
```bash
|
|
# Variables de entorno
|
|
LOG_LEVEL=debug # debug | info | warn | error
|
|
LOG_FORMAT=dev # dev | json
|
|
|
|
# Ver SQL queries
|
|
TYPEORM_LOGGING=true
|
|
```
|
|
|
|
---
|
|
|
|
## Referencias
|
|
|
|
- [Backend README](../backend/README.md)
|
|
- [API OpenAPI Spec](./api/openapi.yaml)
|
|
- [Arquitectura SaaS](./00-vision-general/ARQUITECTURA-SAAS.md)
|
|
- [Mapa de Base de Datos](../database/_MAP.md)
|
|
|
|
---
|
|
|
|
**Ultima actualizacion:** 2025-12-12
|