Some checks are pending
CI Pipeline / changes (push) Waiting to run
CI Pipeline / core (push) Blocked by required conditions
CI Pipeline / trading-backend (push) Blocked by required conditions
CI Pipeline / trading-data-service (push) Blocked by required conditions
CI Pipeline / trading-frontend (push) Blocked by required conditions
CI Pipeline / erp-core (push) Blocked by required conditions
CI Pipeline / erp-mecanicas (push) Blocked by required conditions
CI Pipeline / gamilit-backend (push) Blocked by required conditions
CI Pipeline / gamilit-frontend (push) Blocked by required conditions
14 KiB
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_atpara 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