Casos de uso - Bproc de tipo Viewer para análisis de condicion de pago

Configuración e Implementación de Bproc tipo Viewer

Este documento técnico y operativo detalla el uso, los objetivos principales y un caso práctico de implementación del Bproc de tipo Viewer dentro del sistema GO, enfocado en el análisis visual de condiciones de pago de facturas de venta pendientes.

1. Descripción y Objetivo General

Un Bproc de tipo Viewer es una herramienta avanzada del sistema GO diseñada para extender las funcionalidades estándar de la plataforma. Permite agregar acciones personalizadas directamente en las grillas de las vistas (ya sean maestros o documentos).

El objetivo principal de esta funcionalidad es dotar al sistema de flexibilidad analítica y operativa, permitiendo conectar los datos de GO con lógica personalizada mediante scripts generados en Guest Code.

2. Características Principales

  • Propósito de Extensión: Facilita la ejecución de lógica de negocio a medida (procesamiento de datos, integraciones, validaciones) mediante scripts en lenguaje python, enviando la información de los ítems seleccionados.

  • Asignación Unívoca: Cada Bproc de tipo Viewer se vincula a una única vista específica dentro del ecosistema de GO, visualizándose operativamente como botones de acción contextuales en dicha grilla.

  • Control de Acceso Basado en Roles: La visibilidad del botón y la capacidad de ejecución de las acciones están completamente restringidas y controladas según la pertenencia de los usuarios a determinados equipos de trabajo.

3. Componentes del Bproc Viewer

Componente Descripción Funcional
Actividades Representan las acciones individuales parametrizadas dentro del Bproc. Cada actividad genera un botón independiente en la interfaz de la vista para el usuario habilitado.
Equipos Filtro de seguridad fundamental que define qué grupos de usuarios cuentan con los privilegios necesarios para ejecutar el Bproc desde el menú de opciones.
Tipo de Acción Define la naturaleza técnica del procesamiento. El sistema admite la ejecución nativa de un Script o la invocación de una API externa mediante una dirección URL.

4. Modalidades de Ejecución

El procesamiento de la información en el Bproc de Viewer varía de acuerdo con el volumen de datos seleccionado por el operador, ofreciendo tres mecánicas diferenciadas:

Modalidad Comportamiento Operativo Validación del Sistema
Un Registro Ejecuta la acción exclusivamente sobre una única fila seleccionada en la grilla activa. Inicio inmediato y directo. No se requiere ventana emergente de confirmación.
Selección Múltiple Procesa en lote un conjunto de registros marcados por el usuario mediante las casillas de selección. Solicita confirmación explícita mediante un pop-up interactivo: “Acabas de seleccionar N registros ¿Deseas continuar?”.
Todos los registros Ignora cualquier check manual y extrae el universo completo de registros expuestos en la vista técnica del sistema. Despliega un pop-up de advertencia crítica informando que la acción afectará de forma masiva a la totalidad de los elementos de la vista.

5. Caso de Uso Práctico: Análisis de Condiciones de Pago de Facturas Pendientes

Contexto del negocio: Con el fin de evaluar los flujos de cobro, la gerencia financiera requiere un método dinámico para visualizar las condiciones de pago de las facturas de venta que se encuentran pendientes en el sistema GO.

Para dar respuesta a esta necesidad, se plantea una integración aprovechando la potencia del Bproc de tipo Viewer acoplado a un desarrollo a medida en Python a través de la aplicación Guest code.

Flujo de Implementación Paso a Paso:

  1. Alta del Componente: Generar un nuevo registro de Bproc de tipo Viewer en el panel de administración de GO.

  2. Asignación de Contexto: Vincular el Bproc específicamente a la vista que contiene la grilla de “Facturas de Venta Pendientes”.

  1. Creación de la Actividad: Configurar una nueva actividad para el Bproc que añadirá un botón visible en la interfaz operativa bajo el título descriptivo: “Ver en formato de gráfico la condición de pago”.

  2. Configuración Técnica del Endpoint: Definir el Tipo de Acción como API e introducir en el campo correspondiente la dirección URL generada y expuesta desde el entorno de desarrollo de la aplicación Guest code.

  1. Lógica de Procesamiento y Analítica (Script Python):
  • Al accionar el botón en la grilla del sistema GO, se capturan y envían los datos de los registros de facturas seleccionados hacia la URL configurada.

  • La aplicación guest code ejecuta el script programado en Python.

  • El script realiza una extracción, segmentación y análisis multidimensional de las condiciones de pago basándose en tres variables esenciales:

    • Por Tipo: Clasificación y tipología de la factura de venta en cuestión.

    • Por Método de Pago: Segmentación según la vía de cobro asociada (transferencia bancaria, cheque electrónico, efectivo, etc.).

    • Por Condición de Pago: Agrupación por los plazos de vencimiento pactados comercialmente (Contado, 30 días, 60 días, entre otros).

# Script para Finnegans GO Guest Code
# Gráfico Importe por Condición de Pago — 100% autocontenido (sin CDN ni librerías JS)
# Fix: BProcViewer(request.json()) en lugar de model_validate_json

import json
from finnegans.scripting import request, HTTPResponse
from finnegans.bproc import BProcViewer


PALETA = [
    "#3b82f6", "#ef4444", "#10b981", "#f59e0b",
    "#8b5cf6", "#06b6d4", "#f97316", "#ec4899",
    "#84cc16", "#6366f1",
]


# ── helpers ──────────────────────────────────────────────────────────────────

def agrupar_por_condicion(records: list) -> tuple:
    totals = {}
    for rec in records:
        condicion = (rec.get("CONDICIONPAGO") or "Sin Condición").strip()
        raw = rec.get("IMPORTEMONPRINCIPAL", "0") or "0"
        try:
            importe = float(raw)
        except (ValueError, TypeError):
            importe = 0.0
        totals[condicion] = totals.get(condicion, 0.0) + importe

    sorted_items = sorted(totals.items(), key=lambda x: x[1], reverse=True)
    labels = [item[0] for item in sorted_items]
    values = [round(item[1], 2) for item in sorted_items]
    return labels, values


def fmt_moneda(valor: float) -> str:
    entero, *dec = f"{valor:.2f}".split(".")
    grupos = []
    while len(entero) > 3:
        grupos.insert(0, entero[-3:])
        entero = entero[:-3]
    grupos.insert(0, entero)
    return "$ " + ".".join(grupos) + "," + (dec[0] if dec else "00")


def build_conic_gradient(values: list, gap_deg: float = 2.5) -> str:
    total = sum(values)
    if total <= 0:
        return "#e2e8f0 0deg 360deg"
    n = len(values)
    usable = 360.0 - (gap_deg * n if n > 1 else 0)
    stops = []
    angle = 0.0
    for i, val in enumerate(values):
        sweep = (val / total) * usable
        color = PALETA[i % len(PALETA)]
        end = angle + sweep
        stops.append(f"{color} {angle:.2f}deg {end:.2f}deg")
        angle = end
        if i < n - 1:
            gap_end = angle + gap_deg
            stops.append(f"#ffffff {angle:.2f}deg {gap_end:.2f}deg")
            angle = gap_end
    return f"conic-gradient(from -90deg, {', '.join(stops)})"


def build_pie_section(labels: list, values: list, total_importe: float) -> str:
    if not labels:
        return '<p class="empty-msg">Sin datos para el gráfico.</p>'

    gradient = build_conic_gradient(values)
    legend_html = ""
    for i, (lbl, val) in enumerate(zip(labels, values)):
        color = PALETA[i % len(PALETA)]
        pct = round((val / total_importe) * 100, 1) if total_importe > 0 else 0
        legend_html += f"""
        <div class="legend-item">
          <span class="legend-swatch" style="background:{color};"></span>
          <div class="legend-text">
            <span class="legend-label" title="{lbl}">{lbl}</span>
            <span class="legend-amount">{fmt_moneda(val)}</span>
          </div>
          <span class="legend-pct">{pct}%</span>
        </div>"""

    return f"""
    <div class="pie-layout">
      <div class="pie-ring">
        <div class="pie-chart" style="background:{gradient};"></div>
      </div>
      <div class="legend">{legend_html}</div>
    </div>"""


def build_bar_section(labels: list, values: list, total_importe: float) -> str:
    if not labels:
        return '<p class="empty-msg">Sin datos para mostrar.</p>'

    max_val = max(values)
    rows = ""
    for i, (lbl, val) in enumerate(zip(labels, values)):
        color = PALETA[i % len(PALETA)]
        pct_barra = round((val / max_val) * 100, 1) if max_val > 0 else 0
        pct_total = round((val / total_importe) * 100, 1) if total_importe > 0 else 0
        show_pct_inside = pct_barra >= 18
        pct_inside = f'<span class="bar-pct">{pct_total}%</span>' if show_pct_inside else ""
        pct_outside = f'<span class="bar-pct-out">{pct_total}%</span>' if not show_pct_inside else ""
        rows += f"""
        <div class="bar-item">
          <div class="bar-meta">
            <span class="bar-dot" style="background:{color};"></span>
            <span class="bar-label" title="{lbl}">{lbl}</span>
            <span class="bar-value">{fmt_moneda(val)}</span>
          </div>
          <div class="bar-track-wrap">
            <div class="bar-track">
              <div class="bar-fill" style="width:{pct_barra}%;background:{color};">{pct_inside}</div>
            </div>
            {pct_outside}
          </div>
        </div>"""
    return rows


def build_table_rows(labels: list, values: list, total_importe: float) -> str:
    if not labels:
        return '<tr><td colspan="3" class="empty-cell">Sin datos</td></tr>'

    rows = ""
    for i, (lbl, val) in enumerate(zip(labels, values)):
        color = PALETA[i % len(PALETA)]
        pct_total = round((val / total_importe) * 100, 1) if total_importe > 0 else 0
        rows += f"""
        <tr>
          <td><span class="dot" style="background:{color};"></span>{lbl}</td>
          <td class="num">{fmt_moneda(val)}</td>
          <td class="num">{pct_total}%</td>
        </tr>"""
    return rows


def build_html(labels: list, values: list, total_records: int) -> str:
    total_importe = sum(values) if values else 0.0
    pie_html = build_pie_section(labels, values, total_importe)
    bar_html = build_bar_section(labels, values, total_importe)
    table_html = build_table_rows(labels, values, total_importe)

    return f"""<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Importe por Condición de Pago</title>
  <style>
    *, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
    html, body {{
      width: 100%;
      height: 100%;
      margin: 0;
      padding: 0;
      overflow: hidden;
    }}
    body {{
      font-family: 'Segoe UI', system-ui, Arial, sans-serif;
      color: #1e293b;
      background: #eef2f7;
    }}
    .viewport {{
      width: 100%;
      height: 100%;
      overflow-x: auto;
      overflow-y: auto;
      -webkit-overflow-scrolling: touch;
    }}
    .shell {{
      width: 100%;
      min-width: 720px;
      padding: 10px 14px 12px;
      display: flex;
      flex-direction: column;
      gap: 8px;
      box-sizing: border-box;
    }}
    .header {{
      background: linear-gradient(135deg, #1e40af 0%, #3b82f6 100%);
      color: #fff;
      border-radius: 10px;
      padding: 11px 14px;
      box-shadow: 0 2px 10px rgba(30, 64, 175, 0.2);
      width: 100%;
      box-sizing: border-box;
    }}
    .header h2 {{
      font-size: 0.98rem;
      font-weight: 700;
      letter-spacing: -0.01em;
    }}
    .header p {{
      font-size: 0.72rem;
      opacity: 0.88;
      margin-top: 2px;
    }}

    .kpi-row {{
      display: grid;
      grid-template-columns: repeat(3, 1fr);
      gap: 8px;
      width: 100%;
    }}
    .kpi {{
      background: #fff;
      border-radius: 8px;
      border: 1px solid #e2e8f0;
      padding: 8px 10px;
      text-align: center;
    }}
    .kpi .kpi-label {{
      font-size: 0.62rem;
      color: #94a3b8;
      text-transform: uppercase;
      letter-spacing: 0.05em;
      font-weight: 600;
    }}
    .kpi .kpi-value {{
      font-size: 0.88rem;
      font-weight: 700;
      color: #0f172a;
      margin-top: 2px;
      line-height: 1.2;
      white-space: nowrap;
    }}

    .charts-row {{
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 8px;
      width: 100%;
    }}

    .card {{
      background: #fff;
      border-radius: 10px;
      border: 1px solid #e2e8f0;
      box-shadow: 0 1px 3px rgba(15, 23, 42, 0.05);
      padding: 11px 13px;
      width: 100%;
      box-sizing: border-box;
    }}
    .card-title {{
      font-size: 0.72rem;
      font-weight: 700;
      color: #334155;
      margin-bottom: 10px;
      padding-bottom: 6px;
      border-bottom: 1px solid #f1f5f9;
      text-transform: uppercase;
      letter-spacing: 0.04em;
    }}

    /* ── Torta completa (conic-gradient + separadores) ── */
    .pie-layout {{
      display: flex;
      align-items: center;
      gap: 14px;
      min-height: 0;
    }}
    .pie-ring {{
      flex-shrink: 0;
      padding: 4px;
      border-radius: 50%;
      background: #fff;
      box-shadow: 0 0 0 1px #e2e8f0, 0 4px 12px rgba(15, 23, 42, 0.08);
    }}
    .pie-chart {{
      width: 150px;
      height: 150px;
      border-radius: 50%;
      filter: drop-shadow(0 1px 4px rgba(15, 23, 42, 0.08));
    }}
    .legend {{
      flex: 1;
      min-width: 0;
      display: flex;
      flex-direction: column;
      gap: 6px;
      max-height: 160px;
      overflow-y: auto;
      padding-right: 4px;
    }}
    .legend-item {{
      display: grid;
      grid-template-columns: 10px 1fr auto;
      align-items: start;
      gap: 7px;
      padding: 6px 8px;
      background: #f8fafc;
      border-radius: 6px;
      border: 1px solid #f1f5f9;
    }}
    .legend-swatch {{
      width: 10px;
      height: 10px;
      border-radius: 3px;
      margin-top: 2px;
    }}
    .legend-text {{
      min-width: 0;
      display: flex;
      flex-direction: column;
      gap: 1px;
    }}
    .legend-label {{
      font-size: 0.72rem;
      font-weight: 600;
      color: #1e293b;
      line-height: 1.25;
      word-break: break-word;
    }}
    .legend-amount {{
      font-size: 0.68rem;
      color: #64748b;
      white-space: nowrap;
    }}
    .legend-pct {{
      font-weight: 800;
      color: #1e40af;
      font-size: 0.78rem;
      white-space: nowrap;
      padding-top: 1px;
    }}

    /* ── Barras ── */
    .bars-scroll {{
      display: flex;
      flex-direction: column;
      gap: 10px;
      max-height: 170px;
      overflow-y: auto;
      padding-right: 2px;
    }}
    .bar-item {{
      display: flex;
      flex-direction: column;
      gap: 4px;
    }}
    .bar-meta {{
      display: flex;
      align-items: center;
      gap: 6px;
      font-size: 0.72rem;
    }}
    .bar-dot {{
      width: 8px;
      height: 8px;
      border-radius: 50%;
      flex-shrink: 0;
    }}
    .bar-label {{
      flex: 1;
      min-width: 0;
      color: #334155;
      font-weight: 600;
      word-break: break-word;
    }}
    .bar-value {{
      font-weight: 700;
      color: #0f172a;
      white-space: nowrap;
      font-size: 0.72rem;
    }}
    .bar-track-wrap {{
      display: flex;
      align-items: center;
      gap: 6px;
    }}
    .bar-track {{
      flex: 1;
      background: #f1f5f9;
      border-radius: 5px;
      height: 20px;
      overflow: hidden;
    }}
    .bar-fill {{
      height: 100%;
      border-radius: 5px;
      display: flex;
      align-items: center;
      min-width: 4px;
    }}
    .bar-pct {{
      font-size: 0.65rem;
      color: #fff;
      font-weight: 700;
      padding-left: 6px;
      white-space: nowrap;
      text-shadow: 0 1px 2px rgba(0,0,0,.25);
    }}
    .bar-pct-out {{
      font-size: 0.7rem;
      font-weight: 700;
      color: #64748b;
      white-space: nowrap;
      min-width: 36px;
      text-align: right;
    }}

    /* ── Tabla ── */
    .table-wrap {{
      max-height: 140px;
      overflow: auto;
      border: 1px solid #f1f5f9;
      border-radius: 6px;
    }}
    table {{ width: 100%; border-collapse: collapse; font-size: 0.74rem; }}
    thead th {{
      position: sticky;
      top: 0;
      z-index: 1;
      background: #f8fafc;
      color: #64748b;
      font-weight: 700;
      padding: 6px 10px;
      text-align: left;
      border-bottom: 2px solid #e2e8f0;
      font-size: 0.66rem;
      text-transform: uppercase;
      letter-spacing: 0.04em;
    }}
    thead th.num {{ text-align: right; }}
    tbody tr:hover {{ background: #f8fafc; }}
    tbody td {{
      padding: 6px 10px;
      border-bottom: 1px solid #f1f5f9;
      color: #334155;
      vertical-align: middle;
    }}
    tbody td.num {{ text-align: right; font-weight: 600; color: #1e293b; }}
    tbody tr:last-child td {{ border-bottom: none; }}
    .dot {{
      display: inline-block;
      width: 9px;
      height: 9px;
      border-radius: 50%;
      margin-right: 7px;
      vertical-align: middle;
      flex-shrink: 0;
    }}
    .empty-msg, .empty-cell {{
      color: #94a3b8;
      text-align: center;
      padding: 24px;
      font-size: 0.85rem;
    }}
    .footer {{
      font-size: 0.72rem;
      color: #94a3b8;
      text-align: center;
      padding-top: 4px;
    }}

    /* Scrollbar discreta */
    .viewport::-webkit-scrollbar,
    .legend::-webkit-scrollbar,
    .bars-scroll::-webkit-scrollbar,
    .table-wrap::-webkit-scrollbar {{
      width: 8px;
      height: 8px;
    }}
    .viewport::-webkit-scrollbar-thumb,
    .legend::-webkit-scrollbar-thumb,
    .bars-scroll::-webkit-scrollbar-thumb,
    .table-wrap::-webkit-scrollbar-thumb {{
      background: #cbd5e1;
      border-radius: 4px;
    }}
    .viewport::-webkit-scrollbar-track {{
      background: #e2e8f0;
    }}
  </style>
</head>
<body>
  <div class="viewport">
    <div class="shell">
      <div class="header">
        <h2>Importe por Condición de Pago</h2>
        <p>{total_records} comprobante(s) seleccionado(s) &mdash; agrupado por condición</p>
      </div>

      <div class="kpi-row">
        <div class="kpi">
          <div class="kpi-label">Registros</div>
          <div class="kpi-value">{total_records}</div>
        </div>
        <div class="kpi">
          <div class="kpi-label">Condiciones</div>
          <div class="kpi-value">{len(labels)}</div>
        </div>
        <div class="kpi">
          <div class="kpi-label">Total General</div>
          <div class="kpi-value">{fmt_moneda(total_importe)}</div>
        </div>
      </div>

      <div class="charts-row">
        <div class="card">
          <div class="card-title">Distribución por condición</div>
          {pie_html}
        </div>
        <div class="card">
          <div class="card-title">Comparativo por importe</div>
          <div class="bars-scroll">{bar_html}</div>
        </div>
      </div>

      <div class="card">
        <div class="card-title">Detalle</div>
        <div class="table-wrap">
          <table>
            <thead>
              <tr>
                <th>Condición de Pago</th>
                <th class="num">Importe</th>
                <th class="num">% del Total</th>
              </tr>
            </thead>
            <tbody>{table_html}</tbody>
          </table>
        </div>
      </div>

      <div class="footer">Finnegans GO &bull; Guest Code</div>
    </div>
  </div>
  <script>
    try {{
      if (window.parent && window.parent !== window) {{
        var vp = document.querySelector('.viewport');
        var shell = document.querySelector('.shell');
        var h = Math.max(vp ? vp.scrollHeight : 0, shell ? shell.scrollHeight : 0, document.body.scrollHeight) + 24;
        var w = shell ? shell.scrollWidth : 720;
        window.parent.postMessage("resizepopup", "*");
        window.parent.postMessage({{ type: "resize", width: w, height: h }}, "*");
        window.parent.postMessage({{ type: "guest-code-resize", width: w, height: h }}, "*");
      }}
    }} catch (e) {{}}
  </script>
</body>
</html>"""


# ── punto de entrada ──────────────────────────────────────────────────────────

def main():
    try:
        viewer = BProcViewer(request.json())
        records = viewer.get_selected_records()

        if not records:
            html = """<!DOCTYPE html><html lang="es"><head><meta charset="UTF-8"><style>
            body{font-family:Arial,sans-serif;display:flex;align-items:center;justify-content:center;
            min-height:100vh;background:#eef2f7;margin:0;padding:20px;}
            .msg{background:#fff;border-radius:12px;padding:36px 44px;text-align:center;
            box-shadow:0 2px 14px rgba(0,0,0,.08);max-width:420px;}
            h3{color:#64748b;margin:0;}p{color:#94a3b8;margin-top:8px;font-size:.85rem;}
            </style></head><body><div class="msg">
            <h3>No hay registros seleccionados</h3>
            <p>Seleccioná al menos un comprobante y volvé a ejecutar.</p>
            </div></body></html>"""
            return HTTPResponse(200, body=html)

        labels, values = agrupar_por_condicion(records)
        html = build_html(labels, values, len(records))
        return HTTPResponse(200, body=html)

    except Exception as exc:
        html = f"""<!DOCTYPE html><html lang="es"><head><meta charset="UTF-8">
        <style>body{{font-family:Arial,sans-serif;padding:24px;background:#fff1f2;margin:0;}}
        h3{{color:#b91c1c;margin-bottom:12px;}}
        pre{{background:#ffe4e6;padding:16px;border-radius:8px;font-size:.82rem;
        color:#991b1b;white-space:pre-wrap;word-break:break-all;}}
        </style></head><body>
        <h3>Error al generar el gráfico</h3>
        <pre>{str(exc)}</pre>
        </body></html>"""
        return HTTPResponse(500, body=html)

  1. Visualización del Output: La aplicación externa procesa las métricas y devuelve la información consolidada en un formato de gráfico interactivo y visual, optimizando la toma de decisiones estratégicas sobre las cuentas por cobrar del negocio.