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étodos
Section titled “Métodos”| Método | Propósito | TX | Emite |
|---|---|---|---|
cerrarPeriodo(ctx, anio, 12) | Cálculo + persistencia de saldo de cierre + evento ciclo:cierre. Solo diciembre. | ✅ withTransaction | ✅ ciclo: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 === 12obligatorio —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 === 4contipo ∈ {ACTIVO, PASIVO, PATRIMONIO}. Cuentas de resultado (INGRESO/GASTO) no se persisten — se cierran contra resultados acumulados. - Inserta o actualiza en
saldo_cuentas_cierrecon(cuenta_codigo, anio, mes)como PK lógica. - Estado al insertar:
BORRADOR. Si ya existe conestado='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).
bootstrapCierre — seed histórico
Section titled “bootstrapCierre — seed histórico”async bootstrapCierre( ctx: ServiceContext, anio: number, mes: number,): Promise<ServiceResult<{ cuentas_cerradas: number; anio: number; mes: number }>>Diferencia con cerrarPeriodo | bootstrapCierre |
|---|---|
| Mes permitido | Cualquier 1-12 |
| Estado del saldo | CERRADO directo (no BORRADOR) |
| Fuente | 'BOOTSTRAP' (no 'SISTEMA') |
Sobrescribe CERRADO previo | Sí, siempre sobreescribe |
| Transacción | No abre TX |
Evento ciclo:cierre | No 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:
| Caso | Resultado |
|---|---|
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).
Persistencia
Section titled “Persistencia”administracion.saldo_cuentas_cierre:
| Columna | Tipo | Notas |
|---|---|---|
cuenta_codigo | text | PK lógica (junto con anio, mes) |
anio, mes | int | Período del cierre |
debe_acum, haber_acum | numeric | Acumulado del año hasta el período |
saldo_deudor, saldo_acreedor | numeric | Saldo final post-clasificación |
estado | text | BORRADOR o CERRADO |
fuente | text | SISTEMA o BOOTSTRAP |
created_by, updated_by, updated_at | audit | userId 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.
Errores
Section titled “Errores”| Error | Causa |
|---|---|
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.