primeira versão do e-li-nps construido com IA

This commit is contained in:
Luiz Silva 2025-12-31 11:18:20 -03:00
commit 06950d6e2c
34 changed files with 2524 additions and 0 deletions

View file

@ -0,0 +1,157 @@
package elinps
import (
"context"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
// ListarProdutos retorna os produtos existentes a partir das tabelas `nps_*`.
//
// Importante: este painel é para exploração interna. Mesmo assim, mantemos uma
// sanitização mínima no nome (prefixo nps_ removido).
func (s *Store) ListarProdutos(ctx context.Context) ([]string, error) {
rows, err := s.pool.Query(ctx, `
SELECT tablename
FROM pg_catalog.pg_tables
WHERE schemaname='public' AND tablename LIKE 'nps_%'
ORDER BY tablename`)
if err != nil {
return nil, err
}
defer rows.Close()
produtos := []string{}
for rows.Next() {
var t string
if err := rows.Scan(&t); err != nil {
return nil, err
}
produtos = append(produtos, strings.TrimPrefix(t, "nps_"))
}
return produtos, rows.Err()
}
// NPSMesAMes calcula o NPS por mês para um produto (tabela `nps_{produto}`).
//
// Regra NPS (110):
// - 16 detratores
// - 78 neutros
// - 910 promotores
func (s *Store) NPSMesAMes(ctx context.Context, tabela string, meses int) ([]NPSMensal, error) {
// Segurança: tabela deve ser derivada de NormalizeProduto + prefixo.
q := fmt.Sprintf(`
WITH base AS (
SELECT
date_trunc('month', respondido_em) AS mes,
nota
FROM %s
WHERE status='respondido'
AND valida=true
AND respondido_em IS NOT NULL
AND respondido_em >= date_trunc('month', now()) - ($1::int * interval '1 month')
)
SELECT
to_char(mes, 'YYYY-MM') AS mes,
SUM(CASE WHEN nota BETWEEN 1 AND 6 THEN 1 ELSE 0 END)::int AS detratores,
SUM(CASE WHEN nota BETWEEN 7 AND 8 THEN 1 ELSE 0 END)::int AS neutros,
SUM(CASE WHEN nota BETWEEN 9 AND 10 THEN 1 ELSE 0 END)::int AS promotores,
COUNT(*)::int AS total
FROM base
GROUP BY mes
ORDER BY mes ASC`, tabela)
rows, err := s.pool.Query(ctx, q, meses)
if err != nil {
return nil, err
}
defer rows.Close()
out := []NPSMensal{}
for rows.Next() {
var m NPSMensal
if err := rows.Scan(&m.Mes, &m.Detratores, &m.Neutros, &m.Promotores, &m.Total); err != nil {
return nil, err
}
if m.Total > 0 {
pctProm := float64(m.Promotores) / float64(m.Total) * 100
pctDet := float64(m.Detratores) / float64(m.Total) * 100
m.NPS = int((pctProm - pctDet) + 0.5) // arredonda para inteiro
}
out = append(out, m)
}
return out, rows.Err()
}
type ListarRespostasFiltro struct {
SomenteNotasBaixas bool
Pagina int
PorPagina int
}
func (f *ListarRespostasFiltro) normalizar() {
if f.Pagina <= 0 {
f.Pagina = 1
}
if f.PorPagina <= 0 || f.PorPagina > 200 {
f.PorPagina = 50
}
}
// ListarRespostas retorna respostas respondidas, com paginação e filtro.
func (s *Store) ListarRespostas(ctx context.Context, tabela string, filtro ListarRespostasFiltro) ([]RespostaPainel, error) {
filtro.normalizar()
offset := (filtro.Pagina - 1) * filtro.PorPagina
cond := "status='respondido' AND valida=true"
if filtro.SomenteNotasBaixas {
cond += " AND nota BETWEEN 1 AND 6"
}
q := fmt.Sprintf(`
SELECT
id,
respondido_em,
pedido_criado_em,
usuario_codigo,
usuario_nome,
usuario_email,
nota,
justificativa
FROM %s
WHERE %s
ORDER BY respondido_em DESC NULLS LAST
LIMIT $1 OFFSET $2`, tabela, cond)
rows, err := s.pool.Query(ctx, q, filtro.PorPagina, offset)
if err != nil {
return nil, err
}
defer rows.Close()
respostas := []RespostaPainel{}
for rows.Next() {
var r RespostaPainel
if err := rows.Scan(
&r.ID,
&r.RespondidoEm,
&r.PedidoCriadoEm,
&r.UsuarioCodigo,
&r.UsuarioNome,
&r.UsuarioEmail,
&r.Nota,
&r.Justificativa,
); err != nil {
return nil, err
}
respostas = append(respostas, r)
}
return respostas, rows.Err()
}
// ensure interface imports
var _ = pgx.ErrNoRows
var _ = time.Second