ConTodo ERP — Modelo de Datos y Diagrama Entidad-Relación (ERD) Multi-Tenant
ConTodo ERP — Modelo de Datos y ERD
Propósito. Este entregable consolida el modelo de datos canónico de ConTodo, un ERP SaaS multi-tenant para PYMEs y medianas de Perú y LATAM (textiles, importadoras, comercializadoras, distribuidoras y manufactura ligera). Define (1) las entidades principales por bounded context, (2) los ERD en
mermaid (erDiagram)con atributos y relaciones clave, (3) la estrategia canónica detenant_id—la decisión de modelado menos reversible del sistema— y (4) las convenciones transversales (PK, monetarios, multimoneda, particionado, inmutabilidad contable, auditoría).
Disclaimer de modelado. El modelo se deriva de los entregables de arquitectura (Solution Architect, Rails Principal Engineer), del modelo contable (PCGE 2019), del módulo textil (BOM/costeo) e importaciones (costeo nacionalizado), y reconcilia las contradicciones detectadas en los debates de tecnología y dominio (notablemente el conflicto
tenant_idvscompany_idy el tipo de la clave). Anti-overclaiming: ningún esquema es "el correcto" en abstracto; este se optimiza para el perfil real de ConTodo (miles de tenants PYME, integridad contable ACID, compliance SUNAT). Todo cambio al modelo de tenancy tras tener cientos de tenants en producción es un proyecto de meses, por eso se trata con el máximo rigor.
1. Decisiones de modelado transversales (contrato de datos)
Estas reglas aplican a todas las tablas tenantizadas del sistema. Resuelven explícitamente las contradicciones que los entregados individuales dejaron abiertas.
| # | Decisión canónica | Justificación | Alternativa descartada |
|---|---|---|---|
| D1 | Nombre de la columna de tenant: tenant_id (no company_id). | Cuatro de los siete agentes técnicos (Architect, IA, Data, Ciberseguridad) ya usan tenant_id; es el término de dominio SaaS estándar y desacopla el concepto de tenancy del de "empresa contable". company queda como atributo de negocio (la persona jurídica/RUC). | company_id: confunde tenancy con la entidad Company; obliga a renombrar al introducir holdings multi-RUC bajo un mismo tenant. |
| D2 | Tipo: BIGINT para PK y FK internas; UUID solo para identificadores expuestos (public_id, event_id, claims JWT). | BIGINT da índices compactos, joins rápidos y es amigable con sharding por rango/cohorte. UUID público evita enumeración y fuga de volumen de negocio. Mezclar tipos rompe los casts de RLS en runtime (riesgo RT10 del debate técnico). | UUID en todas las PK: índices ~2x más grandes, fragmentación de B-tree, current_setting(...)::uuid frágil. |
| D3 | RLS de PostgreSQL como autoridad de aislamiento, con tenant_id NOT NULL en cada tabla y default_scope Rails como primera barrera. | Defensa en profundidad: aunque un dev olvide el WHERE, el motor devuelve 0 filas. | Solo default_scope ActiveRecord: un bug = data leak cross-tenant (evento de extinción de marca). |
| D4 | Propagación de tenant vía SET LOCAL app.current_tenant dentro de transacción explícita; toda lectura/escritura corre en transacción. | SET LOCAL es transaccional y sobrevive a PgBouncer/RDS Proxy en modo transaction; un SET de sesión filtraría contexto entre tenants al multiplexar conexiones (el bug más peligroso identificado en el debate). | SET de sesión: incompatible con pooling transaccional. |
| D5 | Particionado declarativo por HASH(tenant_id) en tablas calientes (kardex_entries, journal_entry_lines, electronic_documents); subparticionado por RANGE(period) donde el volumen lo exija. Tenants power-law (5% → 50% carga) se promueven a Silo (DB dedicada vía connects_to). | Contiene noisy-neighbor y acelera VACUUM; el HASH balancea mejor que LIST ante distribución desigual. La promoción a Silo es la mitigación real del tenant gigante. | LIST(tenant_id): particiones desbalanceadas con power-law. |
| D6 | Montos: NUMERIC(16,2) (montos), NUMERIC(18,6) (cantidades/costos unitarios), NUMERIC(12,6) (tipo de cambio). Nunca float. | Exactitud contable: la partida doble no tolera errores de coma flotante. | float/money: redondeos no deterministas. |
| D7 | Multimoneda por línea: monto en moneda origen (*_oc) + tipo de cambio + monto en moneda funcional PEN (*_fc) persistidos (NIC 21). | SUNAT exige libros en soles al TC de la fecha; recalcular al vuelo es inconsistente. | Conversión en reportes. |
| D8 | Inmutabilidad de documentos fiscales/contables (status=posted ⇒ no editable; se corrige por extorno). Tablas audit_logs append-only por tenant. | Trazabilidad PLE/SIRE y auditoría Big Four. | Edición in-place: rompe la trazabilidad tributaria. |
| D9 | Soft-delete + deleted_at en maestros (productos, clientes, cuentas); nunca borrado físico de comprobantes (conservación 5 años, Art. 87 CT). | Cumplimiento de conservación y "derecho al olvido" gestionado por export+anonimización, no DELETE. | Hard delete. |
| D10 | Auditoría temporal estándar: created_at, updated_at, created_by_id, updated_by_id, lock_version (optimistic locking) en toda tabla transaccional. | Concurrencia segura y trazabilidad de autoría. | — |
1.1 Columnas estándar de toda tabla tenantizada
-- Plantilla aplicada a TODA tabla de negocio (vía concern Tenantable + migración base)
tenant_id BIGINT NOT NULL REFERENCES tenants(id), -- D1, D3
public_id UUID NOT NULL DEFAULT gen_random_uuid(),-- D2 (identificador expuesto)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by_id BIGINT,
updated_by_id BIGINT,
lock_version INTEGER NOT NULL DEFAULT 0, -- D10
deleted_at TIMESTAMPTZ -- D9 (NULL = vigente)
-- + índice compuesto líder por tenant: (tenant_id, <clave natural>)
-- + política RLS: USING (tenant_id = current_setting('app.current_tenant')::bigint)
2. Estrategia de tenant_id (decisión nuclear)
2.1 Modelo híbrido Pooled-first, Silo-on-demand
| Capa | Tenant PYME (99%) | Tenant Enterprise / soberanía de datos |
|---|---|---|
| Almacenamiento | Shared Schema en RDS PostgreSQL común | DB dedicada (RDS o Aurora Serverless v2) vía connects_to |
| Aislamiento | tenant_id + RLS + particionado | Aislamiento físico (DB separada) + RLS redundante |
| Onboarding | Instantáneo (INSERT + seed) | Pipeline de provisioning (Terraform), minutos |
| Costo | ~US$ 0.20–2 / tenant / mes | US$ 15–50+ / tenant / mes (upsell premium) |
| Disparador de promoción | — | >X% CPU sostenido atribuible al tenant, exigencia contractual de residencia, o volumen de CPE > umbral |
2.2 Las cuatro capas de defensa del aislamiento
| Capa | Mecanismo | Falla segura |
|---|---|---|
| HTTP | Middleware resuelve tenant; rechaza request sin tenant. | 401/403 |
| ORM | Tenantable default scope + asignación automática de tenant_id. | Excepción si falta Current.tenant_id |
| DB (RLS) | USING (tenant_id = current_setting('app.current_tenant')::bigint). | 0 filas (no fuga) |
| Background | TenantSetter.with(args[:tenant_id]) envuelve cada job en transacción. | Job aborta (fail-closed) |
2.3 Jerarquía de tenancy
Tenant (1 RUC = 1 persona jurídica) 1—N Branch (sucursal) 1—N Warehouse (almacén). Un tenant tiene N usuarios con N roles, ámbito por sucursal. Esta jerarquía habilita los requisitos multiempresa / multisucursal / multialmacén / multiusuario / multirol.
Nota de modelado multiempresa. El requisito "multiempresa" se resuelve en dos niveles: (a) cada empresa-cliente es un
Tenant; (b) un grupo económico (holding con varios RUC) que contrata ConTodo puede modelarse como un tenant con variasCompanyo como varios tenants federados según necesite consolidación contable. El modelo soportaCompanycomo entidad explícita bajoTenantpara habilitar consolidación futura sin migración.
3. Mapa de bounded contexts y dependencias de datos
4. Entidades principales por módulo
| Bounded context | Entidades núcleo | Relación contable / operativa clave |
|---|---|---|
| Identity & Access | User, Role, Permission, Membership, RolePermission | Membership liga User↔Tenant↔Branch con Role (RBAC multirol/sucursal). |
| Organization | Tenant, Company, Branch, Warehouse, Setting, EmissionSeries | Series por sucursal/punto de emisión (numeración correlativa anti-fraude). |
| SharedKernel | Party (cliente/proveedor unificado por RUC/DNI), Currency, ExchangeRate, TaxParameter, OutboxEvent, AuditLog | Party es el auxiliar obligatorio de CxC/CxP. TaxParameter versiona UIT/tasas por vigencia. |
| Inventory / Kardex | Product (MP/SEMI/PT, configurable), ProductVariant (talla×color), StockItem, KardexEntry, Lot, StockTransfer | Kardex valorizado promedio móvil/PEPS, multialmacén, formatos 12.1/13.1. |
| Purchasing | PurchaseOrder, PurchaseOrderLine, GoodsReceipt, SupplierInvoice | GoodsReceipt → KardexEntry → asiento PURCHASE_TO_INV. |
| Sales | Quote, SalesOrder, SalesOrderLine, ElectronicDocument (Factura/Boleta/NC/ND), EDLine | CPE UBL 2.1, detracción/percepción, dispara SALE_INVOICE_CREDIT + SALE_COGS. |
| Manufacturing (Textil) | Bom, BomComponent, BomOperation, ProductionOrder, ProductionConsumption, MaquilaOrder, MaquilaSettlement | Costeo MP/MOD/CIF; maquila como almacén-tercero; merma acumulada. |
| Logistics | DispatchGuide (GRE-Remitente/Transportista), Carrier, Vehicle, Driver | Motivo de traslado; integra con MTC y maquila. |
| Imports | ImportFile (expediente, AASM), ImportItem, CustomsDeclaration (DAM), ImportCost, CostAllocation | Prorrateo multi-base → costo nacionalizado → KardexEntry. |
| Accounting (PCGE) | Account (PCGE 2019), JournalEntry, JournalEntryLine, AccountingTemplate, AccountBalance, FiscalPeriod | Partida doble garantizada por trigger; plantillas parametrizables. |
| Treasury | BankAccount, CashTransaction, OpenItem (CxC/CxP), Payment, PaymentApplication, BankStatement | Subledgers conciliados con cuentas 12/42; conciliación bancaria. |
| FixedAssets | FixedAsset, DepreciationSchedule, DepreciationRun | Doble libro contable/tributario (NIC 12). |
| HumanResources | Employee, EmploymentContract, Position, CostCenter | Legajo; CostCenter dimensiona asientos. |
| Payroll | PayrollPeriod, Payslip, PayslipConcept, PayrollConceptDef | PLAME/T-Registro/AFPnet; dispara PAYROLL_ACCRUAL. |
| CRM | Lead, Opportunity, PipelineStage, Activity, Contact | Comparte Party; alimenta forecast IA. |
5. ERD por módulo
5.1 Plataforma: Tenancy, IAM y SharedKernel
5.2 Inventario / Kardex y Compras
5.3 Ventas y Comprobantes Electrónicos (CPE)
5.4 Producción Textil: BOM, Orden de Producción y Maquila
5.5 Importaciones: Expediente, DAM y Costeo Nacionalizado
5.6 Contabilidad (PCGE), Tesorería y Activos Fijos
5.7 RRHH / Planillas y CRM
6. Integridad transversal: del evento operativo al asiento
Garantías de integridad implementadas en el modelo:
| Regla | Mecanismo | Tabla(s) |
|---|---|---|
Partida doble (Σ debe = Σ haber) | Trigger PL/pgSQL assert_balanced + validación app | journal_entry_lines |
| No comprobantes duplicados | UNIQUE (tenant_id, doc_type, serie, number) | electronic_documents |
| Correlativo atómico sin saltos | Secuencia con advisory lock por serie | emission_series.last_number |
| Idempotencia de eventos | UNIQUE (event_id); consumidores idempotentes | outbox_events |
| Inmutabilidad fiscal | Constraint: status=posted ⇒ UPDATE bloqueado (trigger) | journal_entries, electronic_documents |
| Aislamiento tenant | RLS USING tenant_id = current_setting(...) | todas |
| Kardex sin costo negativo | Lock pesimista / cola serializada por (variant, warehouse) | kardex_entries, stock_items |
| Cuadre subledger↔mayor | Job nocturno: Σ open_items = saldo cuenta 12/42 | open_items vs account_balances |
7. Particionado y rendimiento
| Tabla | Estrategia de partición | Razón |
|---|---|---|
kardex_entries | HASH(tenant_id) + subpartición RANGE(entry_date) por año | Tabla más caliente; VACUUM y queries por periodo. |
journal_entry_lines | HASH(tenant_id) + RANGE(period) | Millones de líneas; reportes por periodo. |
electronic_documents | HASH(tenant_id) + RANGE(issue_date) | Volumen CPE; archivado a S3/Glacier de periodos cerrados. |
outbox_events | RANGE(occurred_at) (mensual) + drop de particiones publicadas | Tabla de alta rotación; purga eficiente. |
audit_logs | RANGE(created_at) | Append-only, retención larga. |
account_balances | Sin particionar (saldos agregados) | Pequeña; evita SUM en caliente sobre el mayor. |
Saldos pre-agregados (
account_balances): evitan recorrer millones de líneas en cada EEFF. Se actualizan por trigger/job al contabilizar, no al vuelo. Lecturas pesadas (BI/EEFF) van a read replica víaconnected_to(role: :reading).
8. Riesgos del modelo de datos y mitigaciones
| # | Riesgo | Severidad | Mitigación |
|---|---|---|---|
| R1 | tenant_id con tipo/nombre inconsistente entre packs rompe casts de RLS en runtime. | Crítica | D1+D2 canónicos fijados ANTES de la primera migración; linter de esquema en CI. |
| R2 | SET de sesión filtra contexto bajo PgBouncer/RDS Proxy (data leak cross-tenant). | Crítica | SET LOCAL (D4) + toda query en transacción + test de aislamiento con pool en modo transaction. |
| R3 | Fuga cross-tenant por WHERE olvidado. | Crítica | RLS en motor (D3) + test automático de aislamiento por cada modelo Tenantable. |
| R4 | Particionado LIST desbalanceado por power-law (tenant gigante). | Alta | HASH(tenant_id) (D5) + promoción a Silo con disparador definido. |
| R5 | Asiento descuadrado persistido. | Alta | Trigger assert_balanced (no solo validación app). |
| R6 | Costo de inventario negativo por concurrencia en Kardex. | Alta | Lock por (variant, warehouse) o cola Sidekiq serializada. |
| R7 | UUID en PK infla índices y degrada joins a escala. | Media | BIGINT interno, UUID solo público (D2). |
| R8 | Migración global lenta (backfill sobre tabla particionada de cientos de millones). | Alta | strong_migrations, backfill por lotes, columnas sin default, runbook de ventana. |
| R9 | Borrado de comprobante viola conservación 5 años. | Crítica | Soft-delete (D9); hard-delete prohibido por policy + S3 versionado. |
| R10 | Doble pipeline de captura (outbox vs CDC) duplica carga WAL. | Alta | ADR vinculante: outbox→integración, CDC→BI; no ambos para el mismo fin. |
9. Oportunidades habilitadas por el modelo
| # | Oportunidad | Valor |
|---|---|---|
| O1 | Outbox como changelog reproducible para BI/IA sin tocar tablas operativas. | Pipeline analítico desacoplado; reconstrucción de read models por replay. |
| O2 | Party unificado (cliente=proveedor=taller) habilita 360° del tercero y scoring de talleres de maquila. | Dato propietario, network effect (marketplace de talleres). |
| O3 | AccountingTemplate parametrizable permite a un contador (no dev) ajustar mapeos por empresa. | Mantenibilidad y diferenciador vs CONCAR/SISCONT. |
| O4 | Costo nacionalizado y BOM multinivel persistidos alimentan margen real y forecast IA. | Decisiones de precio correctas; foso competitivo textil/importador. |
| O5 | Saldos pre-agregados + read replicas dan EEFF en tiempo real con drill-down asiento→documento. | Ventaja BI frente a Defontana/StarSoft. |
| O6 | Silo on-demand sin reescritura (misma codebase, otra DB) habilita tier "soberanía de datos". | Upsell premium para medianas reguladas. |
10. Conclusión
El modelo de datos de ConTodo se cimienta en un contrato de datos transversal explícito (sección 1) que fija de antemano las decisiones menos reversibles: tenant_id BIGINT canónico con RLS como autoridad de aislamiento, SET LOCAL transaccional compatible con pooling, montos NUMERIC, multimoneda por línea e inmutabilidad fiscal. Sobre esa base, cada bounded context aporta sus entidades núcleo —desde el Party unificado y el catálogo PCGE 2019 hasta el BOM multinivel textil, el expediente de importación con costo nacionalizado y los subledgers de tesorería— todas conectadas por el outbox de eventos que garantiza que "venta confirmada ⇒ asiento contable ⇒ Kardex valorizado" sin dual-write inconsistente. La estrategia híbrida Pooled-first/Silo-on-demand entrega la economía de escala que hace viable competir en precio contra SAP B1, NetSuite y Defontana, conservando el aislamiento garantizado por motor que un ERP financiero exige. Las decisiones de particionado (HASH(tenant_id)), saldos pre-agregados y read replicas blindan el rendimiento a escala, mientras que la reconciliación de las contradicciones de los debates (tipo/nombre de tenant, pipeline de eventos único) elimina la deuda de gobierno antes de la primera línea de código.