codenautas/backend-plus

View on GitHub
doc/reportes-conteo.md

Summary

Maintainability
Test Coverage
# Reportes que cuentan cosas

## objetivo

Que los reportes, informes o tablas resumen tengan una forma automática 
que le permita al usuario ver los elementos contados en cada categoría. 

Por ejemplo si el reporte es un reporte de empleados por sucursal:

sucursal   | jefes | profesionales | técnicos | otros | TOTAL 
-----------|------:|--------------:|---------:|------:|------:
SUR        |     2 |            12 |       18 |     2 |    34
NORTE      |     1 |             8 |        9 |     1 |    20
TOTAL      |     3 |            20 |       17 |     3 |    54

debería haber una manera para ver la lista de empleados que están en una categoría
(profesionales de la sucursal SUR). 

## antecedentes

Tenemos antecedentes de nuestra forma de caracterizar antes de contar

   1. Desarrollamos [tabulator](https://www.npmjs.com/package/tabulator) en el 2015 con este objetivo,
      la tabla que se genera es un objeto que puede pasarse a HTML, 
      pero no está integrado con las grillas de backend-plus. 
   2. Para las grillas backend-plus tenemos como costumbre definir la tabla poniendo en 
      `sql.from` una subconsulta que caracterice en un campo nuevo los renglones a contar 
      y luego basar la cuenta en ese campo. Eso garantiza que ningún registro se va a contar 2 veces.

No tenemos una forma unificada de revisar. 

## caracterizar antes de contar

Caracterizar antes de contar es un método que asegura que no exista duplicación
involuntaria (por la forma en que se escribieron las expresiones que caracterizan los casos)
ni existan registros que no se contaron. 
Así puede haber una columna de total que seguro es la cantidad de registros revisados. 

Cosas riesgosas pueden ser escribir cosas como:
```sql
select sucursal,
       count(case when es_jefe then 1 else null end) as jefes,
       count(case when categoria='profesional' then 1 else null end) as profesionales,
       count(case when categoria='tecnico' then 1 else null end) as tecnicos,
       count(*) as TOTAL1,
       jefes+profesionales+tecnicos as TOTAL2
    from empleados
    group by sucursal;
```

Ahí TOTAL1 es la cantidad de empleados, TOTAL2 es la suma de las columnas,
un empleado puede estar contado dos veces, como profesional y como técnico. 

Cierto es que podría ocurrir que alguien pidiera un listado donde un registro se cuente dos veces,
es probable que no haya una columna TOTAL para ello. 
De todos modos es buena práctica que eso quede bien claro,
supongamos entonces que está claro que profesionales y técnicos los queremos solo distinguir en los no jefes,
el diseño debería ser algo así:

![sobre profesoinales, técnicos y otros debe leerse "no jefes"](reportes-conteo-fig1.png)

Queda claro entonces que profesional y técnico se refieren a "no jefes" profesionales y técnicos. 

## programar para el futuro

Pero ¿qué pasa con los que no son jefes ni profesionales ni técnicos?
El usuario nos puede decir solo hay profesionales y técnicos
¿Por qué entonces no usamos un booleano? `es_profesional`. 
¿Será porque es poco descriptivo? ¿será porque algún día podría haber una tercera categoría?.

La pregunta es: ¿es absolutamente inconcebible que exista una categoría más?
Si la respuesta es no, es correcto usar un campo de texto (o quizás uno numérico).
Pero ¿cuál es la programación correcta del SQL que genera el listado?.
¿Hay que prever que puede haber más? Una manera sería agregar el campo "otros".
¿Pero si el usuario nos dice que no quiere esa columna porque no hay "otros"?

> **_Ok, si no hay otros no hay otros_**

Esa frase no dice nada de qué pasa cuando sí hay, la solución es entonces
que haya otros cuando hay. 

O sea se podría contar los otros siempre y controlar si alguno de los registros
tiene un valor distinto de cero y, en el caso de que que haya, mostrar la columna. 

Repitamos entonces el SQL dentro de una subconsulta que caracterice los registros:

```sql
select sucursal,
       count(case when caracterirzacion='jefe' then 1 else null end) as jefes,
       count(case when caracterirzacion='profesional' then 1 else null end) as profesionales,
       count(case when caracterirzacion='tecnico' then 1 else null end) as tecnicos,
       count(case when caracterirzacion='otros' then 1 else null end) as otros,
       count(*) as TOTAL1
    from (
        select sucursal,
               case when es_jefe then 1 else 'jefe'
                    when categoria in ('profesional','tecnico') then categoria
                    else 'otros' 
                end as caracterizacion
            from empleados
    ) as empleados_caracterizados
    group by sucursal;
```

Habiendo quedado claro que la caracterización por jefe era excluyente 
y satisfaciendo al usuario en no mostrar la columna otros mientras no haya otros,
tenemos una formulación con las siguientes ventajas:
   * el total es la suma aunque aparezcan "otros"
   * no hay duplicación

Pero todavía quedan algunos problemas a resolver:
   * no hay interface de usuario para hacer el "master/detail"
   * entre la subconsulta y la consulta de arriba hay código duplicado y baja cohesión;
     si se modifica lo de abajo (agregando alguna categoría y uno se olvida de agregarlo
     arriba va a haber una incoherencia)

## solución rápida para backend-plus

Vamos a diseñar una solución que resuelva el problema del la baja cohesión entre partes.

Se podría crear un nuevo tipo de definición de tablas donde el SQL se arme automáticamente
basado en:
   1. Una definición de grilla típica de backend-plus a nivel de la tabla origen de la cuenta 
   2. Un agregado en la definición que indique cómo es la caracterización. 
   
Algo así como

```ts
function cuenta_empleados(context:TableContext){
    return {
        table:'cuenta_empleados',
        fields:[
            {name:'sucursal', typeName:'text'},
        ],
        classifier:{
            source:'empleados',
            criteria:[
                {name:'jefe', when:'es_jefe'},
                {name:'no jefe', when:`categoria in ('profesional','tecnico')`, then:'categoria'},
                {else:'otros'},
                {name:'total', title:'TOTAL', special:'sum_all'}
            ]
        }
    }
}
```

Ahí vemos casi todos los elementos separados para poder armar el SQL, 
falta saber cuántas columnas habrá en categorías. 
Quitemos entonces el atributo `then`. 

```ts
function cuenta_empleados(context:TableContext){
    return {
        table:'cuenta_empleados',
        fields:[
            {name:'sucursal', typeName:'text'},
        ],
        classifier:{
            source:'empleados',
            criteria:[
                {name:'jefe', when:'es_jefe'},
                {name:`no_jefe_profesional`, when:`categoria = 'profesional'`, title:'profesional'},
                {name:`no_jefe_tecnico`, when:`categoria = 'tecnico'`, title:'técnico'},
                {else:'otros'},
                {name:'total', title:'TOTAL', special:'sum_all'}
            ]
        }
    }
}
```

Si se quisiera que basarse en una lista (que puede ser fija o dependiente del contenido 
de una tabla) se pueden agregar dinámicamente elementos a `criteria`. 

```ts
    ...(['profesional', 'tecnico'].map(cat=>
        {name:`no_jefe_${cat}`, when:`categoria = ${quote_literal(cat)}`, title:cat}
    ),
```

Sigue todavía el problema de cómo visualizar el detalle. Si bien se conoce `classifier.source`
la conexión no se puede hacer directamente porque en el source no existe el clasificador. 

# La solución final

Es claro que es conveniente poder indicar dentro mismo de la tabla empleados que 
hay una clasificación de empleados que se puede usar para contar. 

```ts
function empleados(context:TableContext){
    return {
        table:'empleados',
        fields:[
            {name:'id_empleado', typeName:'text'   },
            {name:'apellido'   , typeName:'text'   , isName:true},
            {name:'nombre'     , typeName:'text'   , isName:true},
            {name:'sucursal'   , typeName:'text'   },
            {name:'turno'      , typeName:'text'   },
            {name:'es_jefe'    , typeName:'boolean'},
            {name:'categoria'  , typeName:'text'   },
        ],
        classifiers:[
            {
                classifier:'jefe_categoria',
                groupers:[
                    ['sucursal'],
                    ['turno'],
                    ['sucursal','turno'],
                ]
                criteria:[
                    {name:'jefe', when:'es_jefe'},
                    {name:`no_jefe_profesional`, when:`categoria = 'profesional'`, title:'profesional'},
                    {name:`no_jefe_tecnico`, when:`categoria = 'tecnico'`, title:'técnico'},
                    {else:'otros'},
                    {name:'total', title:'TOTAL', special:'sum_all'}
                ]
            }
        ]
    }
}
```

¿Qué tenemos con esta definición?. ¿Qué va a hacer tableDefAdapt?
   1. Agregar a fields un campo oculto por cada clasificador ("jefe_categoría" en este caso).
   2. Marcar con `grouper:true` todas las FK que no digan explícitamente `grouper:false`
   3. Agregar en las definiciones de tablas una tabla por cada clasificador/agrupador
      en este caso son 3 tablas nuevas: `empleados__jefe_categoria__sucursal`, `empleados__jefe_categoria__turno`, `empleados__jefe_categoria__sucursal__turno`. 

Luego es solo cuestión de agregarlo esas tablas generadas en el menú. 

Quizás también se pueda poner en el menú contextual de la tabla 
una opción para elegir alguno de las tablas clasificadoras.

![se debe agregar una opción nueva en los ... de la tabla](reportes-conteo-fig2.png)