Skip to content

Plataforma técnica · Sevastopol

Ciclo Contable Island

Islands Sevastopol Ciclo Contable

CicloContableIsland es una vista de control read-only del cierre por período: pinta el pipeline de pasos (pendiente | parcial | completo) que retorna GET /api/ciclo-contable/estado y permite abrir un detalle inline por paso sin salir del canvas. No contabiliza ni genera asientos — la única acción de escritura es Generar Stock, que delega en /api/inventario/saldo-stock/procesar.

  • Directorysevastopol/src/components/islands/cicloContable/
    • CicloContableIsland.tsx — componente único, internamente CicloContablePanel
  • Directorysevastopol/src/pages/api/ciclo-contable/
    • […path].ts — proxy genérico vía createProxy("/api/ciclo-contable")
  • Directorysevastopol/src/lib/hooks/
    • useActiveTenant.ts
    • useActivePeriod.ts

El componente bifurca entre tres vistas según modoVista (signal local) y period().month (hook global):

modoVistaperiod().monthVistaLlamadas a la API
mensualnúmero 1–12Pipeline del mes (11 pasos)1 — ?anio=Y&mes=M
mensualnullResumen comparativo 12 meses12 en paralelo — ?anio=Y&mes=1..12
anual(ignorado)Pipeline anual (7 pasos)1 — ?anio=Y
if (modoVista() === "mensual" && activeMes() === null) {
const calls = Array.from({ length: 12 }, (_, i) => {
const params = new URLSearchParams({ anio: String(anio), mes: String(i + 1) });
if (tid) params.set("tenant_id", tid);
return authenticatedFetch(`${API}/estado?${params.toString()}`).then((r) =>
r.ok ? r.json() : Promise.reject(new Error(`Error mes ${i + 1}`)),
);
});
const results = await Promise.all(calls);
setResumenMensual(results as EstadoCicloContable[]);
return;
}

El cambio de modo siempre limpia estado y resumenMensual, fuerza pasoActivo = null y dispara una recarga.

flowchart TB
  MOUNT["onMount<br/>tenantId ready"]
  CHGT["useActiveTenant onChange"]
  CHGP["useActivePeriod onChange"]
  CHGM["switchModo(mensual | anual)"]

  LOAD["loadEstado()"]
  BIF{"modo === mensual<br/>y mes === null?"}
  N12["Promise.all<br/>12 fetch /estado?anio&mes=i"]
  N1["fetch /estado?anio[&mes]"]

  ST1["setResumenMensual([...12])"]
  ST2["setEstado(EstadoCicloContable)"]

  TAB["ResumenAnualMensualTable"]
  PIPE["Pipeline + DetallePasoPanel"]

  MOUNT --> LOAD
  CHGT --> LOAD
  CHGP --> LOAD
  CHGM --> LOAD

  LOAD --> BIF
  BIF -- sí --> N12 --> ST1 --> TAB
  BIF -- no --> N1 --> ST2 --> PIPE

Dos hooks compartidos del repositorio; ambos disparan recarga reactiva.

const { tenantId } = useActiveTenant((id) => {
void loadEstado();
void loadCuentas();
});
const { period } = useActivePeriod(() => void loadEstado());
const activeAnio = () => period().year ?? new Date().getFullYear();
const activeMes = () => period().month ?? null;
HookAportaReactividad
useActiveTenantUUID de la base tenant seleccionada en TenantSelectorBar.Cambia al cambiar tenant; además recarga el plan de cuentas para incobrables.
useActivePeriod{ year, month } global del contexto contable.month null activa el resumen 12 meses.

El resto del estado es local (createSignal):

SignalTipoUso
estadoEstadoCicloContable | nullResultado del pipeline de un solo período.
resumenMensualEstadoCicloContable[]12 resultados de la vista comparativa.
loadingbooleanBloqueo de UI durante el fetch.
generandobooleanBloqueo del botón Generar Stock.
pasoActivostring | nullAcordeón de detalle inline; toggle cierra si vuelve a clickearse.
modoVista"mensual" | "anual"Switch del segmented control.
cuentasAccount[]Plan de cuentas imputables — usado para etiquetar cuentas en el detalle de incobrables.

El listado de pasos viene completo desde Orchestrator. La island lo renderiza como una secuencia horizontal (lg) o vertical (sm), con un conector de color entre nodos que refleja si el paso previo está completo.

VistaPasosOrigenDoc
Mensual11 — operaciones, clasificacion, compras, ventas, remuneraciones, declaraciones, costo_ventas, stock, depreciacion, conciliacion, cierre_balanceCicloContableService.getEstado(ctx, anio, mes)→ Vista mensual
Anual7 — depreciacion, provision_gastos, incobrables, costo_ventas, ppm, balances_reportes, resultados_acumuladosCicloContableService.getEstado(ctx, anio, null)→ Vista anual

Cada nodo del pipeline aplica colores e iconos por estado vía nodeColor / nodeIcon:

function nodeColor(estado: string): string {
if (estado === "completo") return "bg-emerald-500 hover:bg-emerald-600";
if (estado === "parcial") return "bg-amber-400 hover:bg-amber-500";
return "bg-zinc-400 hover:bg-zinc-500";
}
function nodeIcon(estado: string): string {
if (estado === "completo") return "✓";
if (estado === "parcial") return "⚠";
return "✗";
}

La tabla ResumenAnualMensualTable pinta una grilla mes × paso con badges circulares por estado. Se renderiza sólo en modo mensual con mes activo null.

Las 11 columnas son fijas (PASOS_MENSUAL_COLS) y se agrega una columna Global computada en cliente con globalEstadoMes:

function globalEstadoMes(ciclo: EstadoCicloContable):
"completo" | "parcial" | "pendiente" {
const pasos = ciclo.pasos;
if (pasos.every((p) => p.estado === "completo")) return "completo";
if (pasos.some((p) => p.estado === "completo" || p.estado === "parcial"))
return "parcial";
return "pendiente";
}

Al clickear un nodo, pasoActivo cambia y se renderiza DetallePasoPanel debajo del pipeline (acordeón). El panel es un Switch / Match por paso.id con grids de StatItem específicos por paso, más mensajes humanos cuando hay condiciones de borde:

Conteo de totales y sin procesar para los tres tipos de documentos SII del mes (compras, ventas, boletas). Cada sin_procesar > 0 se pinta en ámbar.

stock es el único paso que expone una acción contabilizable. El botón se monta sólo si hay mes activo y delega en el dominio inventario:

async function handleGenerarStock() {
const tid = tenantId();
const anio = activeAnio();
const mes = activeMes();
if (!mes) return;
setGenerando(true);
try {
const params = new URLSearchParams({ tenant_id: tid ?? "" });
const res = await authenticatedFetch(
`/api/inventario/saldo-stock/procesar?${params.toString()}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ año: anio, mes }),
},
);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error ?? "Error al generar stock");
}
toast.push("Stock generado correctamente", "ok");
await loadEstado();
} catch (e: any) {
toast.error(e.message ?? "Error al generar stock");
} finally {
setGenerando(false);
}
}

Tras un POST exitoso, se recarga loadEstado() para reflejar el nuevo estado del paso. Si mes === null el botón no se monta — la acción no existe a nivel anual.

El badge superior y la línea inferior de cada nodo no usan directamente paso.estado; pasan por dos derivaciones para distinguir vacío real (sin datos del período) de pendiente activo:

function esPasoVacio(paso: PasoCiclo): boolean {
if (paso.estado !== "pendiente") return false;
const label = labelPendiente(paso);
return label.startsWith("sin ") || label === "no ejecutado" || label === "bloqueado";
}
function statusLabelForPaso(paso: PasoCiclo): string {
if (esPasoVacio(paso)) return "Sin datos";
return statusLabel(paso.estado); // Completo | Parcial | Pendiente
}
Casopaso.estadolabelPendienteBadge mostrado
Sin datos del períodopendienteempieza con "sin "Sin datos
No ejecutado (balances/resultados)pendiente"no ejecutado"Sin datos
Bloqueado por período previopendiente"bloqueado"Sin datos
Pendiente activopendienteotroPendiente
Parcialparcial(no aplica)Parcial
Completocompleto(no aplica)Completo

La línea inferior usa resumenCorto(paso) cuando el estado es parcial y labelPendiente(paso) cuando es pendiente — ambos son switch por paso.id con conteos específicos del detalle.

El componente dispara dos saltos a otras islands desde el detalle:

OrigenDestinoMecanismo
incobrables → botón “Abrir Libro de banco”LibroBancoViewwindow.dispatchEvent(new CustomEvent("sidebar:navigate", { detail: "LibroBancoView" }))
ppm → botón “Abrir PPM anual”IngresosView (pestaña ppm)sessionStorage.setItem("ingresos.activeTab", "ppm") + CustomEvent("sidebar:navigate", { detail: "IngresosView" })

El payload del CustomEvent es el nombre lógico de la vista; el sidebar global resuelve la ruta.

MétodoRutaOperación
GET/api/ciclo-contable/estado?anio=Y&mes=MPipeline mensual (11 pasos).
GET/api/ciclo-contable/estado?anio=YPipeline anual (7 pasos).
GET/api/admin/chart-of-accounts?tenant_id=...Plan de cuentas imputables (etiqueta cuentas en incobrables).
POST/api/inventario/saldo-stock/procesar?tenant_id=...Acción inline del paso stock.

tenant_id se inyecta como query param desde tenantId() en cada request.

Sevastopol no llama directo a Orchestrator. La ruta src/pages/api/ciclo-contable/[...path].ts es una línea:

import { createProxy } from "@/lib/proxyUtils";
export const { GET } = createProxy("/api/ciclo-contable");

Solo se exporta GET — el dominio es read-only y no necesita POST/PUT/DELETE. La fábrica createProxy() reenvía método, query string, cookies y Authorization. El detalle del BFF está en BFF Proxy a Orchestrator.

ReglaMotivo
Usar tenant activo en todas las llamadasEl pipeline es por tenant; el panel queda vacío hasta que tenantId() resuelve.
Disparar 12 fetch en paralelo si mes === nullOrchestrator no expone una vista anual-mensualizada; la composición vive en cliente.
Acordeón único (pasoActivo)Mantener el contexto del cierre en una sola página; click repetido cierra.
Diferenciar Sin datos de PendienteUn paso sin actividad ni esquema (multi-tenant tolerante) no debe leerse como “tarea por hacer”.
Recargar tras Generar StockSincroniza el badge con el nuevo estado que retorne Orchestrator.
Botón de generar sólo en mensual con mes activoLa acción del dominio inventario opera por mes; no aplica a la vista anual ni al resumen.
Etiquetar cuentas de incobrables contra el planMostrar código · nombre ayuda al operador a verificar antes de saltar al Libro de banco.
No exponer estado Global desde el contratoLa columna Global del resumen 12 meses es UX local; no es un dato del backend.