ConTodo
Consultor Senior — Data Architecture / Modelado

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 de tenant_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_id vs company_id y 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ónicaJustificaciónAlternativa descartada
D1Nombre 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.
D2Tipo: 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.
D3RLS 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).
D4Propagació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.
D5Particionado 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.
D6Montos: 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.
D7Multimoneda 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.
D8Inmutabilidad 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.
D9Soft-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.
D10Auditorí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

CapaTenant PYME (99%)Tenant Enterprise / soberanía de datos
AlmacenamientoShared Schema en RDS PostgreSQL comúnDB dedicada (RDS o Aurora Serverless v2) vía connects_to
Aislamientotenant_id + RLS + particionadoAislamiento físico (DB separada) + RLS redundante
OnboardingInstantáneo (INSERT + seed)Pipeline de provisioning (Terraform), minutos
Costo~US$ 0.20–2 / tenant / mesUS$ 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

CapaMecanismoFalla segura
HTTPMiddleware resuelve tenant; rechaza request sin tenant.401/403
ORMTenantable 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)
BackgroundTenantSetter.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 varias Company o como varios tenants federados según necesite consolidación contable. El modelo soporta Company como entidad explícita bajo Tenant para habilitar consolidación futura sin migración.


3. Mapa de bounded contexts y dependencias de datos


4. Entidades principales por módulo

Bounded contextEntidades núcleoRelación contable / operativa clave
Identity & AccessUser, Role, Permission, Membership, RolePermissionMembership liga User↔Tenant↔Branch con Role (RBAC multirol/sucursal).
OrganizationTenant, Company, Branch, Warehouse, Setting, EmissionSeriesSeries por sucursal/punto de emisión (numeración correlativa anti-fraude).
SharedKernelParty (cliente/proveedor unificado por RUC/DNI), Currency, ExchangeRate, TaxParameter, OutboxEvent, AuditLogParty es el auxiliar obligatorio de CxC/CxP. TaxParameter versiona UIT/tasas por vigencia.
Inventory / KardexProduct (MP/SEMI/PT, configurable), ProductVariant (talla×color), StockItem, KardexEntry, Lot, StockTransferKardex valorizado promedio móvil/PEPS, multialmacén, formatos 12.1/13.1.
PurchasingPurchaseOrder, PurchaseOrderLine, GoodsReceipt, SupplierInvoiceGoodsReceipt → KardexEntry → asiento PURCHASE_TO_INV.
SalesQuote, SalesOrder, SalesOrderLine, ElectronicDocument (Factura/Boleta/NC/ND), EDLineCPE UBL 2.1, detracción/percepción, dispara SALE_INVOICE_CREDIT + SALE_COGS.
Manufacturing (Textil)Bom, BomComponent, BomOperation, ProductionOrder, ProductionConsumption, MaquilaOrder, MaquilaSettlementCosteo MP/MOD/CIF; maquila como almacén-tercero; merma acumulada.
LogisticsDispatchGuide (GRE-Remitente/Transportista), Carrier, Vehicle, DriverMotivo de traslado; integra con MTC y maquila.
ImportsImportFile (expediente, AASM), ImportItem, CustomsDeclaration (DAM), ImportCost, CostAllocationProrrateo multi-base → costo nacionalizado → KardexEntry.
Accounting (PCGE)Account (PCGE 2019), JournalEntry, JournalEntryLine, AccountingTemplate, AccountBalance, FiscalPeriodPartida doble garantizada por trigger; plantillas parametrizables.
TreasuryBankAccount, CashTransaction, OpenItem (CxC/CxP), Payment, PaymentApplication, BankStatementSubledgers conciliados con cuentas 12/42; conciliación bancaria.
FixedAssetsFixedAsset, DepreciationSchedule, DepreciationRunDoble libro contable/tributario (NIC 12).
HumanResourcesEmployee, EmploymentContract, Position, CostCenterLegajo; CostCenter dimensiona asientos.
PayrollPayrollPeriod, Payslip, PayslipConcept, PayrollConceptDefPLAME/T-Registro/AFPnet; dispara PAYROLL_ACCRUAL.
CRMLead, Opportunity, PipelineStage, Activity, ContactComparte 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:

ReglaMecanismoTabla(s)
Partida doble (Σ debe = Σ haber)Trigger PL/pgSQL assert_balanced + validación appjournal_entry_lines
No comprobantes duplicadosUNIQUE (tenant_id, doc_type, serie, number)electronic_documents
Correlativo atómico sin saltosSecuencia con advisory lock por serieemission_series.last_number
Idempotencia de eventosUNIQUE (event_id); consumidores idempotentesoutbox_events
Inmutabilidad fiscalConstraint: status=posted ⇒ UPDATE bloqueado (trigger)journal_entries, electronic_documents
Aislamiento tenantRLS USING tenant_id = current_setting(...)todas
Kardex sin costo negativoLock pesimista / cola serializada por (variant, warehouse)kardex_entries, stock_items
Cuadre subledger↔mayorJob nocturno: Σ open_items = saldo cuenta 12/42open_items vs account_balances

7. Particionado y rendimiento

TablaEstrategia de particiónRazón
kardex_entriesHASH(tenant_id) + subpartición RANGE(entry_date) por añoTabla más caliente; VACUUM y queries por periodo.
journal_entry_linesHASH(tenant_id) + RANGE(period)Millones de líneas; reportes por periodo.
electronic_documentsHASH(tenant_id) + RANGE(issue_date)Volumen CPE; archivado a S3/Glacier de periodos cerrados.
outbox_eventsRANGE(occurred_at) (mensual) + drop de particiones publicadasTabla de alta rotación; purga eficiente.
audit_logsRANGE(created_at)Append-only, retención larga.
account_balancesSin 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ía connected_to(role: :reading).


8. Riesgos del modelo de datos y mitigaciones

#RiesgoSeveridadMitigación
R1tenant_id con tipo/nombre inconsistente entre packs rompe casts de RLS en runtime.CríticaD1+D2 canónicos fijados ANTES de la primera migración; linter de esquema en CI.
R2SET de sesión filtra contexto bajo PgBouncer/RDS Proxy (data leak cross-tenant).CríticaSET LOCAL (D4) + toda query en transacción + test de aislamiento con pool en modo transaction.
R3Fuga cross-tenant por WHERE olvidado.CríticaRLS en motor (D3) + test automático de aislamiento por cada modelo Tenantable.
R4Particionado LIST desbalanceado por power-law (tenant gigante).AltaHASH(tenant_id) (D5) + promoción a Silo con disparador definido.
R5Asiento descuadrado persistido.AltaTrigger assert_balanced (no solo validación app).
R6Costo de inventario negativo por concurrencia en Kardex.AltaLock por (variant, warehouse) o cola Sidekiq serializada.
R7UUID en PK infla índices y degrada joins a escala.MediaBIGINT interno, UUID solo público (D2).
R8Migración global lenta (backfill sobre tabla particionada de cientos de millones).Altastrong_migrations, backfill por lotes, columnas sin default, runbook de ventana.
R9Borrado de comprobante viola conservación 5 años.CríticaSoft-delete (D9); hard-delete prohibido por policy + S3 versionado.
R10Doble pipeline de captura (outbox vs CDC) duplica carga WAL.AltaADR vinculante: outbox→integración, CDC→BI; no ambos para el mismo fin.

9. Oportunidades habilitadas por el modelo

#OportunidadValor
O1Outbox como changelog reproducible para BI/IA sin tocar tablas operativas.Pipeline analítico desacoplado; reconstrucción de read models por replay.
O2Party unificado (cliente=proveedor=taller) habilita 360° del tercero y scoring de talleres de maquila.Dato propietario, network effect (marketplace de talleres).
O3AccountingTemplate parametrizable permite a un contador (no dev) ajustar mapeos por empresa.Mantenibilidad y diferenciador vs CONCAR/SISCONT.
O4Costo nacionalizado y BOM multinivel persistidos alimentan margen real y forecast IA.Decisiones de precio correctas; foso competitivo textil/importador.
O5Saldos pre-agregados + read replicas dan EEFF en tiempo real con drill-down asiento→documento.Ventaja BI frente a Defontana/StarSoft.
O6Silo 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.