diff --git a/README.md b/README.md index e3d1a16..36b65aa 100644 --- a/README.md +++ b/README.md @@ -2113,3 +2113,121 @@ docker compose exec app bundle exec rspec \ ``` + +--- + +
+🔢 Dashboard × Operações: por que os números não batiam — notas x visitas + painel de avulsas (24/08/2026) + +> ⚠️ **STATUS: implementado, ainda NÃO executado.** Não há Ruby/Bundler nem Postgres na máquina de +> desenvolvimento — foi conferida a sintaxe de todos os `.rb` e `.erb` alterados. **A suíte e a +> validação com dado real continuam pendentes** — roteiro no fim desta seção. + +### 🎯 O problema +Mesmo período filtrado, dois números diferentes: + +| | Dashboard financeiro | Dashboard de Operações | +|---|---|---| +| Total | 4977 | 4973 | +| Entregues / Sucesso | 4852 | 4851 | +| Falhadas / Recusas | 124 | 122 | +| Pendentes | 1 | 0 | + +Não era arredondamento: **as duas telas contam coisas diferentes**, e nada na interface dizia isso. + +- O **financeiro** conta **visitas** (idas ao local). É o recorte certo lá, porque é por ida que o + motorista recebe — `Entrega.contar_atendidas` conta linhas, e a consolidação paga em cima disso. +- **Operações** conta **notas fiscais** (último status de cada NF). É o recorte certo aqui, porque + é o que o cliente paga e o que confere nos documentos físicos — o mesmo critério da aba ENTREGAS + da planilha entregue (`Analytics::PlanilhaEntregas`). + +Uma NF que falhou dia 10 e foi entregue dia 12 vale **2 no financeiro e 1 em Operações**. Correto +nos dois — mas invisível. + +### 🐛 Três defeitos reais por trás disso + +**1. O dedup rodava sobre a tabela inteira, não sobre o período.** O `ROW_NUMBER() ... rn = 1` +ficava numa CTE **antes** do filtro de data. Se o último checkout de uma NF era **posterior** ao fim +do período, a visita que aconteceu **dentro** do período sumia da contagem do mês. Subcontagem +silenciosa em todo fechamento. Agora o dedup acontece em `#linhas`, **depois** do período e do +cross-filter. + +**2. O dedup escondia todo o insucesso reentregue.** NF que falhou duas vezes antes de entregar +aparecia como 100% de sucesso, e o motivo sumia do "Índices de falha". + +**3. O período nem era aplicado no modo Operação.** `montar([@operacao], ...)` era chamado **sem +`inicio:`/`fim:`** e o seletor de data só aparecia no modo Global — comparar as duas telas "no mesmo +período" era literalmente impossível. + +### 🆕 O que mudou na tela + +**Operações — camada "Visitas ao local"** (abaixo dos 4 cards): visitas realizadas, retentativas, +insucessos por visita e "entregues na 2ª ida ou mais". Os 4 cards de cima seguem contando **notas**. + +**Operações — painel "Notas fora da operação"**: NFs entregues no período que **não estão em nenhuma +planilha `gade_entregas_*`** — os planos avulsos e de inclusão. Contavam no financeiro e o +`INNER JOIN` com a tabela da operação as descartava aqui. Agora aparecem com NF, plano/título, +motorista, unidade, data, resultado e motivo, com quebra por plano de origem. + +**Operações — período no modo Operação**: o seletor passa a valer também aqui, mas **só quando o +operador escolhe uma faixa** (`inicio`/`fim` na URL). Sem escolha, o recorte segue sendo a operação +inteira — senão abrir uma operação de meses atrás cairia no mês corrente e mostraria zero. Botão +**"Operação inteira"** volta ao recorte natural. + +**Financeiro — linha "N notas fiscais"** no card Total Entregas, ao lado de "visitas atendidas". + +### ⚙️ Pontos não-óbvios + +**Duas camadas no mesmo objeto.** `OperacaoMetricas#visitas` = uma linha por ida; `#linhas` = uma +linha por NF (a última visita). Todos os KPIs, o donut, os motivos, o mapa e a tabela espelho +continuam saindo de `#linhas` — ou seja, **a tela segue batendo com a planilha do cliente**. Só a +faixa nova lê `#visitas`. + +**`uniq` por `tracking_id`.** Sem a CTE, se a mesma `nota_fiscal` estiver repetida dentro de uma +tabela de operação (ou em duas tabelas do UNION global), o `INNER JOIN` devolvia a **mesma visita** +mais de uma vez. Agora colapsa. + +**Filtro de conta unificado.** `Entrega.condicao_conta_sql` nasceu para as queries cruas de +`Analytics` usarem exatamente o mesmo recorte de `DB_EXISTING_ACCOUNT_ID` do scope +`da_conta_gade` — antes o dashboard filtrava conta e Operações não. + +**`NOT IN` com `NULL` devolve zero linhas.** Cada `SELECT` da união em `NotasForaOperacao` filtra +`nota_fiscal IS NOT NULL`; sem isso um único NULL numa planilha deixaria o painel vazio para sempre. +Tem spec para isso. + +### 📂 Arquivos +``` +app/models/entrega.rb (contas_gade + condicao_conta_sql) +app/services/analytics/operacao_metricas.rb (visitas x linhas; dedup pós-filtro; conta) +app/services/analytics/notas_fora_operacao.rb (NOVO — avulsas/inclusão) +app/controllers/operacoes_dashboard_controller.rb (período no modo Operação; @fora_operacao) +app/controllers/dashboard_controller.rb (@notas_atendidas) +app/views/operacoes_dashboard/_painel.html.erb (faixa "Visitas ao local") +app/views/operacoes_dashboard/_fora_operacao.html.erb (NOVO — painel de avulsas) +app/views/operacoes_dashboard/index.html.erb (seletor de período + render do painel) +app/views/dashboard/index.html.erb (linha "N notas fiscais") +spec/services/analytics/operacao_metricas_spec.rb (+ NF com retentativa) +spec/services/analytics/notas_fora_operacao_spec.rb (NOVO) +``` + +> **Sem migration e sem gem nova** — model, services, controllers, views e specs. + +### ⏳ Pendente — roteiro +```bash +# 1. Suíte (não pôde ser executada aqui — sem Ruby/Bundler local) +docker compose exec app bundle exec rspec \ + spec/services/analytics/operacao_metricas_spec.rb \ + spec/services/analytics/notas_fora_operacao_spec.rb \ + spec/models/entrega_spec.rb + +# 2. Conferir a coluna que carrega o nome do PLANO ("(Avulsa)", "INCLUSÃO"). +# NotasForaOperacao::COLUNAS_PLANO tenta title, notes, comments, route_id nessa ordem. +# Se o painel mostrar "SEM PLANO IDENTIFICADO", a coluna certa é outra: +docker compose exec app bin/rails runner 'puts Entrega.column_names.sort' + +# 3. Com dado real, no mesmo período nas duas telas: +# financeiro "N notas fiscais" == Operações Global "Total de Entregas" +# financeiro "Total Entregas" == Operações "Visitas ao local" + notas fora da operação +``` + +
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 8cba40d..eca9195 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -102,6 +102,13 @@ class DashboardController < ApplicationController @entregas_pendentes = pendentes.count @total_entregas = @entregas_atendidas + @entregas_pendentes + # Quantas NOTAS FISCAIS distintas estão por trás das visitas atendidas. É o + # número que o dashboard de Operações mostra (e que o cliente paga/confere + # nos documentos); aqui contamos VISITAS, porque é por ida ao local que o + # motorista recebe. Exibir os dois lado a lado evita a leitura de que uma das + # telas está errada quando elas divergem — a diferença são retentativas. + @notas_atendidas = atendidas.distinct.count(:reference_id) + # Configurações de preço config = Configuracao.mapa_de_precos diff --git a/app/controllers/operacoes_dashboard_controller.rb b/app/controllers/operacoes_dashboard_controller.rb index 6e79101..ca48bd0 100644 --- a/app/controllers/operacoes_dashboard_controller.rb +++ b/app/controllers/operacoes_dashboard_controller.rb @@ -16,6 +16,7 @@ class OperacoesDashboardController < ApplicationController authorize :dashboard, :operacoes? @periodo_inicio, @periodo_fim = periodo_selecionado + @periodo_explicito = periodo_explicito? @modo = MODOS.include?(params[:modo]) ? params[:modo] : 'operacao' @operacoes_agrupadas = Operacao.agrupadas_por_mes @tabelas_validas = Operacao.nomes_validos @@ -26,6 +27,7 @@ class OperacoesDashboardController < ApplicationController when 'global' # Global: agrega TODAS as operações dentro da faixa de datas escolhida. @metricas = montar(@tabelas_validas, inicio: @periodo_inicio, fim: @periodo_fim, filtros: @filtros, data: @data_filtro) + @fora_operacao = Analytics::NotasForaOperacao.new(inicio: @periodo_inicio, fim: @periodo_fim) when 'comparar' # Comparar: cada operação inteira (sem filtro de data — só vale no Global). @op_a = Operacao.sanitizar([params[:op_a]]).first || @tabelas_validas[0] @@ -33,9 +35,14 @@ class OperacoesDashboardController < ApplicationController @metricas_a = montar([@op_a]) if @op_a @metricas_b = montar([@op_b]) if @op_b else - # Operação única: a operação inteira (a data vem dos próprios dados). + # Operação única. Sem datas na URL o recorte é a operação inteira (o + # período vem dos próprios dados) — é o que mantém utilizável abrir uma + # operação de meses atrás. Quando o operador ESCOLHE um período, ele passa + # a valer aqui também: sem isso, comparar esta tela com o dashboard + # financeiro "no mesmo período" era impossível, porque a data era ignorada. @operacao = Operacao.sanitizar([params[:operacao]]).first || @tabelas_validas.first - @metricas = montar([@operacao], filtros: @filtros, data: @data_filtro) if @operacao + @metricas = montar([@operacao], filtros: @filtros, data: @data_filtro, **faixa_ativa) if @operacao + @fora_operacao = Analytics::NotasForaOperacao.new(inicio: @periodo_inicio, fim: @periodo_fim) if @periodo_explicito end end @@ -46,6 +53,7 @@ class OperacoesDashboardController < ApplicationController authorize :dashboard, :operacoes? @periodo_inicio, @periodo_fim = periodo_selecionado + @periodo_explicito = periodo_explicito? @modo = params[:modo] == 'global' ? 'global' : 'operacao' @operacoes_agrupadas = Operacao.agrupadas_por_mes @tabelas_validas = Operacao.nomes_validos @@ -56,7 +64,7 @@ class OperacoesDashboardController < ApplicationController @metricas = montar(@tabelas_validas, inicio: @periodo_inicio, fim: @periodo_fim, filtros: @filtros, data: @data_filtro) else @operacao = Operacao.sanitizar([params[:operacao]]).first || @tabelas_validas.first - @metricas = montar([@operacao], filtros: @filtros, data: @data_filtro) if @operacao + @metricas = montar([@operacao], filtros: @filtros, data: @data_filtro, **faixa_ativa) if @operacao end montar_tabela_espelho if @metricas @@ -100,6 +108,20 @@ class OperacoesDashboardController < ApplicationController @espelho_linhas = linhas[(@espelho_pagina - 1) * ESPELHO_POR_PAGINA, ESPELHO_POR_PAGINA] || [] end + # Período escolhido pelo operador (veio na URL) x default da tela. Só o + # explícito é aplicado no modo Operação — ver o comentário no #index. + def periodo_explicito? + params[:inicio].present? || params[:fim].present? + end + + # Faixa a repassar para as métricas no modo Operação: vazia quando o operador + # não escolheu período (aí o recorte é a operação inteira). + def faixa_ativa + return {} unless @periodo_explicito + + { inicio: @periodo_inicio, fim: @periodo_fim } + end + def montar(tabelas, inicio: nil, fim: nil, filtros: {}, data: nil) Analytics::OperacaoMetricas.new(tabelas: tabelas, inicio: inicio, fim: fim, filtros: filtros, data: data) end diff --git a/app/models/entrega.rb b/app/models/entrega.rb index 3882316..d7b67d4 100644 --- a/app/models/entrega.rb +++ b/app/models/entrega.rb @@ -92,19 +92,39 @@ class Entrega < ApplicationRecord # Aceita: "95907" (uma) | "95907,12345" (várias) | "all" ou vazio (sem filtro). # Use "95907," (vírgula no fim) para incluir também registros de conta vazia/NULL. scope :da_conta_gade, -> { - contas = ENV.fetch('DB_EXISTING_ACCOUNT_ID', '95907').to_s.strip - if contas.empty? || contas.casecmp?('all') - all - else - valores = contas.split(',', -1).map(&:strip) - # Token vazio (ex.: "95907,") → inclui também as linhas sem conta (NULL). - valores << nil if valores.any?(&:empty?) - where(account_id: valores) - end + valores = contas_gade + valores.nil? ? all : where(account_id: valores) } # ── Métodos de classe ──────────────────────────────────────── + # Contas aceitas (DB_EXISTING_ACCOUNT_ID), normalizadas. `nil` = sem filtro + # ("all"/vazio); um token vazio ("95907,") inclui também as linhas sem conta. + def self.contas_gade + contas = ENV.fetch('DB_EXISTING_ACCOUNT_ID', '95907').to_s.strip + return nil if contas.empty? || contas.casecmp?('all') + + valores = contas.split(',', -1).map(&:strip) + valores << nil if valores.any?(&:empty?) + valores.uniq + end + + # A MESMA condição do scope :da_conta_gade, como fragmento SQL — para as + # queries cruas de Analytics (que montam UNION por operação e não passam pelo + # ActiveRecord) usarem exatamente o mesmo recorte de conta do dashboard. + # Devolve nil quando não há filtro. `apelido` é o alias da tabela na query. + def self.condicao_conta_sql(apelido = table_name) + valores = contas_gade + return nil if valores.nil? + + coluna = "#{connection.quote_table_name(apelido)}.account_id" + listadas = valores.compact + partes = [] + partes << sanitize_sql_array(["#{coluna} IN (?)", listadas]) if listadas.any? + partes << "#{coluna} IS NULL" if valores.include?(nil) + "(#{partes.join(' OR ')})" + end + # Lista motoristas únicos (para selects, consolidações) def self.motoristas_ativos(inicio: nil, fim: nil) base = da_conta_gade diff --git a/app/services/analytics/notas_fora_operacao.rb b/app/services/analytics/notas_fora_operacao.rb new file mode 100644 index 0000000..ebbe1e9 --- /dev/null +++ b/app/services/analytics/notas_fora_operacao.rb @@ -0,0 +1,148 @@ +# app/services/analytics/notas_fora_operacao.rb +# +# NFs que o motorista entregou no período mas que NÃO estão em nenhuma planilha +# de operação (`gade_entregas_*`) — as notas que entram por plano avulso ou de +# inclusão, fora do carregamento original do cliente. +# +# Elas contam no dashboard financeiro (o motorista foi ao local e recebe por +# isso) e SUMIAM do dashboard de operações, porque lá o INNER JOIN com a tabela +# da operação simplesmente as descarta. Era metade da divergência entre as duas +# telas — agora aparece como painel próprio em vez de virar diferença silenciosa. +# +# SEGURANÇA: os nomes das tabelas passam pela whitelist (Operacao.nomes_validos, +# que lê o catálogo) + quote_table_name. Bases SOMENTE LEITURA. +module Analytics + class NotasForaOperacao + # Teto da listagem na tela (os totais continuam contando tudo). + LIMITE = 300 + + # Colunas do espelho que podem carregar o nome do PLANO/rota de origem + # ("(Avulsa)", "INCLUSÃO"...). Nem toda base tem todas — as ausentes viram + # NULL, mesmo padrão de selects_gade em OperacaoMetricas. Whitelist fixa: + # nada aqui vem do usuário. + COLUNAS_PLANO = %w[title notes comments route_id].freeze + + def initialize(inicio:, fim:) + @inicio = inicio&.to_date + @fim = fim&.to_date + end + + # Visitas cruas (pode haver mais de uma por NF). + def visitas + @visitas ||= carregar + end + + # Uma linha por NF: a última visita dela. Mesmo critério de OperacaoMetricas. + def linhas + @linhas ||= visitas.group_by { |r| r['reference_id'].to_s } + .values + .map { |vs| vs.max_by { |r| ordem_visita(r) } } + .sort_by { |r| r['checkout'].to_s } + .reverse + end + + def total + linhas.size + end + + def entregues + linhas.count { |r| r['status'] == 'completed' } + end + + def nao_entregues + linhas.count { |r| Entrega::STATUS_FALHA.include?(r['status']) } + end + + def pendentes + total - entregues - nao_entregues + end + + def any? + total.positive? + end + + # Agrupamento por plano de origem, quando a base tiver alguma das colunas de + # COLUNAS_PLANO preenchida. Serve para separar "(Avulsa)" de "INCLUSÃO". + def por_plano + linhas.group_by { |r| plano(r) } + .map { |nome, rows| { nome: nome, total: rows.size } } + .sort_by { |h| -h[:total] } + end + + # Rótulo do plano de uma linha: primeira coluna de COLUNAS_PLANO preenchida. + def plano(registro) + COLUNAS_PLANO.each do |coluna| + valor = registro[coluna].to_s.strip + return valor if valor.present? + end + 'SEM PLANO IDENTIFICADO' + end + + def listagem + linhas.first(LIMITE) + end + + def truncada? + total > LIMITE + end + + private + + def ordem_visita(registro) + t = registro['checkout'].presence&.to_time + [t ? 1 : 0, t || Time.at(0)] + rescue ArgumentError, TypeError + [0, Time.at(0)] + end + + def carregar + tabelas = Operacao.nomes_validos + return [] if tabelas.empty? + + conn = ActiveRecord::Base.connection + rastreio = conn.quote_table_name(Entrega.table_name) + conta = Entrega.condicao_conta_sql('r') + + # `nota_fiscal IS NOT NULL` é OBRIGATÓRIO: um único NULL na subquery faz o + # NOT IN devolver ZERO linhas (semântica de três valores do SQL) e o painel + # apareceria vazio para sempre. + conhecidas = tabelas.map do |t| + "SELECT nota_fiscal FROM #{conn.quote_table_name(t)} WHERE nota_fiscal IS NOT NULL" + end.join(' UNION ') + + sql = <<~SQL + SELECT r.tracking_id, r.reference_id, r.driver, r.vehicle, r.status, r.observation, + r.contact_name, r.address, r.checkout, r.planned_date, + #{selects_plano(conn)} + FROM #{rastreio} r + WHERE r.reference_id IS NOT NULL + #{conta ? "AND #{conta}" : ''} + #{filtro_periodo(conn)} + AND r.reference_id::text NOT IN (#{conhecidas}) + SQL + + conn.select_all(sql).to_a + end + + # Colunas de plano que existirem de fato; as demais viram NULL com o mesmo + # alias, para a leitura da linha não precisar saber quais existem. + def selects_plano(conn) + existentes = conn.columns(Entrega.table_name).map(&:name) + COLUNAS_PLANO.map do |coluna| + existentes.include?(coluna) ? "r.#{coluna}" : "CAST(NULL AS text) AS #{coluna}" + end.join(', ') + end + + # Mesmo recorte de OperacaoMetricas#filtro_periodo: atendidas pela data real + # (checkout); em aberto (sem checkout) pela data planejada. + def filtro_periodo(conn) + return '' unless @inicio && @fim + + ini = conn.quote(@inicio) + fim_excl = conn.quote(@fim + 1) + fim_dia = conn.quote(@fim.end_of_day) + "AND ((r.checkout >= #{ini} AND r.checkout < #{fim_excl})" \ + " OR (r.checkout IS NULL AND r.planned_date >= #{ini} AND r.planned_date <= #{fim_dia}))" + end + end +end diff --git a/app/services/analytics/operacao_metricas.rb b/app/services/analytics/operacao_metricas.rb index e05bf31..a2304b0 100644 --- a/app/services/analytics/operacao_metricas.rb +++ b/app/services/analytics/operacao_metricas.rb @@ -2,12 +2,20 @@ # # Núcleo de dados do "Dashboard de Operações". # -# Espelha a query de gestão do cliente: pega o ÚLTIMO status de cada NF em -# db_reem_simplerout_2026 (ROW_NUMBER por reference_id, checkout desc) e faz -# INNER JOIN com a(s) tabela(s) de operação (gade_entregas_*) por -# reference_id::text = nota_fiscal. Depois agrega tudo em Ruby para alimentar os -# painéis (KPIs, insucessos %, índices de falha, status, motoristas, STS, por dia -# e o mapa de calor). +# Espelha a query de gestão do cliente: casa db_reem_simplerout_2026 com a(s) +# tabela(s) de operação (gade_entregas_*) por reference_id::text = nota_fiscal e +# agrega em Ruby para alimentar os painéis (KPIs, insucessos %, índices de falha, +# status, motoristas, STS, por dia e o mapa de calor). +# +# DUAS CAMADAS, de propósito: +# #visitas — uma linha por ida do motorista ao local. É o que o dashboard +# financeiro conta (Entrega.contar_atendidas) e o que se paga ao +# motorista. +# #linhas — uma linha por NOTA FISCAL (a última visita de cada uma). É o que +# o cliente paga, o que confere com os documentos físicos e com a +# aba ENTREGAS da planilha. TODOS os KPIs desta tela saem daqui. +# Os dois números só coincidem quando nenhuma NF precisou de segunda ida; a +# diferença aparece na tela como "retentativas" em vez de ficar escondida. # # SEGURANÇA: os nomes das tabelas de operação só entram no SQL depois de passar # pela whitelist (Operacao.sanitizar) + connection.quote_table_name — mesmo padrão @@ -36,15 +44,30 @@ module Analytics @tabelas.map { |t| Operacao.label(t) }.join(', ') end - # Linhas após o cross-filter — base de TODAS as agregações/KPIs. - def linhas - @linhas ||= begin + # VISITAS após o cross-filter: uma linha por passagem do motorista. Uma mesma + # NF pode ter várias (falhou dia 10, entregou dia 12). É a base da análise + # operacional — quantas idas ao local foram necessárias. + def visitas + @visitas ||= begin base = @filtros.empty? ? registros : registros.select { |r| @filtros.all? { |col, val| r[col].to_s == val.to_s } } @data ? base.select { |r| data_de(r['checkout']) == @data } : base end end + # NOTAS FISCAIS: uma linha por NF, com a ÚLTIMA visita dela. É a base de + # TODOS os KPIs desta tela, porque é o que confere com os documentos físicos + # e com a aba ENTREGAS da planilha entregue ao cliente (Analytics:: + # PlanilhaEntregas também usa "último status por reference_id"). + # + # ⚠️ O dedup precisa acontecer AQUI — depois do período e do cross-filter — e + # não no SQL. Enquanto ele rodava como ROW_NUMBER + `rn = 1` sobre a tabela + # INTEIRA, uma NF reentregue DEPOIS do fim do período perdia a visita que + # estava DENTRO dele e sumia da contagem do mês. + def linhas + @linhas ||= por_nota.values.map { |vs| vs.max_by { |r| ordem_visita(r) } } + end + # Campos pesquisáveis da tabela espelho (planilha da operação). BUSCA_CAMPOS = %w[reference_id driver vehicle status observation contact_name address nome_completo endereco_completo status_gade operacao].freeze @@ -103,6 +126,34 @@ module Analytics total - sucesso - recusas end + # ── Camada operacional (por VISITA, não por NF) ─────────────── + # Os KPIs acima contam NOTAS porque é o que o cliente paga e confere. Estes + # contam IDAS AO LOCAL — é o que o motorista recebe e o que o dashboard + # financeiro usa (Entrega.contar_atendidas conta linhas). Sem eles, uma NF + # que falhou duas vezes antes de ser entregue aparecia como 100% de sucesso + # e o insucesso sumia da tela. + def total_visitas + visitas.size + end + + # Idas ao local além da primeira de cada NF. + def retentativas + total_visitas - total + end + + def visitas_insucesso + visitas.count { |r| Entrega::STATUS_FALHA.include?(r['status']) } + end + + # NFs que hoje constam como ENTREGUES mas custaram mais de uma ida — a + # informação que o dedup escondia por completo. + def notas_reentregues + @notas_reentregues ||= por_nota.count do |_nf, vs| + vs.any? { |r| Entrega::STATUS_FALHA.include?(r['status']) } && + vs.max_by { |r| ordem_visita(r) }['status'] == 'completed' + end + end + # Donut "Insucessos %": completas vs falhas (sobre o total). def insucessos_pct { @@ -206,6 +257,27 @@ module Analytics private + # NF => visitas dela (já filtradas). Chave string: reference_id é numérico no + # espelho e texto na tabela da operação. + def por_nota + @por_nota ||= visitas.group_by { |r| r['reference_id'].to_s } + end + + # Ordem de "última visita": quem tem checkout ganha de quem não tem e, entre + # as com checkout, vence a mais recente. Mesma regra do ROW_NUMBER que a + # planilha do cliente usa (checkout DESC NULLS LAST). + def ordem_visita(registro) + t = tempo_de(registro['checkout']) + [t ? 1 : 0, t || Time.at(0)] + end + + def tempo_de(valor) + return nil if valor.nil? || valor.to_s.strip.empty? + valor.to_time + rescue ArgumentError, TypeError, NoMethodError + nil + end + def datas_checkout @datas_checkout ||= linhas.filter_map { |r| data_de(r['checkout']) } end @@ -247,35 +319,33 @@ module Analytics conn = ActiveRecord::Base.connection + rastreio = conn.quote_table_name(Entrega.table_name) + conta = Entrega.condicao_conta_sql('r') + unions = @tabelas.map do |tabela| gade = conn.quote_table_name(tabela) label = conn.quote(Operacao.label(tabela)) <<~SQL.strip SELECT #{label} AS operacao, + r.tracking_id, r.reference_id, r.driver, r.vehicle, r.status, r.observation, r.contact_name, r.address, r.checkout, r.planned_date, r.foto_da_fachada, r.latitude, r.longitude, r.checkout_latitude, r.checkout_longitude, #{selects_gade(conn, tabela)} - FROM ultimo r + FROM #{rastreio} r INNER JOIN #{gade} g ON r.reference_id::text = g.nota_fiscal - WHERE r.rn = 1#{filtro_periodo(conn)} + WHERE r.reference_id IS NOT NULL#{conta ? " AND #{conta}" : ''}#{filtro_periodo(conn)} SQL end - # Sem filtro de conta: o INNER JOIN com a tabela da operação (gade_entregas_*) - # já restringe aos dados do cliente. Mantém a query idêntica à de gestão. - sql = <<~SQL - WITH ultimo AS ( - SELECT *, - ROW_NUMBER() OVER (PARTITION BY reference_id ORDER BY checkout DESC NULLS LAST) AS rn - FROM #{conn.quote_table_name(Entrega.table_name)} - WHERE reference_id IS NOT NULL - ) - #{unions.join("\nUNION ALL\n")} - SQL - - conn.select_all(sql).to_a + # Traz TODAS as visitas; a redução para uma linha por NF é feita em #linhas, + # já depois do período e do cross-filter (ver o comentário lá). + # + # `uniq` por tracking_id: se a mesma nota_fiscal estiver repetida dentro de + # uma tabela de operação (ou em duas tabelas do UNION), o INNER JOIN + # devolveria a MESMA visita mais de uma vez e inflaria a contagem. + conn.select_all(unions.join("\nUNION ALL\n")).to_a.uniq { |r| r['tracking_id'] } end # Colunas extras da tabela gade que só existem em ALGUMAS operações (ex.: diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb index 5256fb3..83690cf 100644 --- a/app/views/dashboard/index.html.erb +++ b/app/views/dashboard/index.html.erb @@ -168,6 +168,11 @@ · <%= @entregas_falhadas %> falhadas + <%# Conta VISITAS (é por ida ao local que o motorista recebe). O dashboard + de Operações conta NOTAS — a diferença são retentativas da mesma NF. %> +

+ visitas atendidas · <%= @notas_atendidas %> notas fiscais +

<%# Card 3: Consolidações %> diff --git a/app/views/operacoes_dashboard/_fora_operacao.html.erb b/app/views/operacoes_dashboard/_fora_operacao.html.erb new file mode 100644 index 0000000..b9ca8c5 --- /dev/null +++ b/app/views/operacoes_dashboard/_fora_operacao.html.erb @@ -0,0 +1,81 @@ +<%# locals: fora (Analytics::NotasForaOperacao), inicio, fim (Date) + NFs entregues no período que não constam em nenhuma planilha de operação — + os planos avulsos / de inclusão. Contam no dashboard financeiro e antes + sumiam daqui (o INNER JOIN com a tabela da operação as descartava). %> +
+
+
+

+ <%= icone :parcial, cor: 'text-amber-400', espaco: false %> + Notas fora da operação +

+

+ Entregues em <%= inicio.strftime('%d/%m/%Y') %> – <%= fim.strftime('%d/%m/%Y') %> + sem constar em nenhuma planilha gade_entregas_* — planos avulsos e de inclusão. +

+
+
+ NFs <%= fora.total %> + entregues <%= fora.entregues %> + recusas <%= fora.nao_entregues %> + <% if fora.pendentes.positive? %> + pendentes <%= fora.pendentes %> + <% end %> +
+
+ + <% if fora.total.zero? %> +

+ Nenhuma nota fora das planilhas de operação neste período. +

+ <% else %> + <%# Quebra por plano de origem — é aqui que "(Avulsa)" e "INCLUSÃO" se separam. %> +
+ <% fora.por_plano.first(8).each do |grupo| %> + + <%= grupo[:nome] %> <%= grupo[:total] %> + + <% end %> +
+ +
+ + + + + + + + + + + + + + <% fora.listagem.each do |linha| %> + <% sucesso = linha['status'] == 'completed' %> + + + + + + + + + + <% end %> + +
NFPlano / títuloMotoristaUnidadeDataResultadoMotivo
<%= linha['reference_id'] %><%= fora.plano(linha) %><%= linha['driver'].presence || '—' %><%= linha['contact_name'].presence || '—' %> + <%= (linha['checkout'].presence || linha['planned_date'].presence)&.to_date&.strftime('%d/%m/%Y') || '—' %> + + <%= sucesso ? 'Entregue' : (linha['status'].presence || '—') %> + <%= linha['observation'].presence || '—' %>
+
+ + <% if fora.truncada? %> +

+ Mostrando as <%= Analytics::NotasForaOperacao::LIMITE %> mais recentes de <%= fora.total %>. +

+ <% end %> + <% end %> +
diff --git a/app/views/operacoes_dashboard/_painel.html.erb b/app/views/operacoes_dashboard/_painel.html.erb index 417794f..f32f58d 100644 --- a/app/views/operacoes_dashboard/_painel.html.erb +++ b/app/views/operacoes_dashboard/_painel.html.erb @@ -24,6 +24,7 @@

Total de Entregas

<%= metricas.total %>

+

notas fiscais da operação

@@ -49,6 +50,37 @@ <%= pct_total.(metricas.pendentes) %>% do total

+ + <%# Camada operacional: os cards acima contam NOTAS (o que o cliente paga e + o que confere nos documentos). Aqui ficam as IDAS ao local — que é o + que o motorista recebe e o que o dashboard financeiro conta. Sem esta + faixa, uma NF que só foi entregue na segunda tentativa aparecia como + sucesso puro e o insucesso sumia da tela. %> +
+

Visitas ao local

+
+
+
Visitas realizadas
+
<%= metricas.total_visitas %>
+
+
+
Retentativas
+
<%= metricas.retentativas %>
+
+
+
Insucessos (por visita)
+
<%= metricas.visitas_insucesso %>
+
+
+
Entregues na 2ª ida ou mais
+
<%= metricas.notas_reentregues %>
+
+
+

+ O dashboard financeiro conta VISITAS (o motorista recebe por ida); esta tela conta NOTAS. + A diferença são as <%= metricas.retentativas %> retentativas acima. +

+
<%# Coluna direita — gráficos/tabelas ocupam o espaço ao lado dos cards. %> diff --git a/app/views/operacoes_dashboard/index.html.erb b/app/views/operacoes_dashboard/index.html.erb index 8c70d20..d3e8abc 100644 --- a/app/views/operacoes_dashboard/index.html.erb +++ b/app/views/operacoes_dashboard/index.html.erb @@ -57,6 +57,7 @@ <%= icone :comparar %> Comparação entre operações (cada uma no seu próprio período) <% elsif @metricas&.data_inicio %> <%= @metricas.operacoes_label %> · <%= @metricas.data_inicio.strftime('%d/%m/%Y') %> – <%= @metricas.data_fim.strftime('%d/%m/%Y') %> + <%= @periodo_explicito ? '(período filtrado)' : '(operação inteira)' %> <% elsif @operacao %> <%= Operacao.label(@operacao) %> <% end %> @@ -64,9 +65,11 @@

- <%# Filtro de período — só na visão Global (a faixa de data não se aplica a - operação única, que usa o período natural dos próprios dados). %> - <% if @modo == 'global' %> + <%# Filtro de período — Global e Operação. No modo Operação ele só entra em + vigor quando o operador ESCOLHE uma faixa (params inicio/fim): sem isso o + recorte é a operação inteira, senão abrir uma operação de meses atrás + cairia no mês corrente e mostraria zero. %> + <% if %w[global operacao].include?(@modo) %>
<%= icone :calendario, espaco: false %> @@ -77,12 +80,22 @@
<% atalhos.each do |label, (ini, fim)| %> - <% ativo = @periodo_inicio == ini && @periodo_fim == fim %> + <%# No Global a faixa SEMPRE vale (é o que define a visão); no modo + Operação só quando escolhida — senão o atalho apareceria aceso + sem estar sendo aplicado. %> + <% ativo = (@periodo_explicito || @modo == 'global') && @periodo_inicio == ini && @periodo_fim == fim %> <%= link_to label, - operacoes_dashboard_path(modo: 'global', inicio: ini.strftime('%Y-%m-%d'), fim: fim.strftime('%Y-%m-%d')), + operacoes_dashboard_path(ctx_ops.merge(modo: @modo, inicio: ini.strftime('%Y-%m-%d'), fim: fim.strftime('%Y-%m-%d'))), data: { turbo: false }, class: "px-3 py-2.5 rounded-xl text-sm whitespace-nowrap border #{ativo ? 'bg-orange-500 text-black border-orange-500 font-bold' : 'bg-[#1a1a1a] text-gray-300 border-white/10 hover:border-orange-500'}" %> <% end %> + <%# Volta ao recorte natural da operação (sem faixa de datas na URL). %> + <% if @modo == 'operacao' %> + <%= link_to 'Operação inteira', + operacoes_dashboard_path(ctx_ops.merge(modo: 'operacao')), + data: { turbo: false }, + class: "px-3 py-2.5 rounded-xl text-sm whitespace-nowrap border #{@periodo_explicito ? 'bg-[#1a1a1a] text-gray-300 border-white/10 hover:border-orange-500' : 'bg-orange-500 text-black border-orange-500 font-bold'}" %> + <% end %>
<% end %> @@ -90,12 +103,16 @@ <%# ── Abas de modo + atalho para a planilha ─────────────── %> <% modos = { 'operacao' => [:operacao, 'Operação'], 'global' => [:global, 'Global'], 'comparar' => [:comparar, 'Comparar'] } %> + <%# A faixa de datas só viaja entre as abas se o operador tiver ESCOLHIDO uma. + Carregar o default (mês corrente) ao entrar em "Operação" faria uma operação + de meses atrás abrir zerada. %> + <% ctx_periodo = @periodo_explicito ? { inicio: ini_iso, fim: fim_iso } : {} %>
<% modos.each do |m, (ic, label)| %> <% ativo = @modo == m %> <%= link_to rotulo(ic, label, cor: (ativo ? nil : 'text-brand-laranja')), - operacoes_dashboard_path(ctx_ops.merge(modo: m, inicio: ini_iso, fim: fim_iso)), + operacoes_dashboard_path(ctx_ops.merge(ctx_periodo).merge(modo: m)), data: { turbo: false }, class: "px-4 py-2 rounded-xl text-sm font-semibold whitespace-nowrap #{ativo ? 'bg-orange-500 text-black' : 'bg-[#1a1a1a] text-gray-300 border border-white/10 hover:border-orange-500'}" %> <% end %> @@ -157,6 +174,9 @@ <%= render 'painel', metricas: @metricas, filtravel: true %> <%= render 'mapa', metricas: @metricas %> <% end %> + <% if @fora_operacao %> + <%= render 'fora_operacao', fora: @fora_operacao, inicio: @periodo_inicio, fim: @periodo_fim %> + <% end %> <% elsif @modo == 'global' %>
@@ -167,6 +187,9 @@ <%= render 'painel', metricas: @metricas, filtravel: true %> <%= render 'mapa', metricas: @metricas %> <% end %> + <% if @fora_operacao %> + <%= render 'fora_operacao', fora: @fora_operacao, inicio: @periodo_inicio, fim: @periodo_fim %> + <% end %> <% else # comparar %>
diff --git a/spec/services/analytics/notas_fora_operacao_spec.rb b/spec/services/analytics/notas_fora_operacao_spec.rb new file mode 100644 index 0000000..6a422fb --- /dev/null +++ b/spec/services/analytics/notas_fora_operacao_spec.rb @@ -0,0 +1,67 @@ +require 'rails_helper' + +RSpec.describe Analytics::NotasForaOperacao do + subject(:fora) { described_class.new(inicio: Date.new(2026, 8, 1), fim: Date.new(2026, 8, 31)) } + + # Visitas já filtradas pelo SQL (NFs que não estão em nenhuma gade_entregas_*). + let(:visitas) do + [ + row(nf: 100, status: 'failed', driver: 'Carlos', checkout: '2026-08-10 09:00:00', title: 'NF 100 - FULANO (Avulsa)'), + row(nf: 100, status: 'completed', driver: 'Carlos', checkout: '2026-08-12 09:00:00', title: 'NF 100 - FULANO (Avulsa)'), + row(nf: 101, status: 'completed', driver: 'Pedro', checkout: '2026-08-11 09:00:00', title: 'INCLUSÃO'), + row(nf: 102, status: 'failed', driver: 'Pedro', checkout: '2026-08-13 09:00:00', title: 'INCLUSÃO', obs: 'ÓBITO'), + row(nf: 103, status: 'pending', driver: 'Marcos', planned: '2026-08-20', title: nil) + ] + end + + before { allow(fora).to receive(:visitas).and_return(visitas) } + + it 'conta uma linha por NF, pelo status da última visita' do + expect(fora.total).to eq(4) + expect(fora.entregues).to eq(2) + expect(fora.nao_entregues).to eq(1) + expect(fora.pendentes).to eq(1) + end + + it 'agrupa por plano de origem (Avulsa x INCLUSÃO)' do + expect(fora.por_plano).to contain_exactly( + { nome: 'INCLUSÃO', total: 2 }, + { nome: 'NF 100 - FULANO (Avulsa)', total: 1 }, + { nome: 'SEM PLANO IDENTIFICADO', total: 1 } + ) + end + + it 'ordena a listagem da visita mais recente para a mais antiga' do + expect(fora.listagem.map { |r| r['reference_id'] }).to eq([102, 100, 101, 103]) + end + + # NOT IN com NULL na subquery devolve zero linhas — por isso cada SELECT da + # união precisa filtrar nota_fiscal IS NOT NULL. + it 'protege o NOT IN contra nota_fiscal NULL' do + allow(Operacao).to receive(:nomes_validos).and_return(%w[gade_entregas_teste]) + sql = nil + allow(ActiveRecord::Base.connection).to receive(:select_all) { |consulta| sql = consulta; [] } + allow(ActiveRecord::Base.connection).to receive(:columns).and_return([]) + + described_class.new(inicio: Date.new(2026, 8, 1), fim: Date.new(2026, 8, 31)).send(:carregar) + + expect(sql).to include('nota_fiscal IS NOT NULL') + expect(sql).to include('NOT IN') + end + + context 'sem nenhuma nota fora da operação' do + let(:visitas) { [] } + + it 'não quebra e informa vazio' do + expect(fora.total).to eq(0) + expect(fora.any?).to be(false) + expect(fora.por_plano).to eq([]) + end + end + + def row(nf:, status:, driver:, checkout: nil, planned: nil, title: nil, obs: nil) + { 'reference_id' => nf, 'status' => status, 'driver' => driver, + 'checkout' => checkout, 'planned_date' => planned, 'observation' => obs, + 'contact_name' => nil, 'title' => title, 'notes' => nil, 'comments' => nil, 'route_id' => nil } + end +end diff --git a/spec/services/analytics/operacao_metricas_spec.rb b/spec/services/analytics/operacao_metricas_spec.rb index ae1c4a7..3deb4cb 100644 --- a/spec/services/analytics/operacao_metricas_spec.rb +++ b/spec/services/analytics/operacao_metricas_spec.rb @@ -3,14 +3,16 @@ require 'rails_helper' RSpec.describe Analytics::OperacaoMetricas do # Linhas simuladas (mesma forma do retorno de select_all) — evita depender das # tabelas externas no teste. Stubamos #registros para focar nas agregações. + # Uma NF por linha: nesse conjunto nenhuma visita se repete, então as duas + # camadas (visitas x notas) coincidem. let(:rows) do [ - row(status: 'completed', driver: 'Carlos', sts: 'STS PERUS', gade: 'RECORRENTE', checkout: '2026-06-22 10:00:00', clat: '-23.5', clng: '-46.6'), - row(status: 'completed', driver: 'Carlos', sts: 'STS PERUS', gade: 'RECORRENTE', checkout: '2026-06-22 11:00:00', lat: '-23.6', lng: '-46.7'), - row(status: 'completed', driver: 'Pedro', sts: 'STS PIRITUBA', gade: 'RECORRENTE', checkout: '2026-06-23 09:00:00', clat: '-23.4', clng: '-46.5'), - row(status: 'failed', driver: 'Pedro', sts: 'STS PIRITUBA', gade: 'NOVO', obs: 'ÓBITO', checkout: '2026-06-23 12:00:00'), - row(status: 'failed', driver: 'Carlos', sts: 'STS PERUS', gade: 'NOVO', obs: 'ÓBITO', checkout: '2026-06-22 13:00:00'), - row(status: 'pending', driver: 'Marcos', sts: 'STS PERUS', gade: 'NOVO', planned: '2026-06-24') + row(nf: 1, status: 'completed', driver: 'Carlos', sts: 'STS PERUS', gade: 'RECORRENTE', checkout: '2026-06-22 10:00:00', clat: '-23.5', clng: '-46.6'), + row(nf: 2, status: 'completed', driver: 'Carlos', sts: 'STS PERUS', gade: 'RECORRENTE', checkout: '2026-06-22 11:00:00', lat: '-23.6', lng: '-46.7'), + row(nf: 3, status: 'completed', driver: 'Pedro', sts: 'STS PIRITUBA', gade: 'RECORRENTE', checkout: '2026-06-23 09:00:00', clat: '-23.4', clng: '-46.5'), + row(nf: 4, status: 'failed', driver: 'Pedro', sts: 'STS PIRITUBA', gade: 'NOVO', obs: 'ÓBITO', checkout: '2026-06-23 12:00:00'), + row(nf: 5, status: 'failed', driver: 'Carlos', sts: 'STS PERUS', gade: 'NOVO', obs: 'ÓBITO', checkout: '2026-06-22 13:00:00'), + row(nf: 6, status: 'pending', driver: 'Marcos', sts: 'STS PERUS', gade: 'NOVO', planned: '2026-06-24') ] end @@ -31,6 +33,51 @@ RSpec.describe Analytics::OperacaoMetricas do expect(metricas.pendentes).to eq(1) end + it 'sem NF repetida, visitas e notas coincidem' do + expect(metricas.total_visitas).to eq(6) + expect(metricas.retentativas).to eq(0) + expect(metricas.notas_reentregues).to eq(0) + end + + # A regressão que motivou as duas camadas: a mesma NF visitada duas vezes + # (falhou, depois entregou) contava 2 no dashboard financeiro e 1 aqui, sem + # nada na tela explicando a diferença — e o insucesso desaparecia por completo. + context 'com NF visitada mais de uma vez' do + let(:rows) do + [ + row(nf: 10, status: 'failed', driver: 'Carlos', sts: 'STS PERUS', gade: 'NOVO', obs: 'AUSENTE', checkout: '2026-06-10 09:00:00'), + row(nf: 10, status: 'completed', driver: 'Pedro', sts: 'STS PERUS', gade: 'NOVO', checkout: '2026-06-12 09:00:00'), + row(nf: 11, status: 'completed', driver: 'Pedro', sts: 'STS PERUS', gade: 'NOVO', checkout: '2026-06-12 10:00:00') + ] + end + + it 'conta a NF uma vez, pelo status da ÚLTIMA visita' do + expect(metricas.total).to eq(2) + expect(metricas.sucesso).to eq(2) + expect(metricas.recusas).to eq(0) + end + + it 'expõe as idas ao local em vez de descartá-las em silêncio' do + expect(metricas.total_visitas).to eq(3) + expect(metricas.retentativas).to eq(1) + expect(metricas.visitas_insucesso).to eq(1) + expect(metricas.notas_reentregues).to eq(1) + end + + it 'visita sem checkout perde para a que tem' do + sem_checkout = [ + row(nf: 20, status: 'pending', driver: 'Marcos', sts: 'STS PERUS', gade: 'NOVO', planned: '2026-06-24'), + row(nf: 20, status: 'completed', driver: 'Marcos', sts: 'STS PERUS', gade: 'NOVO', checkout: '2026-06-20 09:00:00') + ] + m = described_class.new(tabelas: []) + allow(m).to receive(:registros).and_return(sem_checkout) + + expect(m.total).to eq(1) + expect(m.sucesso).to eq(1) + expect(m.pendentes).to eq(0) + end + end + it 'calcula insucessos %' do ins = metricas.insucessos_pct expect(ins[:completed]).to eq(3) @@ -104,9 +151,9 @@ RSpec.describe Analytics::OperacaoMetricas do end end - def row(status:, driver:, sts:, gade:, obs: nil, checkout: nil, planned: nil, lat: nil, lng: nil, clat: nil, clng: nil) + def row(status:, driver:, sts:, gade:, nf: nil, obs: nil, checkout: nil, planned: nil, lat: nil, lng: nil, clat: nil, clng: nil) { - 'status' => status, 'driver' => driver, 'contact_name' => sts, 'status_gade' => gade, + 'reference_id' => nf, 'status' => status, 'driver' => driver, 'contact_name' => sts, 'status_gade' => gade, 'observation' => obs, 'checkout' => checkout, 'planned_date' => planned, 'latitude' => lat, 'longitude' => lng, 'checkout_latitude' => clat, 'checkout_longitude' => clng