Plataforma técnica · Orchestrator
Ciclo Contable Service
Orchestrator Ciclo Contable Cierre Mensual
CicloContableService compone un snapshot del estado del ciclo contable para un período. Tiene un único método público y no muta estado.
async getEstado( ctx: ServiceContext, anio: number, mes: number | null,): Promise<EstadoCicloContable>mes === null bifurca a getEstadoAnual (privado, 7 pasos). Cualquier otro valor invoca la vista mensual (11 pasos).
flowchart TB
IN["getEstado(ctx, anio, mes)"]
POOL["getPool(ctx.tenantDb)"]
BIF{"mes === null?"}
MEN["Vista mensual<br/>Promise.all(10 queries)"]
AN["Vista anual<br/>Promise.all(7 fuentes)"]
RULES["Reglas estado por paso<br/>(pendiente | parcial | completo)"]
COMP["Compose PasoCiclo[]"]
OUT["EstadoCicloContable"]
IN --> POOL --> BIF
BIF -- no --> MEN --> RULES
BIF -- sí --> AN --> RULES
RULES --> COMP --> OUT Vista mensual — 11 pasos
Section titled “Vista mensual — 11 pasos”Promise.all ejecuta las 10 queries del repositorio en paralelo. El paso costo_ventas se deriva del mismo dato que ventas (sin query extra).
| Orden | id | Detalle | Fuente principal |
|---|---|---|---|
| 1 | operaciones | DetalleOperaciones | operaciones_sii.{compras,ventas,boletas} vs compras_ventas_detalle |
| 2 | clasificacion | DetalleClasificacion | inventario.proveedores_clasificacion LEFT JOIN sobre compras del período |
| 3 | compras | DetalleCompras (con desglose existencias/activo_fijo/gasto/sin_clasificar) | compras_ventas_detalle con tipo_concepto = 'NETO' |
| 4 | ventas | DetalleVentas | compras_ventas_detalle filtrado por VENTAS_CONTABILIZABLES_SQL |
| 5 | costo_ventas | DetalleAnualCostoVentas (reusado) | inventario.movimientos_inventario + saldo_stock |
| 6 | remuneraciones | DetalleRemuneraciones | remuneraciones.{honorarios,contratos,liquidaciones} |
| 7 | declaraciones | DetalleDeclaraciones | declaraciones.declaraciones_f29 (último del período) |
| 8 | stock | DetalleStock | inventario.saldo_stock |
| 9 | depreciacion | DetalleDepreciacionMensual | activo_fijo.{activos,depreciacion} con CTE de activos vigentes |
| 10 | conciliacion | DetalleConciliacion | compras_ventas_detalle (lado libro) + financieros.movimientos_bancarios (lado banco) |
| 11 | cierre_balance | DetalleCierreBalanceMensual | administracion.saldo_cuentas_cierre ó reportes.balance_guardado + checkCierreBlocker |
Vista anual — 7 pasos
Section titled “Vista anual — 7 pasos”Sin orden estricto en UI; se evalúan en paralelo. Dos delegaciones externas: ReportesService.getPpmActualizacionAnual y ReportesRepository.getEstadoResultadosAcumuladosAnual.
id | Detalle | Fuente |
|---|---|---|
depreciacion | DetalleDepreciacionAnual (incluye activos_pendientes_nombres[] top 10) | activo_fijo.* con CTE de vigentes por fecha_fin efectiva |
provision_gastos | DetalleProvisionGastos | inventario.provision_ajustes_cierre LATERAL JOIN sobre compras devengadas |
incobrables | DetalleIncobrables | financieros.conciliacion_bancaria con diferencia negativa |
costo_ventas | DetalleAnualCostoVentas | Agregado anual de movimientos_inventario + saldo_stock |
ppm | DetallePPM | ReportesService.getPpmActualizacionAnual |
balances_reportes | DetalleBalancesReportes | reportes.balance_guardado filtrado por año |
resultados_acumulados | DetalleResultadosAcumulados | ReportesRepository.getEstadoResultadosAcumuladosAnual |
Reglas por paso
Section titled “Reglas por paso”La mayoría sigue el patrón general (total === 0 → pendiente, pendientes === 0 → completo, sino parcial). Las excepciones se documentan abajo.
const f29Existe = f29_estado !== null;const f29SinPago = f29Existe && Number(f29_total_a_pagar ?? 0) === 0;
if (f29_estado === "DECLARADO" || f29_estado === "RECTIFICADO" || f29SinPago) estado = "completo";else if (f29Existe) estado = "parcial";else estado = "parcial"; // declaración es obligatoria → nunca pendienteParticularidad: nunca pendiente. Si no existe registro F29 para el período, el estado es parcial (obligación abierta) en vez de pendiente (no aplica). La diferencia es semántica: la declaración mensual siempre aplica.
Dos fuentes alternativas — basta una para considerar completo:
const cierreViaSaldos = total_cuentas > 0;const cierreViaBalances = balances_guardados > 0;
const cierreSaldosCompleto = cierreViaSaldos && cuentas_cerradas >= total_cuentas;const cierreBalancesCompleto = cierreViaBalances && tipos_aprobados >= tipos_requeridos; // default 2
if (cierreSaldosCompleto || cierreBalancesCompleto) estado = "completo";else if (cierreViaSaldos || cierreViaBalances) estado = "parcial";else estado = "pendiente";Además expone bloqueado_por cuando checkCierreBlocker retorna blocked: true y el estado no es completo:
bloqueado_por: detalleCierre.bloqueado_por_periodo_previo && estadoCierre !== "completo" ? detalleCierre.motivo_bloqueo : null,Depende del valor del campo (CONTABILIZADO | BORRADOR | null), no de un conteo:
if (ventas_total === 0) estado = "pendiente";else if (costo_ventas_estado === "CONTABILIZADO") estado = "completo";else if (costo_ventas_estado === "BORRADOR") estado = "parcial";else estado = "pendiente"; // ventas sí, pero sin procesoAplica idéntico en mensual y anual.
Cuatro ramas según meses_declarados y meses_con_ajuste:
if (meses_declarados === 0) estado = "pendiente";else if (meses_con_ajuste === 0) estado = "completo"; // sin ajustes que contabilizarelse if (meses_contabilizados === 0) estado = "pendiente"; // hay ajustes, ninguno asentadoelse if (meses_contabilizados < meses_con_ajuste) estado = "parcial";else estado = "completo";Considera “actividad” antes de exigir completitud:
const hayActividad = honorarios_total > 0 || contratos_sueldo_vigentes > 0;
if (!hayActividad) estado = "pendiente";else if ( honorarios_sin_procesar === 0 && liquidaciones_pendientes === 0 && (contratos_sueldo_vigentes === 0 || liquidaciones_aprobadas > 0)) estado = "completo";else estado = "parcial";La rama final exige que si hay contratos vigentes, exista al menos una liquidación aprobada — evita marcar completo si nadie corrió liquidaciones del mes.
if (activos_vigentes === 0) estado = "pendiente";else if (cuotas_contabilizadas >= activos_vigentes) estado = "completo";else estado = "parcial";El criterio compara cuotas contabilizadas vs activos vigentes (no cuotas generadas). Es posible tener cuotas generadas en BORRADOR que no cuentan — quedaría parcial.
Doble condición: financiero y tributario OK:
if (!ejecutado_anual) estado = "pendiente";else if (financiero_ok && tributario_ok) estado = "completo";else estado = "parcial";Aplica a operaciones, clasificacion, compras, ventas, stock, conciliacion, provision_gastos, incobrables, balances_reportes, depreciacion (anual):
if (total === 0) estado = "pendiente";else if (pendientes === 0) estado = "completo";else estado = "parcial";Cada paso adapta qué cuenta como pendientes (compras PENDIENTE, movimientos sin conciliar, candidatos sin registro, etc.).
Bloqueo por período previo
Section titled “Bloqueo por período previo”Sólo cierre_balance expone bloqueado_por en el contrato público. Se calcula desde ReportesRepository.checkCierreBlocker(pool, anio, mes), que verifica si el período anterior tiene cierre incompleto.
estado actual | bloqueado_por_periodo_previo | bloqueado_por reportado |
|---|---|---|
completo | true o false | null (un cierre ya consumado no se “desbloquea”) |
parcial o pendiente | true | motivo_bloqueo (string del blocker) |
parcial o pendiente | false | null |
Delegaciones externas
Section titled “Delegaciones externas”| Delegación | Para | Por qué |
|---|---|---|
ReportesService.getPpmActualizacionAnual(ctx, anio) | Vista anual paso ppm | El cálculo PPM con actualización por UTM ya vive en reportes; replicarlo en cicloContable sería duplicar lógica. |
ReportesRepository.getEstadoResultadosAcumuladosAnual(pool, anio) | Vista anual paso resultados_acumulados | El estado financiero/tributario de cierre lo gestiona reportes. |
ReportesRepository.checkCierreBlocker(pool, anio, mes) | Vista mensual paso cierre_balance | Misma lógica de bloqueo que usa el endpoint de cierre directo. |
findCierreEventPayload — modo solo-ids
Section titled “findCierreEventPayload — modo solo-ids”Método estático del repositorio que computa los pasos completados sin construir el detalle. Se usa para emitir eventos de cierre desde otros servicios sin pagar el costo de los detalles:
async findCierreEventPayload( pool: Pool | PoolClient, anio: number, mes: number,): Promise<CierreEventPayload>
interface CierreEventPayload { anio: number; mes: number; pasosCompletados: string[];}Las reglas de “completo” replican exactamente las de getEstado — si divergen, el estado reportado a la UI y los eventos quedarían desincronizados. Los tests CicloContableService.unit.test.ts y CicloContableRepository.unit.test.ts cubren ambas rutas con los mismos fixtures.
Errores
Section titled “Errores”CicloContableService no lanza errores propios. Propaga los del repositorio:
| Origen | Causa |
|---|---|
pool.query | Errores SQL no capturados por el repositorio (cualquier código distinto de 42P01, 3F000, 42703, 42P16). |
ReportesService.getPpmActualizacionAnual | Errores aguas abajo en reportes. |
ReportesRepository.* | Idem. |
No hay ValidationError: el contrato HTTP valida anio y mes en la ruta vía express-validator antes de llegar al servicio.
| Archivo | Cubre |
|---|---|
CicloContableService.unit.test.ts | Reglas de estado por paso con fixtures mock del repositorio. |
CicloContableRepository.unit.test.ts | Queries SQL contra Postgres real (test DB) y fallback 42P01. |
CierreBalanceMensual.unit.test.ts | Doble fuente de cierre (saldos vs balances) y precedencia. |
Por qué no genera asientos
Section titled “Por qué no genera asientos”Decisión explícita: cicloContable es agregador de estado, no contabilizador. Generar asientos desde acá rompería tres invariantes:
- Idempotencia de la UI: refrescar el panel no debería materializar nada.
- Trazabilidad: cada asiento debe nacer en su servicio de dominio (compras, depreciación, F29) con su outbox y su transacción.
- Tenant-tolerance: el servicio retorna
pendientecuando un esquema no existe. Si además contabilizara, fallaría duro en tenants parciales.
La contabilización vive en los dominios fuente. El ciclo sólo cuenta.