Plataforma técnica · Sevastopol
Ciclo Contable Island
Islands Sevastopol Ciclo Contable
Propósito
Section titled “Propósito”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.
Ubicación
Section titled “Ubicación”Directorysevastopol/src/components/islands/cicloContable/
- CicloContableIsland.tsx — componente único, internamente
CicloContablePanel
- CicloContableIsland.tsx — componente único, internamente
Directorysevastopol/src/pages/api/ciclo-contable/
- […path].ts — proxy genérico vía
createProxy("/api/ciclo-contable")
- […path].ts — proxy genérico vía
Directorysevastopol/src/lib/hooks/
- useActiveTenant.ts
- useActivePeriod.ts
Modos de visualización
Section titled “Modos de visualización”El componente bifurca entre tres vistas según modoVista (signal local) y period().month (hook global):
modoVista | period().month | Vista | Llamadas a la API |
|---|---|---|---|
mensual | número 1–12 | Pipeline del mes (11 pasos) | 1 — ?anio=Y&mes=M |
mensual | null | Resumen comparativo 12 meses | 12 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.
Flujo de carga
Section titled “Flujo de carga”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 Hooks y estado global
Section titled “Hooks y estado global”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;| Hook | Aporta | Reactividad |
|---|---|---|
useActiveTenant | UUID 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):
| Signal | Tipo | Uso |
|---|---|---|
estado | EstadoCicloContable | null | Resultado del pipeline de un solo período. |
resumenMensual | EstadoCicloContable[] | 12 resultados de la vista comparativa. |
loading | boolean | Bloqueo de UI durante el fetch. |
generando | boolean | Bloqueo del botón Generar Stock. |
pasoActivo | string | null | Acordeón de detalle inline; toggle cierra si vuelve a clickearse. |
modoVista | "mensual" | "anual" | Switch del segmented control. |
cuentas | Account[] | Plan de cuentas imputables — usado para etiquetar cuentas en el detalle de incobrables. |
Pipeline de pasos
Section titled “Pipeline de pasos”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.
| Vista | Pasos | Origen | Doc |
|---|---|---|---|
| Mensual | 11 — operaciones, clasificacion, compras, ventas, remuneraciones, declaraciones, costo_ventas, stock, depreciacion, conciliacion, cierre_balance | CicloContableService.getEstado(ctx, anio, mes) | → Vista mensual |
| Anual | 7 — depreciacion, provision_gastos, incobrables, costo_ventas, ppm, balances_reportes, resultados_acumulados | CicloContableService.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 "✗";}Resumen 12 meses
Section titled “Resumen 12 meses”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";}DetallePasoPanel
Section titled “DetallePasoPanel”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.
Proveedores sin clasificar y compras bloqueadas; despliega la lista de RUTs sin clasificar cuando el array ruts_sin_clasificar no está vacío.
Totales, contabilizadas y pendientes; además 4 tarjetas CompraClasificacionCard por clasificación de proveedor (existencias, activo_fijo, gasto, sin_clasificar). La última se pinta en ámbar si tiene pendientes — bloquea compras hasta clasificar.
total_documentos, pendientes de ventas y boletas, contabilizadas. El resumen corto deriva un fallback total_documentos - contabilizadas cuando los conteos de pendientes son cero pero faltan registros.
Dos bloques condicionales: honorarios (visible si honorarios_total > 0) y sueldos (visible si contratos_sueldo_vigentes > 0). Cuando ambos son cero pinta un mensaje neutro de “sin actividad”.
Tarjeta con f29_estado (verde si PRESENTADA/PAGADA, ámbar en otro caso) y f29_total_a_pagar formateado en pesos. Sin tabla — sólo dos cards.
Categorías registradas, cerradas (CONTABILIZADO) y pendientes (VALIDADO). Es el único paso que expone un botón de acción — ver Acción inline: Generar Stock.
Dos grillas: lado documentos (total_contabilizado, conciliados, sin_conciliar) y lado bancos (movs_total, movs_conciliados, movs_pendientes).
Bloque doble: cabecera con balances guardados/aprobados/borrador y tipos_aprobados/tipos_requeridos; debajo, cuando hay total_cuentas > 0, el cuadro de saldos cerrados con la celda Período previo que muestra “Bloquea” si bloqueado_por_periodo_previo. Cinco párrafos condicionales cubren los seis escenarios (sin cierre, balances aprobados, balances pendientes de aprobación, cierre cerrado, cierre en borrador, bloqueado por período previo).
Vista distinta según modo: mensual muestra activos_vigentes, cuotas_generadas, cuotas_contabilizadas y pendientes derivadas (cuotasPendientesDepreciacionMensual); anual añade activos_al_dia, activos_pendientes y la lista activos_pendientes_nombres[] (top 10) cuando existe.
Ventas del período y tarjeta con costo_ventas_estado (CONTABILIZADO verde, BORRADOR ámbar, null gris). Tres párrafos condicionales según ventas/estado.
Casos pendientes, generados, monto pendiente y castigado. Debajo, dos cards con la cuenta gasto (3502003 por defecto) y cuenta contraactivo (1104002 por defecto) etiquetadas vía cuentaLabel(codigo, cuentas) contra el plan de cuentas del tenant. Incluye un botón que navega a LibroBancoView vía sidebar:navigate.
Cabecera con meses_declarados, meses_con_ajuste, meses_contabilizados y estado_general; debajo total_declarado, total_actualizado y total_ajuste (CM). Cinco párrafos condicionales para los escenarios de la regla PPM (sin PPM, sin ajuste, ajuste sin contabilizar, parcial, completo). Botón “Abrir PPM anual” navega a IngresosView con sessionStorage.setItem("ingresos.activeTab", "ppm").
ejecutado_anual, guardados, aprobados, borradores; dos cards con ultimo_estado y ultima_fecha_calculo (más ultimo_tipo_presentacion si está).
Cabecera con cinco métricas (ejecutado_anual, total_registros, contabilizados, aprobados, borradores); dos cards con Cobertura tipos (financiero/tributario OK?) y Última actualización.
Acción inline: Generar Stock
Section titled “Acción inline: Generar Stock”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.
Mapeo de estado a etiqueta
Section titled “Mapeo de estado a etiqueta”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}| Caso | paso.estado | labelPendiente | Badge mostrado |
|---|---|---|---|
| Sin datos del período | pendiente | empieza con "sin " | Sin datos |
| No ejecutado (balances/resultados) | pendiente | "no ejecutado" | Sin datos |
| Bloqueado por período previo | pendiente | "bloqueado" | Sin datos |
| Pendiente activo | pendiente | otro | Pendiente |
| Parcial | parcial | (no aplica) | Parcial |
| Completo | completo | (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.
Navegación cross-island
Section titled “Navegación cross-island”El componente dispara dos saltos a otras islands desde el detalle:
| Origen | Destino | Mecanismo |
|---|---|---|
incobrables → botón “Abrir Libro de banco” | LibroBancoView | window.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.
Endpoints consumidos
Section titled “Endpoints consumidos”| Método | Ruta | Operación |
|---|---|---|
GET | /api/ciclo-contable/estado?anio=Y&mes=M | Pipeline mensual (11 pasos). |
GET | /api/ciclo-contable/estado?anio=Y | Pipeline 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.
Proxy local
Section titled “Proxy local”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.
Reglas de UI
Section titled “Reglas de UI”| Regla | Motivo |
|---|---|
| Usar tenant activo en todas las llamadas | El pipeline es por tenant; el panel queda vacío hasta que tenantId() resuelve. |
Disparar 12 fetch en paralelo si mes === null | Orchestrator 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 Pendiente | Un paso sin actividad ni esquema (multi-tenant tolerante) no debe leerse como “tarea por hacer”. |
Recargar tras Generar Stock | Sincroniza el badge con el nuevo estado que retorne Orchestrator. |
| Botón de generar sólo en mensual con mes activo | La acción del dominio inventario opera por mes; no aplica a la vista anual ni al resumen. |
Etiquetar cuentas de incobrables contra el plan | Mostrar código · nombre ayuda al operador a verificar antes de saltar al Libro de banco. |
| No exponer estado Global desde el contrato | La columna Global del resumen 12 meses es UX local; no es un dato del backend. |