# app/controllers/consolidacoes_controller.rb class ConsolidacoesController < ApplicationController include Auditavel before_action :set_consolidacao, only: %i[show edit update destroy finalizar arquivar reativar wizard registrar_pagamento cancelar_pagamento preview_extrato gerar_pdf_relatorio gerar_pdf_extrato gerar_pdf_financeiro] # Formas de pagamento aceitas (o select da UI usa esta mesma lista). FORMAS_PAGAMENTO = %w[pix transferencia dinheiro].freeze # GET /consolidacoes — lista com filtros def index authorize Consolidacao @consolidacoes = Consolidacao.ativas.recentes.includes(:criador) # Filtros @consolidacoes = @consolidacoes.where(status: params[:status]) if params[:status].present? @consolidacoes = @consolidacoes.where('nome ILIKE ?', "%#{params[:nome]}%") if params[:nome].present? @consolidacoes = @consolidacoes.where('data_inicio >= ?', params[:inicio]) if params[:inicio].present? @consolidacoes = @consolidacoes.where('data_fim <= ?', params[:fim]) if params[:fim].present? if params[:motorista].present? ids = ConsolidacaoMotorista.where('motorista_nome ILIKE ?', "%#{params[:motorista]}%") .pluck(:consolidacao_id) @consolidacoes = @consolidacoes.where(id: ids) end if params[:vehicle].present? @consolidacoes = @consolidacoes.where('vehicle_ids @> ?', [params[:vehicle]].to_json) end if %w[pago pendente parcial].include?(params[:pagamento]) @consolidacoes = @consolidacoes.public_send("pagamento_#{params[:pagamento]}") end end # GET /consolidacoes/new def new authorize Consolidacao @consolidacao = Consolidacao.new( data_inicio: Date.current.beginning_of_month, data_fim: Date.current ) @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?) if motoristas.empty? @consolidacao.errors.add(:base, 'Selecione ao menos um motorista') preparar_form_new return render :new, status: :unprocessable_entity end ActiveRecord::Base.transaction do @consolidacao.save! motoristas.each do |nome| @consolidacao.consolidacao_motoristas.create!(motorista_nome: nome) end end auditar!(:criar, @consolidacao, dados_novos: @consolidacao.attributes) redirect_to wizard_consolidacao_path(@consolidacao), notice: 'Consolidação criada! Selecione o motorista para validar.' rescue ActiveRecord::RecordInvalid preparar_form_new render :new, status: :unprocessable_entity end # GET /consolidacoes/veiculos_disponiveis?motoristas[]=X&inicio=&fim= # Lista (JSON) os veículos usados pelos motoristas selecionados no período — # alimenta o filtro dinâmico de veículos no formulário de nova consolidação. def veiculos_disponiveis authorize Consolidacao, :new? veiculos = Entrega.veiculos_de_motoristas( params[:motoristas], inicio: params[:inicio], fim: params[:fim] ) 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/buscar_nf?nf=123 — busca a NF na SimpleRoute (nota avulsa). def buscar_nf authorize Consolidacao, :create? nf = params[:nf].to_s.strip return render(json: { ok: false, erro: 'Informe o número da nota.' }, status: :unprocessable_entity) if nf.blank? entregas = Entrega.da_conta_gade.por_nf(nf).order(:checkout) if entregas.empty? render json: { ok: false, erro: "Nota #{nf} não encontrada na base de rastreio." } else render json: { ok: true, nf: nf, entregas: entregas.map(&:resumo_apontamento) } end end # POST /consolidacoes/avulsa — cria uma consolidação JÁ a partir de uma NF # avulsa (entrega que chegou por fora) e adiciona o apontamento ao motorista. def avulsa authorize Consolidacao, :create? entrega = Entrega.da_conta_gade.find_by(tracking_id: params[:tracking_id]) return redirect_to(consolidacoes_path, alert: 'Entrega não encontrada na base de rastreio.') unless entrega data_ref = (entrega.checkout || entrega.planned_date)&.to_date || Date.current consolidacao = nil ActiveRecord::Base.transaction do consolidacao = Consolidacao.create!( nome: "Avulsa nf#{entrega.numero_nf}", data_inicio: data_ref, data_fim: data_ref.end_of_month, created_by: current_user.id ) consolidacao.adicionar_apontamento!(entrega, Array(params[:tipos]).reject(&:blank?), current_user) end auditar!(:criar, consolidacao, dados_novos: { avulsa_nf: entrega.numero_nf, motorista: entrega.driver, tipos: params[:tipos] }) redirect_to consolidacao_path(consolidacao), notice: "Consolidação avulsa criada para #{entrega.driver} (NF #{entrega.numero_nf})." rescue ArgumentError => e redirect_to consolidacoes_path, alert: e.message rescue ActiveRecord::RecordInvalid => e redirect_to consolidacoes_path, alert: "Não foi possível criar a consolidação avulsa: #{e.message}" end # GET /consolidacoes/:id — visão geral def show authorize @consolidacao @motoristas = @consolidacao.consolidacao_motoristas.order(:motorista_nome) # Apontamentos manuais agrupados por nota (tracking) p/ exibição e remoção. @apontamentos = @consolidacao.consolidacao_entregas.apontamentos .order(:motorista_nome, :tracking_id) .group_by { |ce| [ce.motorista_nome, ce.tracking_id] } end # GET /consolidacoes/:id/wizard — PASSO 1: selecionar motorista def wizard authorize @consolidacao, :update? @motoristas = @consolidacao.consolidacao_motoristas.order(:motorista_nome).map do |cm| total = @consolidacao.entregas_elegiveis_count(cm.motorista_nome) classificadas = @consolidacao.classificadas_count(cm.motorista_nome) apontamentos = @consolidacao.consolidacao_entregas .apontamentos .where(motorista_nome: cm.motorista_nome) .distinct.count(:tracking_id) { registro: cm, total: total, classificadas: classificadas, apontamentos: apontamentos, # Espelha o gate do servidor (todas_entregas_classificadas?): um motorista # só com apontamento manual tem 0 entregas elegíveis e já está "completo". completo: total.zero? || classificadas >= total } end end # POST /consolidacoes/:id/finalizar def finalizar authorize @consolidacao, :update? if @consolidacao.finalizar!(current_user) auditar!(:finalizar, @consolidacao) NotificacaoService.notificar_finalizacao(@consolidacao) # WhatsApp/email aos motoristas redirect_to @consolidacao, notice: 'Consolidação finalizada! Motoristas notificados.' else redirect_to wizard_consolidacao_path(@consolidacao), alert: 'Não é possível finalizar: existem entregas não classificadas.' end end # POST /consolidacoes/:id/arquivar def arquivar authorize @consolidacao, :destroy? @consolidacao.arquivar!(current_user) auditar!(:arquivar, @consolidacao) redirect_to consolidacoes_path, notice: 'Consolidação arquivada.' end # GET /consolidacoes/arquivadas — histórico de consolidações arquivadas def arquivadas authorize Consolidacao @consolidacoes = Consolidacao.arquivadas.recentes.includes(:criador, :arquivador) end # POST /consolidacoes/:id/reativar — restaura uma consolidação arquivada def reativar authorize @consolidacao, :destroy? @consolidacao.reativar!(current_user) auditar!(:reativar, @consolidacao) redirect_to consolidacoes_path, notice: 'Consolidação reativada.' end # DELETE /consolidacoes/:id — exclui PERMANENTEMENTE (apenas arquivadas) def destroy authorize @consolidacao, :destroy? @consolidacao.destroy! auditar!(:excluir, @consolidacao) redirect_to arquivadas_consolidacoes_path, notice: 'Consolidação excluída permanentemente.' end # POST /consolidacoes/:id/registrar_pagamento # Marca 1 motorista (param consolidacao_motorista_id) ou TODOS os pendentes. def registrar_pagamento authorize @consolidacao, :registrar_pagamento? return pagamento_indisponivel unless @consolidacao.finalizada? forma = params[:forma_pagamento].presence_in(FORMAS_PAGAMENTO) || 'pix' alvos = motoristas_alvo.select { |cm| !cm.pago? } # NF obrigatória por motorista. Se faltar para qualquer alvo, aborta sem # marcar nenhum (a NF vai pro painel do motorista e pro Extrato). notas = nf_por_motorista(alvos) if notas.nil? return redirect_to @consolidacao, alert: 'Informe o número da Nota Fiscal de cada motorista antes de marcar como pago.' end alvos.each do |cm| cm.marcar_pago!(current_user, forma: forma, nota_fiscal: notas[cm.id]) NotificacaoService.notificar_pagamento(@consolidacao, cm) end auditar!(:registrar_pagamento, @consolidacao, dados_novos: { motoristas: alvos.map(&:motorista_nome), forma: forma, notas_fiscais: alvos.map { |cm| [cm.motorista_nome, notas[cm.id]] }.to_h }) redirect_to @consolidacao, notice: pagamento_notice(alvos.size, :pago) end # POST /consolidacoes/:id/cancelar_pagamento — estorna 1 motorista ou TODOS. def cancelar_pagamento authorize @consolidacao, :cancelar_pagamento? alvos = motoristas_alvo.select(&:pago?) alvos.each { |cm| cm.cancelar_pagamento!(current_user) } auditar!(:cancelar_pagamento, @consolidacao, dados_novos: { motoristas: alvos.map(&:motorista_nome) }) redirect_to @consolidacao, notice: pagamento_notice(alvos.size, :estornado) end # ── FASE 7 — PDFs ──────────────────────────────────────────── # GET /consolidacoes/:id/gerar_pdf_relatorio?motorista=X def gerar_pdf_relatorio authorize @consolidacao, :show? motorista = params[:motorista] pdf = Pdf::RelatorioMotoristaPdf.new(consolidacao: @consolidacao, motorista: motorista) auditar!(:editar, @consolidacao, dados_novos: { pdf: 'relatorio', motorista: motorista }) send_data pdf.render, filename: "relatorio_#{motorista.parameterize}_#{@consolidacao.id}.pdf", type: 'application/pdf', disposition: 'attachment' end # GET /consolidacoes/:id/gerar_pdf_financeiro — relatório financeiro da consolidação def gerar_pdf_financeiro authorize @consolidacao, :show? pdf = Pdf::RelatorioFinanceiroConsolidacaoPdf.new(consolidacao: @consolidacao) auditar!(:editar, @consolidacao, dados_novos: { pdf: 'financeiro' }) send_data pdf.render, filename: "financeiro_#{@consolidacao.nome.parameterize}_#{@consolidacao.id}.pdf", type: 'application/pdf', disposition: 'attachment' end # GET /consolidacoes/:id/gerar_pdf_extrato?motorista=X def gerar_pdf_extrato autorizar_extrato!(params[:motorista]) motorista = params[:motorista] user_mot = buscar_user_motorista(motorista) user_mot&.regenerar_login_token! if user_mot&.login_token.blank? cm = @consolidacao.consolidacao_motoristas.find_by(motorista_nome: motorista) pdf = Pdf::ExtratoPdf.new(consolidacao: @consolidacao, motorista: motorista, user_motorista: user_mot, app_host: ENV['APP_HOST'], pago_em: cm&.pago_em, forma_pagamento: cm&.forma_pagamento, nota_fiscal: cm&.nota_fiscal) auditar!(:editar, @consolidacao, dados_novos: { pdf: 'extrato', motorista: motorista }) send_data pdf.render, filename: "extrato_#{motorista.parameterize}_#{@consolidacao.id}.pdf", type: 'application/pdf', disposition: 'attachment' end # GET /consolidacoes/:id/preview_extrato?motorista=X — preview HTML antes do PDF def preview_extrato authorize @consolidacao, :show? @motorista = params[:motorista] @entregas = @consolidacao.consolidacao_entregas.where(motorista_nome: @motorista) @resumo = @entregas.group(:tipo).count @total = @entregas.sum { |e| e.desconto? ? -e.valor_aplicado : e.valor_aplicado } render layout: false # renderiza dentro do modal (Turbo Frame) end private # Acha o User motorista pelo nome para gerar o QR Code de acesso. O # motorista_nome vem dos dados externos de entrega e o users.nome é digitado # no admin — então toleramos diferenças de caixa e de espaços (extras/duplos). # Sem isso, qualquer divergência deixava user_mot nil e o QR sumia do extrato. def buscar_user_motorista(nome) exato = User.motorista.find_by('LOWER(nome) = ?', nome.to_s.downcase.strip) return exato if exato alvo = normalizar_nome(nome) User.motorista.find { |u| normalizar_nome(u.nome) == alvo } end def normalizar_nome(nome) nome.to_s.strip.downcase.gsub(/\s+/, ' ') end # Staff (show?) baixa qualquer extrato. O motorista baixa só o PRÓPRIO — o PDF # já é filtrado pelo nome dele, então não há vazamento de dados de terceiros. def autorizar_extrato!(motorista_nome) if current_user.motorista? raise Pundit::NotAuthorizedError unless motorista_nome.to_s.casecmp?(current_user.nome.to_s) skip_authorization else authorize @consolidacao, :show? end end def set_consolidacao @consolidacao = Consolidacao.find(params[:id]) end # Um motorista específico (consolidacao_motorista_id) ou todos da consolidação. def motoristas_alvo if params[:consolidacao_motorista_id].present? [@consolidacao.consolidacao_motoristas.find(params[:consolidacao_motorista_id])] else @consolidacao.consolidacao_motoristas.to_a end end # Mapeia { cm.id => "numero da NF" } a partir de params[:notas_fiscais]. # Retorna nil se a NF de QUALQUER alvo estiver em branco (pagamento bloqueado). def nf_por_motorista(alvos) informadas = params[:notas_fiscais] || {} alvos.each_with_object({}) do |cm, mapa| nf = informadas[cm.id.to_s].to_s.strip return nil if nf.blank? mapa[cm.id] = nf end end def pagamento_indisponivel redirect_to @consolidacao, alert: 'Só é possível registrar pagamento de uma consolidação finalizada.' end def pagamento_notice(qtd, acao) return 'Nenhuma alteração: motoristas já estavam no estado solicitado.' if qtd.zero? verbo = acao == :pago ? 'marcado(s) como pago(s)' : 'com pagamento estornado' "#{qtd} motorista(s) #{verbo}." end 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: [], operacoes: []) end end