diff --git a/Imagens para implantação /Imagem colada (11).png b/Imagens para implantação /Imagem colada (11).png new file mode 100644 index 0000000..f68f767 Binary files /dev/null and b/Imagens para implantação /Imagem colada (11).png differ diff --git a/app/controllers/consolidacao_entregas_controller.rb b/app/controllers/consolidacao_entregas_controller.rb index 4cce105..e965b37 100644 --- a/app/controllers/consolidacao_entregas_controller.rb +++ b/app/controllers/consolidacao_entregas_controller.rb @@ -16,7 +16,8 @@ class ConsolidacaoEntregasController < ApplicationController .da_conta_gade .no_periodo(@consolidacao.data_inicio, @consolidacao.data_fim) .do_motorista(@motorista) - @entregas = @entregas.da_veiculo(@consolidacao.vehicle_ids) if @consolidacao.vehicle_ids.present? + @entregas = @entregas.da_veiculo(@consolidacao.vehicle_ids) if @consolidacao.vehicle_ids.present? + @entregas = @entregas.da_operacoes(@consolidacao.operacoes) if @consolidacao.operacoes.present? @entregas = @entregas.order(:planned_date) # Classificações já feitas — agrupadas por tracking_id (uma entrega pode ter @@ -185,7 +186,8 @@ class ConsolidacaoEntregasController < ApplicationController rel = Entrega.pagas.da_conta_gade .no_periodo(@consolidacao.data_inicio, @consolidacao.data_fim) .do_motorista(motorista) - rel = rel.da_veiculo(@consolidacao.vehicle_ids) if @consolidacao.vehicle_ids.present? + rel = rel.da_veiculo(@consolidacao.vehicle_ids) if @consolidacao.vehicle_ids.present? + rel = rel.da_operacoes(@consolidacao.operacoes) if @consolidacao.operacoes.present? rel.pluck(:tracking_id) end diff --git a/app/controllers/consolidacoes_controller.rb b/app/controllers/consolidacoes_controller.rb index c3778c5..a78360d 100644 --- a/app/controllers/consolidacoes_controller.rb +++ b/app/controllers/consolidacoes_controller.rb @@ -46,12 +46,14 @@ class ConsolidacoesController < ApplicationController ) @motoristas = Entrega.motoristas_ativos @veiculos = Entrega.veiculos_unicos + @operacoes = Operacao.todas end # POST /consolidacoes def create authorize Consolidacao @consolidacao = Consolidacao.new(consolidacao_params.merge(created_by: current_user.id)) + @consolidacao.operacoes = Operacao.sanitizar(@consolidacao.operacoes) motoristas = Array(params[:motoristas]).reject(&:blank?) @@ -86,6 +88,19 @@ class ConsolidacoesController < ApplicationController render json: { veiculos: veiculos } end + # GET /consolidacoes/operacoes_disponiveis — lista (JSON) as tabelas de operação. + def operacoes_disponiveis + authorize Consolidacao, :new? + render json: { operacoes: Operacao.todas } + end + + # GET /consolidacoes/dados_da_operacao?operacoes[]=X — motoristas/veículos/período + # derivados das operações selecionadas, para pré-preencher o formulário. + def dados_da_operacao + authorize Consolidacao, :new? + render json: Operacao.dados(params[:operacoes]) + end + # GET /consolidacoes/:id — visão geral def show authorize @consolidacao @@ -268,9 +283,10 @@ class ConsolidacoesController < ApplicationController def preparar_form_new @motoristas = Entrega.motoristas_ativos @veiculos = Entrega.veiculos_unicos + @operacoes = Operacao.todas end def consolidacao_params - params.require(:consolidacao).permit(:nome, :data_inicio, :data_fim, vehicle_ids: []) + params.require(:consolidacao).permit(:nome, :data_inicio, :data_fim, vehicle_ids: [], operacoes: []) end end diff --git a/app/models/consolidacao.rb b/app/models/consolidacao.rb index cdf350f..d893afc 100644 --- a/app/models/consolidacao.rb +++ b/app/models/consolidacao.rb @@ -130,10 +130,16 @@ class Consolidacao < ApplicationRecord inicio: data_inicio, fim: data_fim, motorista: motorista, - vehicle: vehicle_ids.presence + vehicle: vehicle_ids.presence, + operacoes: operacoes.presence ) end + # Rótulos amigáveis das operações vinculadas (para exibição). + def operacoes_label + Array(operacoes).map { |t| Operacao.label(t) } + end + # Entrega conta como classificada se tiver ≥1 pilar (distinct por tracking_id, # já que agora pode haver vários pilares por entrega). def classificadas_count(motorista) diff --git a/app/models/entrega.rb b/app/models/entrega.rb index 3495f8b..0d28652 100644 --- a/app/models/entrega.rb +++ b/app/models/entrega.rb @@ -35,6 +35,21 @@ class Entrega < ApplicationRecord where(vehicle: vehicle) } + # Restringe às entregas cujas NFs (reference_id) constam na(s) tabela(s) de + # operação informada(s). O JOIN é o mesmo do script de gestão do cliente: + # reference_id::text = .nota_fiscal + # + # SEGURANÇA: o nome da tabela entra no SQL, então passa pela whitelist + # (Operacao.sanitizar) e por quote_table_name. Nomes inválidos são descartados; + # se nada sobrar, não filtra (all) — o filtro de operação é opcional. + scope :da_operacoes, ->(tabelas) { + validas = Operacao.sanitizar(tabelas) + next all if validas.empty? + + selects = validas.map { |t| "SELECT nota_fiscal FROM #{connection.quote_table_name(t)}" } + where("reference_id::text IN (#{selects.join(' UNION ')})") + } + scope :do_mes, ->(data = Date.current) { where(planned_date: data.beginning_of_month..data.end_of_month) } @@ -84,10 +99,11 @@ class Entrega < ApplicationRecord end # Contagem de entregas pagas para cálculo estimado - def self.contar_pagas(inicio:, fim:, motorista: nil, vehicle: nil) + def self.contar_pagas(inicio:, fim:, motorista: nil, vehicle: nil, operacoes: nil) base = pagas.da_conta_gade.no_periodo(inicio, fim) - base = base.do_motorista(motorista) if motorista.present? - base = base.da_veiculo(vehicle) if vehicle.present? + base = base.do_motorista(motorista) if motorista.present? + base = base.da_veiculo(vehicle) if vehicle.present? + base = base.da_operacoes(operacoes) if operacoes.present? base.count end diff --git a/app/models/operacao.rb b/app/models/operacao.rb new file mode 100644 index 0000000..d4b99a8 --- /dev/null +++ b/app/models/operacao.rb @@ -0,0 +1,70 @@ +# app/models/operacao.rb +# +# Operações são as tabelas EXTERNAS que o cliente carrega no banco, uma por +# operação/mês (ex.: "gade_entregas_ubs_sul_leste_jun_2026"). Cada tabela tem a +# coluna `nota_fiscal`, que casa com db_reem_simplerout_2026.reference_id::text. +# +# Estas tabelas são SOMENTE LEITURA — apenas SELECT, nunca migration/INSERT/etc. +# +# Como o nome da tabela entra em SQL (FROM dinâmico), TODA entrada externa passa +# por uma whitelist (#nomes_validos) antes de ser usada — proteção anti-injection. +class Operacao + PREFIXO = ENV.fetch('OPERACOES_TABLE_PREFIX', 'gade_entregas_').freeze + + class << self + # [{ tabela: 'gade_entregas_...', label: 'Ubs Sul Leste Jun 2026' }, ...] + def todas + nomes_validos.map { |t| { tabela: t, label: label(t) } } + end + + # Nomes reais de tabelas no schema public que batem com o prefixo. + # Sem cache de processo: o cliente sobe novas tabelas de operação a qualquer + # momento e elas precisam aparecer sem reiniciar o servidor (a consulta ao + # catálogo é barata). + def nomes_validos + conn.select_values( + "SELECT table_name FROM information_schema.tables " \ + "WHERE table_schema = 'public' AND table_name LIKE #{conn.quote("#{PREFIXO}%")} ORDER BY table_name" + ) + end + + # Interseção da lista recebida com as tabelas que existem de fato. + def sanitizar(lista) + Array(lista).map(&:to_s).uniq & nomes_validos + end + + def valida?(nome) + nomes_validos.include?(nome.to_s) + end + + # Rótulo amigável: tira o prefixo e formata. + def label(tabela) + tabela.to_s.delete_prefix(PREFIXO).tr('_', ' ').strip.titleize + 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) + validas = sanitizar(tabelas) + return vazio if validas.empty? + + entregas = Entrega.da_conta_gade.da_operacoes(validas) + { + motoristas: entregas.distinct.order(:driver).pluck(:driver).compact.reject(&:blank?), + veiculos: entregas.distinct.order(:vehicle).pluck(:vehicle).compact.reject(&:blank?), + inicio: entregas.minimum(:planned_date)&.to_date, + fim: entregas.maximum(:planned_date)&.to_date + } + end + + private + + def vazio + { motoristas: [], veiculos: [], inicio: nil, fim: nil } + end + + def conn + ActiveRecord::Base.connection + end + end +end diff --git a/app/views/consolidacoes/index.html.erb b/app/views/consolidacoes/index.html.erb index e052590..49b0270 100644 --- a/app/views/consolidacoes/index.html.erb +++ b/app/views/consolidacoes/index.html.erb @@ -55,6 +55,9 @@ · 👤 <%= c.consolidacao_motoristas.count %> motorista(s) · Criada por <%= c.criador.nome_display %>

+ <% if c.operacoes.present? %> +

🗂️ <%= c.operacoes_label.join(' · ') %>

+ <% end %>

<%= moeda(c.valor_total) %>

diff --git a/app/views/consolidacoes/new.html.erb b/app/views/consolidacoes/new.html.erb index b4383c9..7d98f54 100644 --- a/app/views/consolidacoes/new.html.erb +++ b/app/views/consolidacoes/new.html.erb @@ -17,6 +17,28 @@
<% end %> + <%# Operação — multi-select opcional (pré-preenche e filtra por NF) %> +
+ +
+ <% if @operacoes.any? %> + <% @operacoes.each do |op| %> + + <% end %> + <% else %> +

Nenhuma tabela de operação encontrada no banco.

+ <% end %> +
+

+
+ <%# Nome %>
<%= f.label :nome, 'Nome da consolidação', class: 'block text-white font-semibold mb-2' %> @@ -159,6 +181,49 @@ if (dataIni) dataIni.addEventListener('change', atualizarVeiculos); if (dataFim) dataFim.addEventListener('change', atualizarVeiculos); + // ── Operações: ao marcar, pré-preenche período + motoristas + veículos ── + const opStatus = document.getElementById('operacao-status'); + const dadosOpUrl = '<%= dados_da_operacao_consolidacoes_path %>'; + + function operacoesSelecionadas() { + return Array.from(document.querySelectorAll('.chk-operacao:checked')).map(c => c.value); + } + + async function aplicarOperacoes() { + const ops = operacoesSelecionadas(); + if (!ops.length) { if (opStatus) opStatus.textContent = ''; return; } + + if (opStatus) opStatus.textContent = 'Carregando dados da operação…'; + const params = new URLSearchParams(); + ops.forEach(o => params.append('operacoes[]', o)); + + try { + const resp = await fetch(dadosOpUrl + '?' + params.toString(), { headers: { 'Accept': 'application/json' } }); + const data = await resp.json(); + + if (data.inicio && dataIni) dataIni.value = data.inicio; + if (data.fim && dataFim) dataFim.value = data.fim; + + const motset = new Set(data.motoristas || []); + document.querySelectorAll('.chk-motorista').forEach(c => { if (motset.has(c.value)) c.checked = true; }); + + await atualizarVeiculos(); + const vehset = new Set(data.veiculos || []); + lista.querySelectorAll('input[name="consolidacao[vehicle_ids][]"]').forEach(c => { if (vehset.has(c.value)) c.checked = true; }); + + if (opStatus) { + opStatus.textContent = (data.motoristas || []).length + ' motorista(s) e ' + + (data.veiculos || []).length + ' veículo(s) nesta(s) operação(ões).'; + } + } catch (e) { + if (opStatus) opStatus.textContent = 'Erro ao carregar dados da operação.'; + } + } + + document.addEventListener('change', function (e) { + if (e.target.classList.contains('chk-operacao')) aplicarOperacoes(); + }); + // Estado inicial (caso o form volte com motoristas já marcados após erro de validação) if (motoristasSelecionados().length) atualizarVeiculos(); })(); diff --git a/app/views/consolidacoes/show.html.erb b/app/views/consolidacoes/show.html.erb index 873c2e1..0a451d7 100644 --- a/app/views/consolidacoes/show.html.erb +++ b/app/views/consolidacoes/show.html.erb @@ -21,6 +21,14 @@ · Finalizada por <%= @consolidacao.finalizador&.nome_display %> em <%= l @consolidacao.finalizado_em, format: :short %> <% end %>

+ <% if @consolidacao.operacoes.present? %> +

+ 🗂️ Operação: + <% @consolidacao.operacoes_label.each do |op| %> + <%= op %> + <% end %> +

+ <% end %>
<% if @consolidacao.rascunho? %> diff --git a/config/routes.rb b/config/routes.rb index 571ee62..b5ede08 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -27,6 +27,8 @@ Rails.application.routes.draw do resources :consolidacoes do collection do get :veiculos_disponiveis # JSON — veículos do(s) motorista(s) no período (form nova consolidação) + get :operacoes_disponiveis # JSON — tabelas de operação (gade_entregas_*) + get :dados_da_operacao # JSON — motoristas/veículos/período da(s) operação(ões) get :arquivadas # Histórico — consolidações arquivadas end diff --git a/db/migrate/20260617000002_add_operacoes_to_consolidacoes.rb b/db/migrate/20260617000002_add_operacoes_to_consolidacoes.rb new file mode 100644 index 0000000..510ed5c --- /dev/null +++ b/db/migrate/20260617000002_add_operacoes_to_consolidacoes.rb @@ -0,0 +1,7 @@ +# Operações (tabelas externas gade_entregas_*) vinculadas a uma consolidação. +# Array de nomes de tabela já validados contra a whitelist (Operacao). +class AddOperacoesToConsolidacoes < ActiveRecord::Migration[7.1] + def change + add_column :consolidacoes, :operacoes, :jsonb, default: [] unless column_exists?(:consolidacoes, :operacoes) + end +end