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 @@
+ 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 %> +
++ <%= @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.
+
+ Nenhuma entrega em aberto neste período. 🎉 +
+ <% else %> +| NF | +Status | +Motorista | +Veículo | +Unidade | +Planejada | +Operação | +Visitas | +
|---|---|---|---|---|---|---|---|
| <%= 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 %> + | +