diff --git a/app/controllers/admin/romaneios_controller.rb b/app/controllers/admin/romaneios_controller.rb index 8b82072..4505b86 100644 --- a/app/controllers/admin/romaneios_controller.rb +++ b/app/controllers/admin/romaneios_controller.rb @@ -29,6 +29,9 @@ class Admin::RomaneiosController < ApplicationController @linhas = @romaneio.linhas_vigentes.do_veiculo(@veiculo).to_a if @veiculo @removidas = @romaneio.linhas_removidas.to_a @colunas_ausentes = colunas_ausentes_da_operacao + # Mesma lista da tela de criação: o vínculo manual da operação acontece aqui + # também, porque é aqui que se descobre que APARELHO saiu vazio. + @operacoes_agrupadas = Operacao.agrupadas_por_mes end # Caminho A: busca o plano no SimpliRoute (API, com queda para o espelho). @@ -131,6 +134,39 @@ class Admin::RomaneiosController < ApplicationController redirect_to admin_romaneio_path(@romaneio, veiculo: params[:veiculo]), alert: e.message end + # Vincula a operação do mês à mão e preenche APARELHO/TELEFONES/NOME a partir + # dela. É a saída para quando o casamento pelo nome do plano não acontece — sem + # isso o único caminho era reimportar, que depende da API responder ou de o + # operador ainda ter o .xlsx do plano. + def vincular_operacao + authorize @romaneio + + resultado = Romaneios::VinculadorDeOperacao.new(romaneio: @romaneio, + tabela: params[:operacao_tabela]).aplicar! + auditar!(:vincular_operacao, @romaneio, + dados_novos: resultado.to_h.merge(operacao_tabela: @romaneio.operacao_tabela)) + + # Sem a coluna STATUS a operação não sabe quem é NOVO: o vínculo deu certo e + # mesmo assim ninguém recebe aparelho. Dizer isso aqui evita a leitura de que + # "vinculei e continua vazio, logo o sistema está errado". + aviso = if resultado.colunas_ausentes.include?('status') + " Atenção: esta operação não tem a coluna STATUS, então APARELHO continua vazio e precisa ser preenchido à mão." + else + '' + end + + redirect_to admin_romaneio_path(@romaneio, veiculo: params[:veiculo]), + notice: "Operação #{@romaneio.operacao_label} vinculada — #{resultado.resumo}.#{aviso}" + rescue ArgumentError + redirect_to admin_romaneio_path(@romaneio, veiculo: params[:veiculo]), + alert: 'Selecione uma operação válida.' + rescue Romaneios::VinculadorDeOperacao::Conflito => e + redirect_to admin_romaneio_path(@romaneio, veiculo: params[:veiculo]), + alert: "#{e.message} Abra o romaneio de " \ + "#{l e.existente.planned_date, format: '%d/%m/%Y'} (#{e.existente.rotulo_plano.presence || 'sem rótulo'}) " \ + 'em vez de duplicar o documento do dia.' + end + # Com ?veiculo= sai INLINE (é a prévia do editor, uma folha só, sub-100ms); # sem veiculo sai o romaneio inteiro como download. def pdf diff --git a/app/policies/romaneio_policy.rb b/app/policies/romaneio_policy.rb index b80c234..52161bf 100644 --- a/app/policies/romaneio_policy.rb +++ b/app/policies/romaneio_policy.rb @@ -10,8 +10,9 @@ class RomaneioPolicy < ApplicationPolicy def create? = index? def destroy? = index? - def importar_planilha? = index? - def reimportar? = index? + def importar_planilha? = index? + def reimportar? = index? + def vincular_operacao? = index? def pdf? = index? def atualizar_linha? = index? def atualizar_veiculo? = index? diff --git a/app/services/romaneios/vinculador_de_operacao.rb b/app/services/romaneios/vinculador_de_operacao.rb new file mode 100644 index 0000000..6569ba9 --- /dev/null +++ b/app/services/romaneios/vinculador_de_operacao.rb @@ -0,0 +1,121 @@ +# app/services/romaneios/vinculador_de_operacao.rb +# +# Vincula (ou troca) a operação do mês de um romaneio JÁ IMPORTADO e preenche +# APARELHO/TELEFONES/NOME a partir da gade_entregas_* — sem rebuscar o plano. +# +# ── POR QUE EXISTE ────────────────────────────────────────────────────────── +# Quem diz que o paciente é NOVO é a operação do mês, e NOVO é exatamente quem +# recebe aparelho. Até aqui só havia dois jeitos de vincular, os dois na hora da +# importação: escolher a operação no formulário ou deixar o +# Romaneios::CasadorDeOperacao deduzir pelo nome do plano. Quando o casamento não +# acontece (nome fora do padrão, empate proposital, operação subida no banco +# DEPOIS da importação), o romaneio ficava preso sem operação: a coluna APARELHO +# saía vazia para todos e a única saída era reimportar o plano — que depende da +# API do SimpliRoute responder ou de o operador ainda ter o .xlsx em mãos. +# +# Este serviço é o caminho manual: escolhida a operação, ele lê as NFs que já +# estão gravadas e aplica o que a operação sabe, mantendo a MESMA precedência do +# Romaneios::Importador (operação > plano) e o MESMO respeito a edição humana — +# campo que alguém digitou na tela não é sobrescrito. +# +# A tabela continua SOMENTE LEITURA: quem fala com ela é o EnriquecimentoGade +# (SELECT com Operacao.valida? + quote_table_name). +module Romaneios + class VinculadorDeOperacao + # Já existe outro romaneio para a mesma data com a operação escolhida. Não é + # erro de programação: é a chave única (planned_date, operacao_tabela) + # avisando que o operador está prestes a criar um segundo documento do mesmo + # dia — a tela precisa dizer isso com o link do outro, não estourar 500. + class Conflito < StandardError + attr_reader :existente + + def initialize(existente) + @existente = existente + super('Já existe um romaneio desta data para essa operação.') + end + end + + Resultado = Struct.new(:com_aparelho, :sem_correspondencia, :preservados, + :total, :colunas_ausentes, keyword_init: true) do + def resumo + partes = ["#{com_aparelho} com aparelho (NOVOS) de #{total} parada(s)"] + partes << "#{sem_correspondencia} sem correspondência na operação" if sem_correspondencia.to_i.positive? + partes << "#{preservados} campo(s) mantido(s) por edição" if preservados.to_i.positive? + partes.join(', ') + end + end + + def initialize(romaneio:, tabela:) + @romaneio = romaneio + @tabela = tabela.to_s + end + + # Devolve um Resultado. Levanta ArgumentError (operação inexistente) ou + # Conflito (chave única) — os dois viram mensagem na tela. + def aplicar! + raise ArgumentError, "Operação inválida: #{@tabela}" unless Operacao.valida?(@tabela) + + conflitante = Romaneio.where(planned_date: @romaneio.planned_date, operacao_tabela: @tabela) + .where.not(id: @romaneio.id).first + raise Conflito, conflitante if conflitante + + gade = EnriquecimentoGade.new(@tabela) + linhas = @romaneio.linhas_vigentes.to_a + dados = gade.para_notas(linhas.map(&:nota_fiscal)) + + resultado = nil + # Transação: vincular sem enriquecer deixaria a tela dizendo que a operação + # está ligada com a coluna APARELHO ainda vazia — o pior dos dois mundos, + # porque some o aviso que hoje denuncia o problema. + Romaneio.transaction do + @romaneio.update!(operacao_tabela: @tabela) + resultado = enriquecer(linhas, dados) + end + + resultado.colunas_ausentes = gade.colunas_ausentes + resultado + end + + private + + def enriquecer(linhas, dados) + com_aparelho = sem_correspondencia = preservados = 0 + + linhas.each do |linha| + extra = dados[linha.nota_fiscal.to_s] + sem_correspondencia += 1 if extra.nil? + extra ||= {} + + # `origem` é o que a linha vale SEM edição humana — é para onde o campo + # volta quando o operador limpa a edição na tela. Ele passa a refletir a + # operação recém-vinculada; sem isso, "restaurar" devolveria o valor de + # antes do vínculo e o aparelho sumiria de novo. + origem = linha.origem.to_h + novos = { + 'aparelho' => extra[:aparelho].to_s, + # Mesma precedência do Importador: a coluna `telefones` da operação é a + # fonte completa; o "TEL:" das anotações do plano só entra quando ela + # não tem número. Idem para o nome. + 'telefones' => extra[:telefones].to_s.presence || origem['telefones'].to_s, + 'nome' => extra[:nome].to_s.presence || origem['nome'].to_s + } + + novos.each do |campo, valor| + if linha.editado?(campo) + preservados += 1 + next + end + linha[campo] = valor + end + + linha.origem = origem.merge(novos) + linha.save! + + com_aparelho += 1 if linha.aparelho.to_s.strip.present? + end + + Resultado.new(com_aparelho: com_aparelho, sem_correspondencia: sem_correspondencia, + preservados: preservados, total: linhas.size) + end + end +end diff --git a/app/views/admin/romaneios/_avisos.html.erb b/app/views/admin/romaneios/_avisos.html.erb index 9f10cdc..7b5580b 100644 --- a/app/views/admin/romaneios/_avisos.html.erb +++ b/app/views/admin/romaneios/_avisos.html.erb @@ -30,6 +30,8 @@

Este romaneio foi importado sem operação vinculada: a coluna APARELHO não é preenchida sozinha e o telefone vem só das anotações do plano. + Escolha a operação do mês em “Vincular operação”, logo acima — não + é preciso reimportar o plano.

<% end %> diff --git a/app/views/admin/romaneios/_operacao.html.erb b/app/views/admin/romaneios/_operacao.html.erb new file mode 100644 index 0000000..e8f9a55 --- /dev/null +++ b/app/views/admin/romaneios/_operacao.html.erb @@ -0,0 +1,76 @@ +<%# app/views/admin/romaneios/_operacao.html.erb + Vínculo MANUAL da operação do mês, feito na própria tela do romaneio. + + Por que aqui e não só na criação: é aqui que o operador descobre que a coluna + APARELHO saiu vazia. Mandar ele reimportar o plano (única saída até então) + depende da API do SimpliRoute responder ou de o .xlsx ainda estar em mãos. + + Sem JavaScript de propósito:
nativo + form comum. Este controle é o + conserto de um romaneio que já está errado — precisa funcionar mesmo quando o + Stimulus não carrega (foi exatamente esse o caso em 28/08/2026, com os assets + dando 404 no servidor e a tela inteira sem JS). %> +<% vinculada = @romaneio.operacao_tabela.present? %> + +<%# O cabeçalho e o formulário ficam EMPILHADOS, não lado a lado: aberto, o + seletor + botão precisam da largura toda da caixa; espremidos numa coluna + lateral o rótulo do estado saía cortado justamente quando ele é a informação + que importa ("Nenhuma vinculada"). %> +
+
+

Operação do mês

+

+ <%= vinculada ? @romaneio.operacao_label : 'Nenhuma vinculada — APARELHO não é preenchido sozinho' %> +

+

+ É ela que diz quem é paciente NOVO, e quem é novo sempre leva aparelho. +

+
+ + <%# Já vinculada: fica fechado, para não convidar a trocar sem motivo. Sem + operação: abre sozinho, porque é a pendência da tela. %> + <%# group-open: aberto, o gatilho fica discreto. Aberto ele vira só o título do + painel — deixá-lo laranja poria DOIS botões laranja um sobre o outro e o + operador não saberia qual confirma. %> +
> + + <%# cor: nil e espaco: false — o fundo laranja apagaria o laranja da marca + e o container já tem gap-2. %> + <%= icone :operacao, cor: nil, espaco: false %> + <%= vinculada ? 'Trocar operação' : 'Vincular operação' %> + + + <%= form_with url: vincular_operacao_admin_romaneio_path(@romaneio), method: :patch, + class: 'mt-3 flex flex-col sm:flex-row gap-2 sm:items-center' do %> + + + + <% end %> + +

+ Preenche APARELHO, TELEFONES e NOME pelas notas fiscais já importadas. + O que você digitou na tabela é mantido — só os campos sem edição são + atualizados. +

+
+
diff --git a/app/views/admin/romaneios/show.html.erb b/app/views/admin/romaneios/show.html.erb index 1804a49..d2b9bae 100644 --- a/app/views/admin/romaneios/show.html.erb +++ b/app/views/admin/romaneios/show.html.erb @@ -66,6 +66,7 @@

Clique em “Reimportar plano” ou envie a planilha do plano na tela anterior.

<% else %> + <%= render 'operacao' %> <%= render 'avisos' %> <%# Duas zonas na tela: VEÍCULOS e a edição. A prévia saiu do fluxo e virou diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb index 18b88ed..62e5ba9 100644 --- a/app/views/dashboard/index.html.erb +++ b/app/views/dashboard/index.html.erb @@ -436,9 +436,11 @@ - -