erp-construccion/docs/ARCHITECTURE.md

14 KiB

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.

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

// 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:

interface ServiceContext {
  tenantId: string;  // UUID del tenant actual
  userId: string;    // UUID del usuario autenticado
}

Patrones de Codigo

1. Entity Pattern

// 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

// 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

// 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

// 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

// 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

// 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

// 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

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

// 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

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

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

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

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

# 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

# .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

# 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

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

// 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

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

# Variables de entorno
LOG_LEVEL=debug      # debug | info | warn | error
LOG_FORMAT=dev       # dev | json

# Ver SQL queries
TYPEORM_LOGGING=true

Referencias


Ultima actualizacion: 2025-12-12