From 94bbe7556f9d7f8021ed534560269d04d1a598c478446df73839cf7eddca119c Mon Sep 17 00:00:00 2001 From: victor Date: Mon, 24 Aug 2026 16:26:27 -0300 Subject: [PATCH] =?UTF-8?q?Corre=C3=A7=C3=A3o=20de=20itens=20que=20est?= =?UTF-8?q?=C3=A3o=20fora=20do=20plano=20valores=20no=20dash=20principal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 39 +++++- app/controllers/dashboard_controller.rb | 34 ++++++ app/models/operacao.rb | 25 ++++ app/services/analytics/notas_fora_operacao.rb | 75 +++++++++--- app/views/dashboard/index.html.erb | 10 +- app/views/dashboard/pendentes.html.erb | 113 ++++++++++++++++++ .../_fora_operacao.html.erb | 20 ++-- config/routes.rb | 2 + spec/requests/dashboard_spec.rb | 63 ++++++++++ .../analytics/notas_fora_operacao_spec.rb | 70 ++++++++--- 10 files changed, 403 insertions(+), 48 deletions(-) create mode 100644 app/views/dashboard/pendentes.html.erb diff --git a/README.md b/README.md index 36b65aa..075e16c 100644 --- a/README.md +++ b/README.md @@ -2176,6 +2176,26 @@ inteira — senão abrir uma operação de meses atrás cairia no mês corrente **Financeiro — linha "N notas fiscais"** no card Total Entregas, ao lado de "visitas atendidas". +**Financeiro — card "N pendentes" agora é clicável** → `/dashboard/pendentes`, a tela nova +**"Entregas em aberto"**. O número existia desde sempre e **nenhuma tela listava as linhas por trás +dele** — só dava para descobrir por `rails runner`. A lista traz NF, status, motorista, veículo, +unidade, data planejada, **em qual operação a NF está** (ou "fora da operação") e **quantas visitas +o rastreio tem para ela**. Essas duas últimas colunas respondem sozinhas por que a entrega ficou em +aberto e por que o dashboard de Operações não a mostrava. + +### 🔍 O que o dado real mostrou (24/08/2026, teste) +A "1 pendente" que não aparecia em Operações era a **NF 85382 — MARIA APARECIDA JESUS SANTOS**: +plano avulso, status `pending`, **sem motorista**, planejada 03/08/2026, STS VILA PRUDENTE _ +SAPOPEMBA. Nota **fora da operação** — o `INNER JOIN` a descartava. Confirmado no painel novo. + +⚠️ **`title` NÃO é o nome do plano.** No dado real ele traz `NF 89096 - KAIQUE TAUAN DA SILVA` — o +formato da coluna A da planilha de importação (`NF {nota_fiscal} - {nome_completo}`), ou seja, o +**destinatário**. Agrupar por ele dava um grupo por NF. `COLUNAS_PLANO` passou a ser +`notes, comments, route_id`; `title` virou a coluna "Destinatário". Quando nenhuma coluna de plano +vem preenchida, a quebra cai para **unidade**, que ainda informa algo. **A coluna que carrega +"(Avulsa)"/"INCLUSÃO" segue não confirmada** — pode ser que o espelho simplesmente não a traga +(o sync já deixa 4 colunas 100% NULL). + ### ⚙️ Pontos não-óbvios **Duas camadas no mesmo objeto.** `OperacaoMetricas#visitas` = uma linha por ida; `#linhas` = uma @@ -2198,16 +2218,20 @@ Tem spec para isso. ### 📂 Arquivos ``` app/models/entrega.rb (contas_gade + condicao_conta_sql) +app/models/operacao.rb (por_notas — em que operação cada NF está) 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/controllers/dashboard_controller.rb (@notas_atendidas + action #pendentes) +app/views/dashboard/pendentes.html.erb (NOVO — tela "Entregas em aberto") +config/routes.rb (GET /dashboard/pendentes) 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") +app/views/dashboard/index.html.erb (linha "N notas fiscais" + card clicável) spec/services/analytics/operacao_metricas_spec.rb (+ NF com retentativa) spec/services/analytics/notas_fora_operacao_spec.rb (NOVO) +spec/requests/dashboard_spec.rb (+ tela de entregas em aberto) ``` > **Sem migration e sem gem nova** — model, services, controllers, views e specs. @@ -2218,12 +2242,15 @@ spec/services/analytics/notas_fora_operacao_spec.rb (NOVO) docker compose exec app bundle exec rspec \ spec/services/analytics/operacao_metricas_spec.rb \ spec/services/analytics/notas_fora_operacao_spec.rb \ + spec/requests/dashboard_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' +# 2. Achar a coluna do PLANO ("(Avulsa)", "INCLUSÃO"). title JÁ foi descartado +# (é o destinatário). Dump de uma nota avulsa real para ver onde o plano está: +docker compose exec app bin/rails runner ' + e = Entrega.por_nf(85382).first + e&.attributes&.reject { |_, v| v.blank? }&.each { |k, v| puts "#{k.ljust(28)} #{v}" } +' # 3. Com dado real, no mesmo período nas duas telas: # financeiro "N notas fiscais" == Operações Global "Total de Entregas" diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index eca9195..5a46284 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -50,6 +50,40 @@ class DashboardController < ApplicationController disposition: 'attachment' end + # GET /dashboard/pendentes?inicio=&fim=&operacoes[]= + # Quais são as entregas EM ABERTO do período — o card do dashboard mostrava só + # o número e não havia tela nenhuma listando as linhas por trás dele. + # + # Em aberto = nem concluída nem falhada (Entrega.pendentes), pela data + # PLANEJADA: é o único recorte sem checkout, então planned_date é o eixo + # possível. Mesmo recorte do card, para o total bater. + def pendentes + skip_authorization + return redirect_to(motorista_dashboard_path) if current_user.motorista? + + @periodo_inicio, @periodo_fim = periodo_selecionado + @operacoes_agrupadas = Operacao.agrupadas_por_mes + @operacao_filtro = Operacao.sanitizar(params[:operacoes]) + + escopo = Entrega.da_conta_gade.pendentes.no_periodo(@periodo_inicio, @periodo_fim) + escopo = escopo.da_operacoes(@operacao_filtro) if @operacao_filtro.present? + + @total_em_aberto = escopo.count + begin + @pagy, @em_aberto = pagy(escopo.order(planned_date: :desc), limit: 50) + rescue Pagy::OverflowError + @pagy, @em_aberto = pagy(escopo.order(planned_date: :desc), limit: 50, page: 1) + end + + # Contexto de cada NF listada: em que operação ela está (se estiver) e + # quantas visitas o rastreio tem para ela. As duas respostas juntas dizem por + # que a entrega ficou em aberto — e por que o dashboard de Operações não a + # mostra: ou é nota fora da operação, ou tem outra visita que a substituiu. + nfs = @em_aberto.map { |e| e.reference_id.to_s }.reject(&:empty?) + @operacao_por_nf = Operacao.por_notas(nfs) + @visitas_por_nf = nfs.any? ? Entrega.where(reference_id: nfs).group(:reference_id).count : {} + end + private # Faixa de datas vinda do filtro de calendário (params inicio/fim). diff --git a/app/models/operacao.rb b/app/models/operacao.rb index 3620036..ec96cce 100644 --- a/app/models/operacao.rb +++ b/app/models/operacao.rb @@ -78,6 +78,31 @@ class Operacao grupos.sort_by { |titulo, _| ordem[titulo] }.map { |titulo, itens| [titulo, itens] } end + # Em QUAIS operações cada NF aparece: { '85382' => ['UBS NORTE AGO 2026'] }. + # NFs que não estiverem em nenhuma planilha simplesmente não vêm no Hash — é + # assim que a tela de pendentes marca "fora da operação" (plano avulso/de + # inclusão) sem precisar de uma query por tabela. + # + # SEGURANÇA: as tabelas vêm de #nomes_validos (catálogo) + quote_table_name; + # as NFs entram por conn.quote. Uma query só, com UNION ALL. + def por_notas(notas) + lista = Array(notas).map(&:to_s).map(&:strip).reject(&:empty?).uniq + return {} if lista.empty? + + tabelas = nomes_validos + return {} if tabelas.empty? + + valores = lista.map { |n| conn.quote(n) }.join(', ') + sql = tabelas.map do |t| + "SELECT nota_fiscal, #{conn.quote(label(t))} AS operacao " \ + "FROM #{conn.quote_table_name(t)} WHERE nota_fiscal IN (#{valores})" + end.join(' UNION ALL ') + + conn.select_all(sql).to_a + .group_by { |r| r['nota_fiscal'].to_s } + .transform_values { |rows| rows.map { |r| r['operacao'] }.uniq.sort } + end + # Dados agregados das entregas das operações informadas, para pré-preencher o # formulário de nova consolidação. Une as NFs de todas as tabelas válidas. def dados(tabelas) diff --git a/app/services/analytics/notas_fora_operacao.rb b/app/services/analytics/notas_fora_operacao.rb index ebbe1e9..306bc67 100644 --- a/app/services/analytics/notas_fora_operacao.rb +++ b/app/services/analytics/notas_fora_operacao.rb @@ -16,11 +16,19 @@ module Analytics # 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 + # Colunas extras do espelho que a tela usa. 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_EXTRA = %w[title notes comments route_id].freeze + + # Candidatas ao nome do PLANO de origem ("(Avulsa)", "INCLUSÃO"...), na ordem + # de preferência. + # + # ⚠️ `title` NÃO entra aqui: conferido em 24/08/2026 com dado real, ele traz + # "NF 89096 - KAIQUE TAUAN DA SILVA" — o formato da coluna A da planilha de + # importação (`NF {nota_fiscal} - {nome_completo}`), ou seja, o destinatário. + # Agrupar por ele daria um grupo por NF, o que não informa nada. + COLUNAS_PLANO = %w[notes comments route_id].freeze def initialize(inicio:, fim:) @inicio = inicio&.to_date @@ -61,21 +69,48 @@ module Analytics 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. + # Nome do plano de origem: primeira coluna de COLUNAS_PLANO preenchida. + # nil quando o espelho não guarda essa informação. def plano(registro) COLUNAS_PLANO.each do |coluna| valor = registro[coluna].to_s.strip return valor if valor.present? end - 'SEM PLANO IDENTIFICADO' + nil + end + + # Destinatário/título da visita (coluna `title` do espelho). + def titulo(registro) + registro['title'].to_s.strip.presence + end + + def plano_identificado? + linhas.any? { |r| plano(r) } + end + + # Quebra do painel em grupos. Quando o espelho traz o plano, separa + # "(Avulsa)" de "INCLUSÃO"; quando não traz, agrupar por título daria um + # grupo por NF — então cai para a unidade, que ainda diz algo útil. + # Devolve [rótulo do agrupamento, [{ nome:, total: }, ...]]. + def agrupamento + if plano_identificado? + ['Plano', agrupar { |r| plano(r) || 'SEM PLANO' }] + else + ['Unidade', agrupar { |r| r['contact_name'].to_s.strip.presence || 'SEM UNIDADE' }] + end + end + + # Rótulo legível do resultado da última visita. + RESULTADOS = { 'completed' => 'Entregue', 'failed' => 'Não entregue', + 'pending' => 'Em aberto', 'in_progress' => 'Em rota' }.freeze + + def resultado(registro) + status = registro['status'].to_s + RESULTADOS[status] || status.presence || 'sem status' + end + + def sucesso?(registro) + registro['status'] == 'completed' end def listagem @@ -88,6 +123,12 @@ module Analytics private + def agrupar + linhas.group_by { |r| yield(r) } + .map { |nome, rows| { nome: nome, total: rows.size } } + .sort_by { |h| -h[:total] } + end + def ordem_visita(registro) t = registro['checkout'].presence&.to_time [t ? 1 : 0, t || Time.at(0)] @@ -124,11 +165,11 @@ module Analytics conn.select_all(sql).to_a end - # Colunas de plano que existirem de fato; as demais viram NULL com o mesmo + # Colunas extras 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| + COLUNAS_EXTRA.map do |coluna| existentes.include?(coluna) ? "r.#{coluna}" : "CAST(NULL AS text) AS #{coluna}" end.join(', ') end diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb index 83690cf..6d3aa67 100644 --- a/app/views/dashboard/index.html.erb +++ b/app/views/dashboard/index.html.erb @@ -164,7 +164,15 @@
<%= @entregas_pagas %> entregues · - <%= @entregas_pendentes %> pendentes + <%# Clicável: leva à lista das entregas em aberto, no MESMO recorte do card + (período + filtro de operação). O número existia sem nenhuma tela por trás. %> + <%= link_to "#{@entregas_pendentes} pendentes", + dashboard_pendentes_path(inicio: @periodo_inicio.strftime('%Y-%m-%d'), + fim: @periodo_fim.strftime('%Y-%m-%d'), + operacoes: @operacao_filtro.presence), + data: { turbo: false }, + title: 'Ver quais são as entregas em aberto', + class: 'text-yellow-500 underline decoration-dotted underline-offset-4 hover:text-yellow-300' %> · <%= @entregas_falhadas %> falhadas
diff --git a/app/views/dashboard/pendentes.html.erb b/app/views/dashboard/pendentes.html.erb new file mode 100644 index 0000000..8badc51 --- /dev/null +++ b/app/views/dashboard/pendentes.html.erb @@ -0,0 +1,113 @@ +<%# app/views/dashboard/pendentes.html.erb + O que há por trás do card "N pendentes": as entregas EM ABERTO do período — + nem concluídas nem falhadas, pela data planejada. Antes o número existia e + não havia tela nenhuma listando as linhas. %> +<% ctx = { inicio: @periodo_inicio.strftime('%Y-%m-%d'), fim: @periodo_fim.strftime('%Y-%m-%d'), operacoes: @operacao_filtro.presence }.compact %> + +
+ <%# ── Cabeçalho ─────────────────────────────────────────── %> +
+
+

Entregas em aberto

+

+ Planejadas entre <%= @periodo_inicio.strftime('%d/%m/%Y') %> e <%= @periodo_fim.strftime('%d/%m/%Y') %> + sem fechamento — o motorista não registrou nem sucesso nem insucesso. + <% if @operacao_filtro.present? %> + · <%= @operacao_filtro.map { |t| Operacao.label(t) }.join(', ') %> + <% end %> +

+
+ <%= link_to '‹ Voltar ao dashboard', dashboard_path(ctx), data: { turbo: false }, + class: 'px-4 py-2.5 rounded-xl text-sm whitespace-nowrap bg-[#1a1a1a] text-gray-300 border border-white/10 hover:border-orange-500' %> +
+ +
+
+

+ <%= @total_em_aberto %> <%= 'entrega'.pluralize(@total_em_aberto) %> em aberto +

+

+ A coluna Operação diz se a NF está em alguma planilha + gade_entregas_*; Visitas é quantas + vezes o rastreio tem essa NF. As duas juntas explicam por que ela ficou em aberto. +

+
+ + <% if @em_aberto.empty? %> +

+ Nenhuma entrega em aberto neste período. 🎉 +

+ <% else %> +
+ + + + + + + + + + + + + + + <% @em_aberto.each do |entrega| %> + <% nf = entrega.reference_id.to_s %> + <% operacao = @operacao_por_nf[nf] %> + <% visitas = @visitas_por_nf[entrega.reference_id].to_i %> + + + + + + + + + + + <% end %> + +
NFStatusMotoristaVeículoUnidadePlanejadaOperaçãoVisitas
<%= entrega.reference_id %> + + <%= entrega.status.presence || 'sem status' %> + + <%= entrega.driver.presence || '— sem motorista' %><%= entrega.vehicle.presence || '—' %><%= entrega.contact_name.presence || '—' %><%= entrega.planned_date&.strftime('%d/%m/%Y') || '—' %> + <% if operacao.present? %> + <%= operacao.join(', ') %> + <% else %> + fora da operação + <% end %> + + <%= visitas %> +
+
+ + <%# Navegação de páginas (mesmo padrão de consolidacao_entregas/revisar) %> + <% if @pagy.pages > 1 %> + <% pag = ->(p) { dashboard_pendentes_path(ctx.merge(page: p)) } %> + + <% end %> + <% end %> +
+
diff --git a/app/views/operacoes_dashboard/_fora_operacao.html.erb b/app/views/operacoes_dashboard/_fora_operacao.html.erb index b9ca8c5..b84329b 100644 --- a/app/views/operacoes_dashboard/_fora_operacao.html.erb +++ b/app/views/operacoes_dashboard/_fora_operacao.html.erb @@ -29,9 +29,12 @@ 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| %> + <%# Quebra por plano de origem — é aqui que "(Avulsa)" e "INCLUSÃO" se separam. + Se o espelho não guardar o plano, cai para unidade (ver #agrupamento). %> + <% rotulo_grupo, grupos = fora.agrupamento %> +
+ <%= rotulo_grupo %>: + <% grupos.first(8).each do |grupo| %> <%= grupo[:nome] %> <%= grupo[:total] %> @@ -43,7 +46,8 @@ NF - Plano / título + Destinatário + Plano Motorista Unidade Data @@ -53,17 +57,17 @@ <% fora.listagem.each do |linha| %> - <% sucesso = linha['status'] == 'completed' %> <%= linha['reference_id'] %> - <%= fora.plano(linha) %> - <%= linha['driver'].presence || '—' %> + <%= fora.titulo(linha) || '—' %> + <%= fora.plano(linha) || '—' %> + <%= linha['driver'].presence || '— sem motorista' %> <%= linha['contact_name'].presence || '—' %> <%= (linha['checkout'].presence || linha['planned_date'].presence)&.to_date&.strftime('%d/%m/%Y') || '—' %> - <%= sucesso ? 'Entregue' : (linha['status'].presence || '—') %> + <%= fora.resultado(linha) %> <%= linha['observation'].presence || '—' %> diff --git a/config/routes.rb b/config/routes.rb index 4cbb931..d271f38 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -15,6 +15,8 @@ Rails.application.routes.draw do # Dashboard principal (admin/gerente/operador) get '/dashboard', to: 'dashboard#index', as: :dashboard get '/dashboard/relatorio_financeiro', to: 'dashboard#relatorio_financeiro', as: :dashboard_relatorio_financeiro + # Entregas em aberto do período (o que há por trás do card "N pendentes") + get '/dashboard/pendentes', to: 'dashboard#pendentes', as: :dashboard_pendentes # Dashboard de análise de entregas por operação (UBS Norte, EMAD, ...) get '/dashboard/operacoes', to: 'operacoes_dashboard#index', as: :operacoes_dashboard diff --git a/spec/requests/dashboard_spec.rb b/spec/requests/dashboard_spec.rb index a3bd074..64aa241 100644 --- a/spec/requests/dashboard_spec.rb +++ b/spec/requests/dashboard_spec.rb @@ -218,3 +218,66 @@ RSpec.describe 'Dashboard — aba Consolidado do ranking de motoristas', type: : expect(painel_consolidado).to include('Nenhuma consolidação finalizada neste período') end end + +# Tela "Entregas em aberto" — o que há por trás do card "N pendentes". +# +# Em aberto = nem concluída nem falhada, pela data PLANEJADA (é o único recorte +# sem checkout). Tem que usar o MESMO recorte do card, senão o número do +# dashboard leva a uma lista que não bate com ele. +RSpec.describe 'Dashboard — entregas em aberto', type: :request do + include_context 'espelho de rastreio' + + let(:inicio) { '2026-08-01' } + let(:fim) { '2026-08-14' } + + before { sign_in create(:gerente) } + + def abrir_pendentes + get dashboard_pendentes_path, params: { inicio: inicio, fim: fim } + expect(response).to have_http_status(:ok) + response.body + end + + it 'lista só o que está em aberto dentro do período' do + semear([ + # Em aberto no período — a única que deve aparecer. + { reference_id: 85_382, driver: nil, status: 'pending', + planned_date: Time.zone.local(2026, 8, 3, 8, 0), checkout: nil }, + # Fechadas: não são "em aberto", mesmo planejadas no período. + { reference_id: 85_383, driver: 'CARLOS', status: 'completed', + planned_date: Time.zone.local(2026, 8, 4, 8, 0), checkout: Time.zone.local(2026, 8, 4, 10, 0) }, + { reference_id: 85_384, driver: 'CARLOS', status: 'failed', + planned_date: Time.zone.local(2026, 8, 5, 8, 0), checkout: Time.zone.local(2026, 8, 5, 10, 0) }, + # Em aberto, mas planejada FORA do período. + { reference_id: 85_385, driver: nil, status: 'pending', + planned_date: Time.zone.local(2026, 9, 2, 8, 0), checkout: nil } + ]) + + corpo = abrir_pendentes + expect(corpo).to include('85382') + expect(corpo).not_to include('85383') + expect(corpo).not_to include('85384') + expect(corpo).not_to include('85385') + end + + # Sem nenhuma tabela gade_entregas_* no banco de teste, toda NF é "fora da + # operação" — que é exatamente o caso real da NF 85382 que motivou a tela. + it 'marca a NF que não está em nenhuma planilha de operação' do + semear([{ reference_id: 85_382, driver: nil, status: 'pending', + planned_date: Time.zone.local(2026, 8, 3, 8, 0), checkout: nil }]) + + expect(abrir_pendentes).to include('fora da operação') + end + + it 'o card do dashboard aponta para esta tela com o mesmo período' do + create(:configuracao, chave: 'preco_entrega', valor: '10.0') + semear([{ reference_id: 85_382, driver: nil, status: 'pending', + planned_date: Time.zone.local(2026, 8, 3, 8, 0), checkout: nil }]) + + get dashboard_path, params: { inicio: inicio, fim: fim } + # O href sai HTML-escapado (&) e a ordem dos params varia — confere o + # caminho e o período separadamente. + expect(response.body).to include(dashboard_pendentes_path) + expect(response.body).to match(%r{/dashboard/pendentes\?[^"]*inicio=2026-08-01}) + end +end diff --git a/spec/services/analytics/notas_fora_operacao_spec.rb b/spec/services/analytics/notas_fora_operacao_spec.rb index 6a422fb..a53886c 100644 --- a/spec/services/analytics/notas_fora_operacao_spec.rb +++ b/spec/services/analytics/notas_fora_operacao_spec.rb @@ -6,11 +6,11 @@ RSpec.describe Analytics::NotasForaOperacao do # 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) + row(nf: 100, status: 'failed', driver: 'Carlos', sts: 'STS PENHA', checkout: '2026-08-10 09:00:00'), + row(nf: 100, status: 'completed', driver: 'Carlos', sts: 'STS PENHA', checkout: '2026-08-12 09:00:00'), + row(nf: 101, status: 'completed', driver: 'Pedro', sts: 'STS PENHA', checkout: '2026-08-11 09:00:00'), + row(nf: 102, status: 'failed', driver: 'Pedro', sts: 'STS SAPOPEMBA', checkout: '2026-08-13 09:00:00', obs: 'ÓBITO'), + row(nf: 103, status: 'pending', driver: nil, sts: 'STS SAPOPEMBA', planned: '2026-08-20') ] end @@ -23,18 +23,55 @@ RSpec.describe Analytics::NotasForaOperacao do 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 + it 'traduz o resultado da última visita' do + linha = fora.linhas.find { |r| r['reference_id'] == 103 } + expect(fora.resultado(linha)).to eq('Em aberto') + expect(fora.sucesso?(linha)).to be(false) + end + + # Regressão do dado real (24/08/2026): `title` traz "NF 89096 - KAIQUE TAUAN DA + # SILVA" — o destinatário, não o plano. Agrupar por ele daria um grupo por NF. + context 'quando o espelho só tem `title` (destinatário)' do + let(:visitas) do + [ + row(nf: 100, status: 'completed', driver: 'Thiago', sts: 'STS PENHA', checkout: '2026-08-12 09:00:00', title: 'NF 100 - KAIQUE TAUAN DA SILVA'), + row(nf: 101, status: 'pending', driver: nil, sts: 'STS PENHA', planned: '2026-08-03', title: 'NF 101 - MARIA APARECIDA') + ] + end + + it 'não trata o título como plano' do + expect(fora.plano(fora.linhas.first)).to be_nil + expect(fora.titulo(fora.linhas.first)).to start_with('NF ') + expect(fora.plano_identificado?).to be(false) + end + + it 'cai para o agrupamento por unidade' do + rotulo, grupos = fora.agrupamento + expect(rotulo).to eq('Unidade') + expect(grupos).to eq([{ nome: 'STS PENHA', total: 2 }]) + end + end + + context 'quando o espelho traz o plano' do + let(:visitas) do + [ + row(nf: 100, status: 'completed', driver: 'Carlos', sts: 'STS PENHA', checkout: '2026-08-12 09:00:00', notes: '(Avulsa)'), + row(nf: 101, status: 'completed', driver: 'Pedro', sts: 'STS PENHA', checkout: '2026-08-11 09:00:00', notes: 'INCLUSÃO'), + row(nf: 102, status: 'failed', driver: 'Pedro', sts: 'STS PENHA', checkout: '2026-08-13 09:00:00', notes: 'INCLUSÃO') + ] + end + + it 'separa (Avulsa) de INCLUSÃO' do + rotulo, grupos = fora.agrupamento + expect(rotulo).to eq('Plano') + expect(grupos).to eq([{ nome: 'INCLUSÃO', total: 2 }, { nome: '(Avulsa)', total: 1 }]) + end + 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 @@ -55,13 +92,14 @@ RSpec.describe Analytics::NotasForaOperacao do 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([]) + expect(fora.agrupamento.last).to eq([]) end end - def row(nf:, status:, driver:, checkout: nil, planned: nil, title: nil, obs: nil) + def row(nf:, status:, driver:, sts: nil, checkout: nil, planned: nil, title: nil, notes: 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 } + 'contact_name' => sts, 'title' => title, 'notes' => notes, + 'comments' => nil, 'route_id' => nil } end end