Correção de alguns valores no dash principal
This commit is contained in:
118
README.md
118
README.md
@@ -2113,3 +2113,121 @@ docker compose exec app bundle exec rspec \
|
|||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>🔢 Dashboard × Operações: por que os números não batiam — notas x visitas + painel de avulsas (24/08/2026)</strong></summary>
|
||||||
|
|
||||||
|
> ⚠️ **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
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|||||||
@@ -102,6 +102,13 @@ class DashboardController < ApplicationController
|
|||||||
@entregas_pendentes = pendentes.count
|
@entregas_pendentes = pendentes.count
|
||||||
@total_entregas = @entregas_atendidas + @entregas_pendentes
|
@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
|
# Configurações de preço
|
||||||
config = Configuracao.mapa_de_precos
|
config = Configuracao.mapa_de_precos
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class OperacoesDashboardController < ApplicationController
|
|||||||
authorize :dashboard, :operacoes?
|
authorize :dashboard, :operacoes?
|
||||||
|
|
||||||
@periodo_inicio, @periodo_fim = periodo_selecionado
|
@periodo_inicio, @periodo_fim = periodo_selecionado
|
||||||
|
@periodo_explicito = periodo_explicito?
|
||||||
@modo = MODOS.include?(params[:modo]) ? params[:modo] : 'operacao'
|
@modo = MODOS.include?(params[:modo]) ? params[:modo] : 'operacao'
|
||||||
@operacoes_agrupadas = Operacao.agrupadas_por_mes
|
@operacoes_agrupadas = Operacao.agrupadas_por_mes
|
||||||
@tabelas_validas = Operacao.nomes_validos
|
@tabelas_validas = Operacao.nomes_validos
|
||||||
@@ -26,6 +27,7 @@ class OperacoesDashboardController < ApplicationController
|
|||||||
when 'global'
|
when 'global'
|
||||||
# Global: agrega TODAS as operações dentro da faixa de datas escolhida.
|
# 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)
|
@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'
|
when 'comparar'
|
||||||
# Comparar: cada operação inteira (sem filtro de data — só vale no Global).
|
# Comparar: cada operação inteira (sem filtro de data — só vale no Global).
|
||||||
@op_a = Operacao.sanitizar([params[:op_a]]).first || @tabelas_validas[0]
|
@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_a = montar([@op_a]) if @op_a
|
||||||
@metricas_b = montar([@op_b]) if @op_b
|
@metricas_b = montar([@op_b]) if @op_b
|
||||||
else
|
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
|
@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
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -46,6 +53,7 @@ class OperacoesDashboardController < ApplicationController
|
|||||||
authorize :dashboard, :operacoes?
|
authorize :dashboard, :operacoes?
|
||||||
|
|
||||||
@periodo_inicio, @periodo_fim = periodo_selecionado
|
@periodo_inicio, @periodo_fim = periodo_selecionado
|
||||||
|
@periodo_explicito = periodo_explicito?
|
||||||
@modo = params[:modo] == 'global' ? 'global' : 'operacao'
|
@modo = params[:modo] == 'global' ? 'global' : 'operacao'
|
||||||
@operacoes_agrupadas = Operacao.agrupadas_por_mes
|
@operacoes_agrupadas = Operacao.agrupadas_por_mes
|
||||||
@tabelas_validas = Operacao.nomes_validos
|
@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)
|
@metricas = montar(@tabelas_validas, inicio: @periodo_inicio, fim: @periodo_fim, filtros: @filtros, data: @data_filtro)
|
||||||
else
|
else
|
||||||
@operacao = Operacao.sanitizar([params[:operacao]]).first || @tabelas_validas.first
|
@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
|
end
|
||||||
|
|
||||||
montar_tabela_espelho if @metricas
|
montar_tabela_espelho if @metricas
|
||||||
@@ -100,6 +108,20 @@ class OperacoesDashboardController < ApplicationController
|
|||||||
@espelho_linhas = linhas[(@espelho_pagina - 1) * ESPELHO_POR_PAGINA, ESPELHO_POR_PAGINA] || []
|
@espelho_linhas = linhas[(@espelho_pagina - 1) * ESPELHO_POR_PAGINA, ESPELHO_POR_PAGINA] || []
|
||||||
end
|
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)
|
def montar(tabelas, inicio: nil, fim: nil, filtros: {}, data: nil)
|
||||||
Analytics::OperacaoMetricas.new(tabelas: tabelas, inicio: inicio, fim: fim, filtros: filtros, data: data)
|
Analytics::OperacaoMetricas.new(tabelas: tabelas, inicio: inicio, fim: fim, filtros: filtros, data: data)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -92,19 +92,39 @@ class Entrega < ApplicationRecord
|
|||||||
# Aceita: "95907" (uma) | "95907,12345" (várias) | "all" ou vazio (sem filtro).
|
# 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.
|
# Use "95907," (vírgula no fim) para incluir também registros de conta vazia/NULL.
|
||||||
scope :da_conta_gade, -> {
|
scope :da_conta_gade, -> {
|
||||||
contas = ENV.fetch('DB_EXISTING_ACCOUNT_ID', '95907').to_s.strip
|
valores = contas_gade
|
||||||
if contas.empty? || contas.casecmp?('all')
|
valores.nil? ? all : where(account_id: valores)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Métodos de classe ────────────────────────────────────────
|
# ── 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)
|
# Lista motoristas únicos (para selects, consolidações)
|
||||||
def self.motoristas_ativos(inicio: nil, fim: nil)
|
def self.motoristas_ativos(inicio: nil, fim: nil)
|
||||||
base = da_conta_gade
|
base = da_conta_gade
|
||||||
|
|||||||
148
app/services/analytics/notas_fora_operacao.rb
Normal file
148
app/services/analytics/notas_fora_operacao.rb
Normal file
@@ -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
|
||||||
@@ -2,12 +2,20 @@
|
|||||||
#
|
#
|
||||||
# Núcleo de dados do "Dashboard de Operações".
|
# 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
|
# Espelha a query de gestão do cliente: casa db_reem_simplerout_2026 com a(s)
|
||||||
# db_reem_simplerout_2026 (ROW_NUMBER por reference_id, checkout desc) e faz
|
# tabela(s) de operação (gade_entregas_*) por reference_id::text = nota_fiscal e
|
||||||
# INNER JOIN com a(s) tabela(s) de operação (gade_entregas_*) por
|
# agrega em Ruby para alimentar os painéis (KPIs, insucessos %, índices de falha,
|
||||||
# reference_id::text = nota_fiscal. Depois agrega tudo em Ruby para alimentar os
|
# status, motoristas, STS, por dia e o mapa de calor).
|
||||||
# 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
|
# 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
|
# pela whitelist (Operacao.sanitizar) + connection.quote_table_name — mesmo padrão
|
||||||
@@ -36,15 +44,30 @@ module Analytics
|
|||||||
@tabelas.map { |t| Operacao.label(t) }.join(', ')
|
@tabelas.map { |t| Operacao.label(t) }.join(', ')
|
||||||
end
|
end
|
||||||
|
|
||||||
# Linhas após o cross-filter — base de TODAS as agregações/KPIs.
|
# VISITAS após o cross-filter: uma linha por passagem do motorista. Uma mesma
|
||||||
def linhas
|
# NF pode ter várias (falhou dia 10, entregou dia 12). É a base da análise
|
||||||
@linhas ||= begin
|
# operacional — quantas idas ao local foram necessárias.
|
||||||
|
def visitas
|
||||||
|
@visitas ||= begin
|
||||||
base = @filtros.empty? ? registros
|
base = @filtros.empty? ? registros
|
||||||
: registros.select { |r| @filtros.all? { |col, val| r[col].to_s == val.to_s } }
|
: registros.select { |r| @filtros.all? { |col, val| r[col].to_s == val.to_s } }
|
||||||
@data ? base.select { |r| data_de(r['checkout']) == @data } : base
|
@data ? base.select { |r| data_de(r['checkout']) == @data } : base
|
||||||
end
|
end
|
||||||
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).
|
# Campos pesquisáveis da tabela espelho (planilha da operação).
|
||||||
BUSCA_CAMPOS = %w[reference_id driver vehicle status observation contact_name
|
BUSCA_CAMPOS = %w[reference_id driver vehicle status observation contact_name
|
||||||
address nome_completo endereco_completo status_gade operacao].freeze
|
address nome_completo endereco_completo status_gade operacao].freeze
|
||||||
@@ -103,6 +126,34 @@ module Analytics
|
|||||||
total - sucesso - recusas
|
total - sucesso - recusas
|
||||||
end
|
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).
|
# Donut "Insucessos %": completas vs falhas (sobre o total).
|
||||||
def insucessos_pct
|
def insucessos_pct
|
||||||
{
|
{
|
||||||
@@ -206,6 +257,27 @@ module Analytics
|
|||||||
|
|
||||||
private
|
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
|
def datas_checkout
|
||||||
@datas_checkout ||= linhas.filter_map { |r| data_de(r['checkout']) }
|
@datas_checkout ||= linhas.filter_map { |r| data_de(r['checkout']) }
|
||||||
end
|
end
|
||||||
@@ -247,35 +319,33 @@ module Analytics
|
|||||||
|
|
||||||
conn = ActiveRecord::Base.connection
|
conn = ActiveRecord::Base.connection
|
||||||
|
|
||||||
|
rastreio = conn.quote_table_name(Entrega.table_name)
|
||||||
|
conta = Entrega.condicao_conta_sql('r')
|
||||||
|
|
||||||
unions = @tabelas.map do |tabela|
|
unions = @tabelas.map do |tabela|
|
||||||
gade = conn.quote_table_name(tabela)
|
gade = conn.quote_table_name(tabela)
|
||||||
label = conn.quote(Operacao.label(tabela))
|
label = conn.quote(Operacao.label(tabela))
|
||||||
<<~SQL.strip
|
<<~SQL.strip
|
||||||
SELECT #{label} AS operacao,
|
SELECT #{label} AS operacao,
|
||||||
|
r.tracking_id,
|
||||||
r.reference_id, r.driver, r.vehicle, r.status, r.observation, r.contact_name, r.address,
|
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.checkout, r.planned_date, r.foto_da_fachada,
|
||||||
r.latitude, r.longitude,
|
r.latitude, r.longitude,
|
||||||
r.checkout_latitude, r.checkout_longitude,
|
r.checkout_latitude, r.checkout_longitude,
|
||||||
#{selects_gade(conn, tabela)}
|
#{selects_gade(conn, tabela)}
|
||||||
FROM ultimo r
|
FROM #{rastreio} r
|
||||||
INNER JOIN #{gade} g ON r.reference_id::text = g.nota_fiscal
|
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
|
SQL
|
||||||
end
|
end
|
||||||
|
|
||||||
# Sem filtro de conta: o INNER JOIN com a tabela da operação (gade_entregas_*)
|
# Traz TODAS as visitas; a redução para uma linha por NF é feita em #linhas,
|
||||||
# já restringe aos dados do cliente. Mantém a query idêntica à de gestão.
|
# já depois do período e do cross-filter (ver o comentário lá).
|
||||||
sql = <<~SQL
|
#
|
||||||
WITH ultimo AS (
|
# `uniq` por tracking_id: se a mesma nota_fiscal estiver repetida dentro de
|
||||||
SELECT *,
|
# uma tabela de operação (ou em duas tabelas do UNION), o INNER JOIN
|
||||||
ROW_NUMBER() OVER (PARTITION BY reference_id ORDER BY checkout DESC NULLS LAST) AS rn
|
# devolveria a MESMA visita mais de uma vez e inflaria a contagem.
|
||||||
FROM #{conn.quote_table_name(Entrega.table_name)}
|
conn.select_all(unions.join("\nUNION ALL\n")).to_a.uniq { |r| r['tracking_id'] }
|
||||||
WHERE reference_id IS NOT NULL
|
|
||||||
)
|
|
||||||
#{unions.join("\nUNION ALL\n")}
|
|
||||||
SQL
|
|
||||||
|
|
||||||
conn.select_all(sql).to_a
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Colunas extras da tabela gade que só existem em ALGUMAS operações (ex.:
|
# Colunas extras da tabela gade que só existem em ALGUMAS operações (ex.:
|
||||||
|
|||||||
@@ -168,6 +168,11 @@
|
|||||||
<span class="text-gray-400">·</span>
|
<span class="text-gray-400">·</span>
|
||||||
<span class="text-red-400"><%= @entregas_falhadas %> falhadas</span>
|
<span class="text-red-400"><%= @entregas_falhadas %> falhadas</span>
|
||||||
</div>
|
</div>
|
||||||
|
<%# 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. %>
|
||||||
|
<p class="text-xs text-gray-500 mt-2">
|
||||||
|
visitas atendidas · <span class="text-gray-400"><%= @notas_atendidas %> notas fiscais</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%# Card 3: Consolidações %>
|
<%# Card 3: Consolidações %>
|
||||||
|
|||||||
81
app/views/operacoes_dashboard/_fora_operacao.html.erb
Normal file
81
app/views/operacoes_dashboard/_fora_operacao.html.erb
Normal file
@@ -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). %>
|
||||||
|
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 overflow-hidden">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3 px-6 py-4 border-b border-white/5">
|
||||||
|
<div>
|
||||||
|
<p class="text-white font-bold flex items-center gap-2">
|
||||||
|
<%= icone :parcial, cor: 'text-amber-400', espaco: false %>
|
||||||
|
Notas fora da operação
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 mt-0.5">
|
||||||
|
Entregues em <%= inicio.strftime('%d/%m/%Y') %> – <%= fim.strftime('%d/%m/%Y') %>
|
||||||
|
sem constar em nenhuma planilha <code class="text-gray-400">gade_entregas_*</code> — planos avulsos e de inclusão.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4 text-sm">
|
||||||
|
<span class="text-gray-400">NFs <strong class="text-white text-lg"><%= fora.total %></strong></span>
|
||||||
|
<span class="text-gray-400">entregues <strong class="text-green-400 text-lg"><%= fora.entregues %></strong></span>
|
||||||
|
<span class="text-gray-400">recusas <strong class="text-blue-400 text-lg"><%= fora.nao_entregues %></strong></span>
|
||||||
|
<% if fora.pendentes.positive? %>
|
||||||
|
<span class="text-gray-400">pendentes <strong class="text-yellow-400 text-lg"><%= fora.pendentes %></strong></span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if fora.total.zero? %>
|
||||||
|
<p class="px-6 py-8 text-center text-gray-500 text-sm">
|
||||||
|
Nenhuma nota fora das planilhas de operação neste período.
|
||||||
|
</p>
|
||||||
|
<% else %>
|
||||||
|
<%# Quebra por plano de origem — é aqui que "(Avulsa)" e "INCLUSÃO" se separam. %>
|
||||||
|
<div class="flex flex-wrap gap-2 px-6 py-3 border-b border-white/5">
|
||||||
|
<% fora.por_plano.first(8).each do |grupo| %>
|
||||||
|
<span class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/5 border border-white/10 text-xs text-gray-300">
|
||||||
|
<%= grupo[:nome] %> <strong class="text-white"><%= grupo[:total] %></strong>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-white/5 text-gray-400 text-xs uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th class="text-left px-6 py-3 font-medium">NF</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium">Plano / título</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium">Motorista</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium">Unidade</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium">Data</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium">Resultado</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium">Motivo</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-white/5">
|
||||||
|
<% fora.listagem.each do |linha| %>
|
||||||
|
<% sucesso = linha['status'] == 'completed' %>
|
||||||
|
<tr class="hover:bg-white/5">
|
||||||
|
<td class="px-6 py-2.5 text-white font-mono"><%= linha['reference_id'] %></td>
|
||||||
|
<td class="px-4 py-2.5 text-gray-300 max-w-[22rem] truncate" title="<%= fora.plano(linha) %>"><%= fora.plano(linha) %></td>
|
||||||
|
<td class="px-4 py-2.5 text-gray-300"><%= linha['driver'].presence || '—' %></td>
|
||||||
|
<td class="px-4 py-2.5 text-gray-300"><%= linha['contact_name'].presence || '—' %></td>
|
||||||
|
<td class="px-4 py-2.5 text-gray-400 whitespace-nowrap">
|
||||||
|
<%= (linha['checkout'].presence || linha['planned_date'].presence)&.to_date&.strftime('%d/%m/%Y') || '—' %>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2.5">
|
||||||
|
<span class="<%= sucesso ? 'text-green-400' : 'text-blue-400' %>"><%= sucesso ? 'Entregue' : (linha['status'].presence || '—') %></span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2.5 text-gray-400"><%= linha['observation'].presence || '—' %></td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if fora.truncada? %>
|
||||||
|
<p class="px-6 py-3 text-xs text-gray-500 border-t border-white/5">
|
||||||
|
Mostrando as <%= Analytics::NotasForaOperacao::LIMITE %> mais recentes de <%= fora.total %>.
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
<div class="group bg-[#1a1a1a] rounded-2xl border border-white/5 p-7 transition-all duration-200 hover:border-white/20 hover:bg-[#202020] hover:-translate-y-0.5 hover:shadow-lg hover:shadow-black/40">
|
<div class="group bg-[#1a1a1a] rounded-2xl border border-white/5 p-7 transition-all duration-200 hover:border-white/20 hover:bg-[#202020] hover:-translate-y-0.5 hover:shadow-lg hover:shadow-black/40">
|
||||||
<p class="text-gray-400 text-sm font-medium mb-1">Total de Entregas</p>
|
<p class="text-gray-400 text-sm font-medium mb-1">Total de Entregas</p>
|
||||||
<p class="text-6xl font-black text-white leading-none inline-block origin-left transition-transform duration-200 group-hover:scale-110"><%= metricas.total %></p>
|
<p class="text-6xl font-black text-white leading-none inline-block origin-left transition-transform duration-200 group-hover:scale-110"><%= metricas.total %></p>
|
||||||
|
<p class="text-xs text-gray-500 mt-2">notas fiscais da operação</p>
|
||||||
<p class="text-xs text-gray-500 mt-2 h-0 overflow-hidden opacity-0 transition-all duration-200 group-hover:h-4 group-hover:opacity-100">
|
<p class="text-xs text-gray-500 mt-2 h-0 overflow-hidden opacity-0 transition-all duration-200 group-hover:h-4 group-hover:opacity-100">
|
||||||
<%= icone :sucesso, cor: 'text-green-500', tamanho: 'w-4 h-4' %> <%= metricas.sucesso %> · <%= icone :ponto, cor: 'text-blue-500', tamanho: 'w-4 h-4' %> <%= metricas.recusas %> · <%= icone :parcial, cor: 'text-amber-400', tamanho: 'w-4 h-4' %> <%= metricas.pendentes %>
|
<%= icone :sucesso, cor: 'text-green-500', tamanho: 'w-4 h-4' %> <%= metricas.sucesso %> · <%= icone :ponto, cor: 'text-blue-500', tamanho: 'w-4 h-4' %> <%= metricas.recusas %> · <%= icone :parcial, cor: 'text-amber-400', tamanho: 'w-4 h-4' %> <%= metricas.pendentes %>
|
||||||
</p>
|
</p>
|
||||||
@@ -49,6 +50,37 @@
|
|||||||
<%= pct_total.(metricas.pendentes) %>% do total
|
<%= pct_total.(metricas.pendentes) %>% do total
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<%# 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. %>
|
||||||
|
<div class="bg-[#141414] rounded-2xl border border-white/5 p-5">
|
||||||
|
<p class="text-gray-400 text-xs font-medium uppercase tracking-wider mb-3">Visitas ao local</p>
|
||||||
|
<dl class="space-y-2 text-sm">
|
||||||
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
|
<dt class="text-gray-400">Visitas realizadas</dt>
|
||||||
|
<dd class="text-white font-bold"><%= metricas.total_visitas %></dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
|
<dt class="text-gray-400">Retentativas</dt>
|
||||||
|
<dd class="<%= metricas.retentativas.positive? ? 'text-amber-400' : 'text-gray-500' %> font-bold"><%= metricas.retentativas %></dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
|
<dt class="text-gray-400">Insucessos (por visita)</dt>
|
||||||
|
<dd class="text-blue-400 font-bold"><%= metricas.visitas_insucesso %></dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
|
<dt class="text-gray-400">Entregues na 2ª ida ou mais</dt>
|
||||||
|
<dd class="text-white font-bold"><%= metricas.notas_reentregues %></dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<p class="text-[11px] leading-snug text-gray-500 mt-3">
|
||||||
|
O dashboard financeiro conta VISITAS (o motorista recebe por ida); esta tela conta NOTAS.
|
||||||
|
A diferença são as <%= metricas.retentativas %> retentativas acima.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%# Coluna direita — gráficos/tabelas ocupam o espaço ao lado dos cards. %>
|
<%# Coluna direita — gráficos/tabelas ocupam o espaço ao lado dos cards. %>
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
<%= icone :comparar %> Comparação entre operações (cada uma no seu próprio período)
|
<%= icone :comparar %> Comparação entre operações (cada uma no seu próprio período)
|
||||||
<% elsif @metricas&.data_inicio %>
|
<% elsif @metricas&.data_inicio %>
|
||||||
<%= @metricas.operacoes_label %> · <%= @metricas.data_inicio.strftime('%d/%m/%Y') %> – <%= @metricas.data_fim.strftime('%d/%m/%Y') %>
|
<%= @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 %>
|
<% elsif @operacao %>
|
||||||
<%= Operacao.label(@operacao) %>
|
<%= Operacao.label(@operacao) %>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -64,9 +65,11 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%# Filtro de período — só na visão Global (a faixa de data não se aplica a
|
<%# Filtro de período — Global e Operação. No modo Operação ele só entra em
|
||||||
operação única, que usa o período natural dos próprios dados). %>
|
vigor quando o operador ESCOLHE uma faixa (params inicio/fim): sem isso o
|
||||||
<% if @modo == 'global' %>
|
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) %>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2"><%= icone :calendario, espaco: false %></span>
|
<span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2"><%= icone :calendario, espaco: false %></span>
|
||||||
@@ -77,12 +80,22 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-1">
|
<div class="flex flex-wrap items-center gap-1">
|
||||||
<% atalhos.each do |label, (ini, fim)| %>
|
<% 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,
|
<%= 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 },
|
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'}" %>
|
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 %>
|
<% 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 %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -90,12 +103,16 @@
|
|||||||
|
|
||||||
<%# ── Abas de modo + atalho para a planilha ─────────────── %>
|
<%# ── Abas de modo + atalho para a planilha ─────────────── %>
|
||||||
<% modos = { 'operacao' => [:operacao, 'Operação'], 'global' => [:global, 'Global'], 'comparar' => [:comparar, 'Comparar'] } %>
|
<% 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 } : {} %>
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-white/5 pb-3">
|
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-white/5 pb-3">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<% modos.each do |m, (ic, label)| %>
|
<% modos.each do |m, (ic, label)| %>
|
||||||
<% ativo = @modo == m %>
|
<% ativo = @modo == m %>
|
||||||
<%= link_to rotulo(ic, label, cor: (ativo ? nil : 'text-brand-laranja')),
|
<%= 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 },
|
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'}" %>
|
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 %>
|
<% end %>
|
||||||
@@ -157,6 +174,9 @@
|
|||||||
<%= render 'painel', metricas: @metricas, filtravel: true %>
|
<%= render 'painel', metricas: @metricas, filtravel: true %>
|
||||||
<%= render 'mapa', metricas: @metricas %>
|
<%= render 'mapa', metricas: @metricas %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<% if @fora_operacao %>
|
||||||
|
<%= render 'fora_operacao', fora: @fora_operacao, inicio: @periodo_inicio, fim: @periodo_fim %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<% elsif @modo == 'global' %>
|
<% elsif @modo == 'global' %>
|
||||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-4 text-sm text-gray-300">
|
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-4 text-sm text-gray-300">
|
||||||
@@ -167,6 +187,9 @@
|
|||||||
<%= render 'painel', metricas: @metricas, filtravel: true %>
|
<%= render 'painel', metricas: @metricas, filtravel: true %>
|
||||||
<%= render 'mapa', metricas: @metricas %>
|
<%= render 'mapa', metricas: @metricas %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<% if @fora_operacao %>
|
||||||
|
<%= render 'fora_operacao', fora: @fora_operacao, inicio: @periodo_inicio, fim: @periodo_fim %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<% else # comparar %>
|
<% else # comparar %>
|
||||||
<form method="get" action="<%= operacoes_dashboard_path %>" data-turbo="false" class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<form method="get" action="<%= operacoes_dashboard_path %>" data-turbo="false" class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
|||||||
67
spec/services/analytics/notas_fora_operacao_spec.rb
Normal file
67
spec/services/analytics/notas_fora_operacao_spec.rb
Normal file
@@ -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
|
||||||
@@ -3,14 +3,16 @@ require 'rails_helper'
|
|||||||
RSpec.describe Analytics::OperacaoMetricas do
|
RSpec.describe Analytics::OperacaoMetricas do
|
||||||
# Linhas simuladas (mesma forma do retorno de select_all) — evita depender das
|
# Linhas simuladas (mesma forma do retorno de select_all) — evita depender das
|
||||||
# tabelas externas no teste. Stubamos #registros para focar nas agregações.
|
# 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
|
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(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(status: 'completed', driver: 'Carlos', sts: 'STS PERUS', gade: 'RECORRENTE', checkout: '2026-06-22 11:00:00', lat: '-23.6', lng: '-46.7'),
|
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(status: 'completed', driver: 'Pedro', sts: 'STS PIRITUBA', gade: 'RECORRENTE', checkout: '2026-06-23 09:00:00', clat: '-23.4', clng: '-46.5'),
|
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(status: 'failed', driver: 'Pedro', sts: 'STS PIRITUBA', gade: 'NOVO', obs: 'ÓBITO', checkout: '2026-06-23 12:00:00'),
|
row(nf: 4, 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(nf: 5, 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: 6, status: 'pending', driver: 'Marcos', sts: 'STS PERUS', gade: 'NOVO', planned: '2026-06-24')
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -31,6 +33,51 @@ RSpec.describe Analytics::OperacaoMetricas do
|
|||||||
expect(metricas.pendentes).to eq(1)
|
expect(metricas.pendentes).to eq(1)
|
||||||
end
|
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
|
it 'calcula insucessos %' do
|
||||||
ins = metricas.insucessos_pct
|
ins = metricas.insucessos_pct
|
||||||
expect(ins[:completed]).to eq(3)
|
expect(ins[:completed]).to eq(3)
|
||||||
@@ -104,9 +151,9 @@ RSpec.describe Analytics::OperacaoMetricas do
|
|||||||
end
|
end
|
||||||
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,
|
'observation' => obs, 'checkout' => checkout, 'planned_date' => planned,
|
||||||
'latitude' => lat, 'longitude' => lng,
|
'latitude' => lat, 'longitude' => lng,
|
||||||
'checkout_latitude' => clat, 'checkout_longitude' => clng
|
'checkout_latitude' => clat, 'checkout_longitude' => clng
|
||||||
|
|||||||
Reference in New Issue
Block a user