erp-construccion/docs/ARCHITECTURE.md

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