# 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 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 @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 # 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 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 linhas.first(LIMITE) end def truncada? total > LIMITE end 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)] 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 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_EXTRA.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