Skip to content

Plataforma técnica · Orchestrator

Cierre Periodo Service

Orchestrator Reportes Cierre Anual

CierrePeriodoService materializa el cierre del período persistiendo saldos finales por cuenta en administracion.saldo_cuentas_cierre. Extiende ReportesServiceSupport para reusar calcular.

Es el único service del dominio reportes que emite un evento de dominio (ciclo:cierre).

MétodoPropósitoTXEmite
cerrarPeriodo(ctx, anio, 12)Cálculo + persistencia de saldo de cierre + evento ciclo:cierre. Solo diciembre.withTransactionciclo:cierre
bootstrapCierre(ctx, anio, mes)Seed histórico: calcula y persiste como CERRADO. Cualquier mes 1-12.

Adicionalmente expone vía ReportesRepository (a través de la facade) checkCierreBlocker(pool, anio, mes) — método estático del repository, no del service, usado por CicloContableService para detectar bloqueo de cierre mensual.

cerrarPeriodo — el único con efecto cross-dominio

Section titled “cerrarPeriodo — el único con efecto cross-dominio”
flowchart TB
  IN["cerrarPeriodo(anio, mes)"]
  VAL["if mes !== 12:<br/>ValidationError<br/>'solo se permite en diciembre'"]
  CALC["this.calcular(anio, TRIBUTARIO, {mes: 12})"]
  TX["withTransaction(ctx, async (client, outbox) => ...)"]
  UPS["upsertCierrePeriodo(client, anio, 12, lineasBalance, userId)<br/>→ saldo_cuentas_cierre BORRADOR"]
  PAY["CicloContableRepository.findCierreEventPayload(client, anio, 12)"]
  QUE["outbox.queue('ciclo:cierre', payload)"]
  COM["COMMIT → flushOutbox"]
  EMI["DomainEventBus.emit('ciclo:cierre', enriched)"]

  IN --> VAL --> CALC --> TX
  TX --> UPS --> PAY --> QUE --> COM --> EMI
async cerrarPeriodo(
ctx: ServiceContext,
anio: number,
mes: number,
): Promise<ServiceResult<{ cuentas_cerradas: number; anio: number; mes: number }>>

Validaciones:

  • anio ∈ [2000, 2100]
  • mes === 12 obligatorio — ValidationError("El cierre de período solo se permite en diciembre (mes 12)"). Cierres mensuales intermedios se persisten vía otros flujos (saldos de cuentas mensuales viven en otra ruta).

Persistencia:

  • Filtra solo líneas nivel === 4 con tipo ∈ {ACTIVO, PASIVO, PATRIMONIO}. Cuentas de resultado (INGRESO/GASTO) no se persisten — se cierran contra resultados acumulados.
  • Inserta o actualiza en saldo_cuentas_cierre con (cuenta_codigo, anio, mes) como PK lógica.
  • Estado al insertar: BORRADOR. Si ya existe con estado='CERRADO', se preserva (no se reabre). Sino, se sobreescribe.
  • fuente='SISTEMA'.

Evento emitido:

const eventPayload = await CicloContableRepository.findCierreEventPayload(client, anio, mes);
outbox.queue("ciclo:cierre", eventPayload);

findCierreEventPayload recomputa los pasos completados del ciclo contable para el mismo (anio, 12) — replicando las reglas de CicloContableService.getEstado sin construir los detalles. El payload final:

interface CierreEventPayload {
anio: number;
mes: number;
pasosCompletados: string[]; // ej: ["operaciones", "compras", "f29", "depreciacion", ...]
}

DomainEventBus lo enriquece con tenantDb/userId/requestId al despachar (vía flushOutbox).

async bootstrapCierre(
ctx: ServiceContext,
anio: number,
mes: number,
): Promise<ServiceResult<{ cuentas_cerradas: number; anio: number; mes: number }>>
Diferencia con cerrarPeriodobootstrapCierre
Mes permitidoCualquier 1-12
Estado del saldoCERRADO directo (no BORRADOR)
Fuente'BOOTSTRAP' (no 'SISTEMA')
Sobrescribe CERRADO previoSí, siempre sobreescribe
TransacciónNo abre TX
Evento ciclo:cierreNo emite

Uso: sembrar saldos históricos cuando se importa un tenant con contabilidad pre-existente y no hay forma de re-ejecutar todo el cálculo mes-a-mes desde el primer día. El estado 'BOOTSTRAP' queda visible en saldo_cuentas_cierre.fuente para auditoría.

checkCierreBlocker — validación de dependencia previa

Section titled “checkCierreBlocker — validación de dependencia previa”

Método estático del repository (no del service). Usado por CicloContableService para el paso cierre_balance:

static async checkCierreBlocker(
pool: Pool | PoolClient,
anio: number,
mes: number,
): Promise<{ blocked: boolean; motivo?: string }>

Reglas:

CasoResultado
mes === 1 y período previo (anio-1, 12) no tiene filas{ blocked: false } (bootstrap case)
mes > 1 y período previo (anio, mes-1) no tiene filas{ blocked: false }
Período previo tiene filas pero ninguna en estado CERRADO{ blocked: true, motivo: "El período YYYY-MM tiene entradas pero no está cerrado..." }
Período previo tiene ≥1 fila en CERRADO{ blocked: false }

La semántica es: si hubo intentos de cierre previo y quedaron en BORRADOR, hay que cerrarlos antes de avanzar. Sin filas = bootstrap (no estamos en cierre incremental aún).

administracion.saldo_cuentas_cierre:

ColumnaTipoNotas
cuenta_codigotextPK lógica (junto con anio, mes)
anio, mesintPeríodo del cierre
debe_acum, haber_acumnumericAcumulado del año hasta el período
saldo_deudor, saldo_acreedornumericSaldo final post-clasificación
estadotextBORRADOR o CERRADO
fuentetextSISTEMA o BOOTSTRAP
created_by, updated_by, updated_ataudituserId del cierre

UPSERT con preservación de CERRADO:

ON CONFLICT (cuenta_codigo, anio, mes) DO UPDATE SET
...,
estado = CASE
WHEN administracion.saldo_cuentas_cierre.estado = 'CERRADO' THEN 'CERRADO'
ELSE 'BORRADOR'
END,
...

Esto evita que un re-cómputo accidental degrade un período ya cerrado.

ErrorCausa
ValidationError("Año inválido")anio fuera de rango
ValidationError("El cierre de período solo se permite en diciembre (mes 12)")cerrarPeriodo con mes !== 12
ValidationError("No se pudo calcular el balance para el período")this.calcular retornó sin data (raro; significa que el motor falló silenciosamente)
ValidationError("Mes inválido (1-12)")bootstrapCierre con mes fuera de rango

Por qué solo diciembre acepta cerrarPeriodo

Section titled “Por qué solo diciembre acepta cerrarPeriodo”

El método persiste el saldo de cierre anual, no un cierre mensual operativo. Los cierres mensuales (revisión de compras, F29, depreciación, etc.) se reflejan en cicloContable.getEstado sin escribir en saldo_cuentas_cierre. La separación evita confundir:

  • cierre mensual = revisión del estado del mes (pendiente|parcial|completo)
  • cierre anual = persistencia del saldo final del año para usarlo como apertura del siguiente

Cuando se necesita persistir un saldo intermedio (cierre de junio para presentación intermedia), se usa bootstrapCierre que no emite ciclo:cierre.

Por qué el evento usa findCierreEventPayload y no el resultado del cierre

Section titled “Por qué el evento usa findCierreEventPayload y no el resultado del cierre”

findCierreEventPayload calcula independientemente qué pasos del ciclo están completos al momento del COMMIT — no usa el cuentas_cerradas del upsert. La razón: el evento ciclo:cierre consume listeners que actúan sobre el estado agregado del ciclo, no sobre el detalle de cuántas cuentas se persistieron en saldo_cuentas_cierre. Una lectura fresca refleja lo que efectivamente quedó tras la TX.