341 lines
14 KiB
Ruby
341 lines
14 KiB
Ruby
# app/controllers/admin/romaneios_controller.rb
|
|
#
|
|
# Romaneio "CONTROLE DE ENTREGA": importa o plano do dia, deixa o operador revisar a
|
|
# tabela com a prévia do PDF real ao lado, e imprime uma folha A4 paisagem por
|
|
# veículo.
|
|
#
|
|
# As bases externas (espelho de rastreio e gade_entregas_*) continuam SOMENTE
|
|
# LEITURA — o que gravamos são as tabelas `romaneio*`, nossas.
|
|
class Admin::RomaneiosController < ApplicationController
|
|
include Auditavel
|
|
|
|
before_action :carregar_romaneio, except: %i[index create importar_planilha]
|
|
|
|
def index
|
|
authorize Romaneio
|
|
# includes: a listagem mostra a contagem de veículos de cada romaneio.
|
|
@romaneios = Romaneio.recentes.includes(:romaneio_veiculos).limit(50)
|
|
@operacoes_agrupadas = Operacao.agrupadas_por_mes
|
|
@data_padrao = Date.current
|
|
# Nome ORIGINAL do logo escolhido (a descricao), para a tela mostrar o mesmo
|
|
# que o operador enviou; nil = ainda usando o logo da GADE do repositório.
|
|
@logo_atual = Configuracao.find_by(chave: 'romaneio_logo')&.descricao
|
|
end
|
|
|
|
def show
|
|
authorize @romaneio
|
|
@veiculos = @romaneio.romaneio_veiculos.ordenados.to_a
|
|
@veiculo = veiculo_selecionado
|
|
@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).
|
|
def create
|
|
authorize Romaneio
|
|
@romaneio = preparar_romaneio
|
|
return if performed?
|
|
|
|
plano = Romaneios::PlanoDoDia.new(data: @romaneio.planned_date)
|
|
importar(plano.linhas, origem: plano.origem)
|
|
rescue SimpliRoute::Error => e
|
|
# NotFound herda de Error: a mensagem dele já diz para enviar o .xlsx.
|
|
descartar_romaneio_vazio
|
|
redirect_to admin_romaneios_path, alert: e.message
|
|
end
|
|
|
|
# Caminho B: upload do .xlsx do plano, para quando o plano do dia ainda não foi
|
|
# publicado ou a API não responde.
|
|
def importar_planilha
|
|
authorize Romaneio
|
|
@romaneio = preparar_romaneio
|
|
return if performed?
|
|
|
|
arquivo = params[:arquivo]
|
|
if arquivo.blank?
|
|
descartar_romaneio_vazio
|
|
return redirect_to(admin_romaneios_path, alert: 'Selecione a planilha do plano (.xlsx).')
|
|
end
|
|
|
|
importar(Romaneios::PlanoPlanilha.new(arquivo).linhas, origem: 'planilha')
|
|
rescue Romaneios::PlanoPlanilha::Erro => e
|
|
descartar_romaneio_vazio
|
|
redirect_to admin_romaneios_path, alert: e.message
|
|
end
|
|
|
|
# ── Logo do cabeçalho ──────────────────────────────────────────────────────
|
|
# O romaneio é documento do CLIENTE, não da Reem: quando a operação muda de
|
|
# contratante, trocar o logo não pode exigir deploy.
|
|
#
|
|
# PNG/JPG apenas porque é o que o Prawn embute — um SVG aceito aqui sairia como
|
|
# folha SEM logo, sem erro nenhum, e ninguém descobriria antes de imprimir.
|
|
LOGO_TIPOS = { 'image/png' => '.png', 'image/jpeg' => '.jpg' }.freeze
|
|
LOGO_TAMANHO_MAX = 2.megabytes
|
|
|
|
def atualizar_logo
|
|
authorize Romaneio
|
|
|
|
arquivo = params[:logo]
|
|
return redirect_to(admin_romaneios_path, alert: 'Escolha o arquivo do logo (PNG ou JPG).') if arquivo.blank?
|
|
|
|
extensao = LOGO_TIPOS[arquivo.content_type.to_s]
|
|
if extensao.nil?
|
|
return redirect_to(admin_romaneios_path,
|
|
alert: 'O logo precisa ser PNG ou JPG — é o que o PDF consegue embutir.')
|
|
end
|
|
if arquivo.size > LOGO_TAMANHO_MAX
|
|
return redirect_to(admin_romaneios_path,
|
|
alert: 'Logo acima de 2 MB. Reduza a imagem antes de enviar.')
|
|
end
|
|
|
|
# Nome GERADO por nós: o nome que vem do navegador é entrada do usuário e não
|
|
# entra em caminho de arquivo. O original fica só na descrição, para a tela
|
|
# mostrar "Logo: LOGOGADE.png" como o operador reconhece.
|
|
nome = "logo-#{Time.current.strftime('%Y%m%d%H%M%S')}#{extensao}"
|
|
destino = Pdf::RomaneioPdf.pasta_logos
|
|
FileUtils.mkdir_p(destino)
|
|
File.binwrite(destino.join(nome), arquivo.read)
|
|
|
|
config = Configuracao.find_or_initialize_by(chave: 'romaneio_logo')
|
|
config.update!(valor: nome, descricao: arquivo.original_filename.to_s.first(120),
|
|
updated_by: current_user&.id)
|
|
|
|
redirect_to admin_romaneios_path,
|
|
notice: "Logo atualizado: #{arquivo.original_filename}. Vale para os próximos PDFs."
|
|
end
|
|
|
|
# Rebusca o plano e reconcilia com o que já está editado (a regra está em
|
|
# Romaneios::Importador).
|
|
def reimportar
|
|
authorize @romaneio
|
|
|
|
# Reimportar com arquivo em mãos usa o arquivo; sem arquivo, volta ao
|
|
# SimpliRoute — inclusive para um romaneio que nasceu de planilha (é justamente
|
|
# assim que o operador troca o plano B pelo A quando a API volta).
|
|
if params[:arquivo].present?
|
|
plano = Romaneios::PlanoPlanilha.new(params[:arquivo])
|
|
linhas = plano.linhas
|
|
origem = 'planilha'
|
|
else
|
|
plano = Romaneios::PlanoDoDia.new(data: @romaneio.planned_date)
|
|
linhas = plano.linhas
|
|
origem = plano.origem
|
|
end
|
|
|
|
resultado = aplicar(linhas, origem: origem)
|
|
auditar!(:reimportar, @romaneio, dados_novos: resultado.to_h)
|
|
redirect_to admin_romaneio_path(@romaneio, veiculo: params[:veiculo]),
|
|
notice: "Plano reimportado — #{resultado.resumo}."
|
|
rescue SimpliRoute::Error, Romaneios::PlanoPlanilha::Erro => e
|
|
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
|
|
authorize @romaneio
|
|
|
|
veiculo = params[:veiculo].to_s
|
|
binario = Pdf::RomaneioPdf.new(romaneio: @romaneio,
|
|
veiculos: veiculo.presence && [veiculo]).render
|
|
|
|
# A prévia é regerada a cada edição: cache do navegador aqui devolveria o PDF
|
|
# anterior e o operador acharia que a alteração não gravou.
|
|
expires_now if veiculo.present?
|
|
|
|
send_data binario, type: 'application/pdf',
|
|
disposition: veiculo.present? ? 'inline' : 'attachment',
|
|
filename: nome_do_arquivo(veiculo)
|
|
end
|
|
|
|
# JSON — grava UM campo de UMA parada. `valor: null` restaura o que o plano
|
|
# trouxe (e destrava o campo para a próxima reimportação).
|
|
def atualizar_linha
|
|
authorize @romaneio
|
|
|
|
linha = @romaneio.romaneio_linhas.find(params[:linha_id])
|
|
campo = params[:campo].to_s
|
|
unless RomaneioLinha::CAMPOS_EDITAVEIS.include?(campo)
|
|
return render json: { ok: false, erro: 'Campo não editável.' }, status: :unprocessable_entity
|
|
end
|
|
|
|
if params[:valor].nil?
|
|
linha[campo] = linha.valor_do_plano(campo)
|
|
linha.desmarcar_editado!(campo)
|
|
else
|
|
linha[campo] = params[:valor].to_s
|
|
linha.marcar_editado!(campo)
|
|
end
|
|
linha.save!
|
|
|
|
# Devolvemos o valor GRAVADO e o JS o reescreve no input: a gravação é
|
|
# last-write-wins, e assim uma sobreposição de duas abas fica visível em vez
|
|
# de silenciosa.
|
|
render json: { ok: true, valor: linha[campo], editado: linha.editado?(campo) }
|
|
end
|
|
|
|
def atualizar_veiculo
|
|
authorize @romaneio
|
|
|
|
registro = @romaneio.romaneio_veiculos.find_by!(veiculo: params[:veiculo].to_s)
|
|
registro.update!(motorista: params[:motorista].to_s)
|
|
render json: { ok: true, valor: registro.motorista }
|
|
end
|
|
|
|
def atualizar_rotulo
|
|
authorize @romaneio
|
|
|
|
@romaneio.update!(rotulo_plano: params[:rotulo_plano].to_s)
|
|
render json: { ok: true, valor: @romaneio.rotulo_plano }
|
|
end
|
|
|
|
def destroy
|
|
authorize @romaneio
|
|
|
|
# Auditar ANTES: depois do destroy não há mais atributos para registrar.
|
|
auditar!(:excluir, @romaneio, dados_anteriores: @romaneio.attributes)
|
|
@romaneio.destroy!
|
|
redirect_to admin_romaneios_path, notice: 'Romaneio excluído.'
|
|
end
|
|
|
|
private
|
|
|
|
def carregar_romaneio
|
|
@romaneio = Romaneio.find(params[:id])
|
|
end
|
|
|
|
# Quais colunas a operação NÃO tem. Perguntamos ao serviço em vez de deduzir de
|
|
# "a coluna APARELHO saiu toda vazia": vazio também é o resultado legítimo de uma
|
|
# operação só com pacientes recorrentes, e um aviso que erra o motivo é pior do
|
|
# que nenhum.
|
|
def colunas_ausentes_da_operacao
|
|
return [] if @romaneio.operacao_tabela.blank?
|
|
|
|
Romaneios::EnriquecimentoGade.new(@romaneio.operacao_tabela).colunas_ausentes
|
|
rescue ArgumentError
|
|
# Operação removida do banco depois que o romaneio foi criado.
|
|
Romaneios::EnriquecimentoGade::COLUNAS.keys
|
|
end
|
|
|
|
# Acha (ou cria) o romaneio da data + operação. `find_or_create_by!` é o que faz
|
|
# "importar de novo o mesmo dia" cair na reconciliação em vez de criar um segundo
|
|
# romaneio — a chave única (planned_date, operacao_tabela) é a garantia disso.
|
|
def preparar_romaneio
|
|
data = params[:planned_date].presence
|
|
if data.blank?
|
|
redirect_to admin_romaneios_path, alert: 'Informe a data do plano.'
|
|
return nil
|
|
end
|
|
|
|
tabela = params[:operacao_tabela].to_s
|
|
if tabela.present? && !Operacao.valida?(tabela)
|
|
redirect_to admin_romaneios_path, alert: 'Selecione uma operação válida.'
|
|
return nil
|
|
end
|
|
|
|
# Sem operação escolhida, tenta deduzi-la do NOME DO PLANO ("ENTREGAS EMAD
|
|
# 09.2026" → gade_entregas_emad_set_2026).
|
|
#
|
|
# POR QUE ISSO É IMPORTANTE E NÃO CONVENIÊNCIA: é a operação que diz quem é
|
|
# paciente NOVO. Importar sem ela produz um romaneio de aparência normal com a
|
|
# coluna APARELHO vazia para TODOS — o erro só aparece no papel, com aparelho
|
|
# não entregue. A operação não é campo opcional na prática; era só difícil de
|
|
# acertar, porque o nome do plano e o nome da tabela nunca são iguais.
|
|
#
|
|
# Empate/nenhum casamento devolve nil e segue sem operação: vincular a errada
|
|
# preencheria APARELHO e TELEFONE com dados de OUTRO mês, sem ninguém
|
|
# desconfiar. A tela avisa quando ficou sem.
|
|
@operacao_deduzida = nil
|
|
if tabela.blank? && params[:rotulo_plano].present?
|
|
tabela = Romaneios::CasadorDeOperacao.casar(params[:rotulo_plano]).to_s
|
|
@operacao_deduzida = tabela.presence
|
|
end
|
|
|
|
data = Date.parse(data)
|
|
Romaneio.find_or_create_by!(planned_date: data, operacao_tabela: tabela) do |r|
|
|
r.rotulo_plano = params[:rotulo_plano].presence || Romaneio.rotulo_sugerido(tabela, data)
|
|
r.importado_por = current_user
|
|
end
|
|
rescue Date::Error
|
|
redirect_to admin_romaneios_path, alert: 'Data do plano inválida.'
|
|
nil
|
|
end
|
|
|
|
def importar(linhas, origem:)
|
|
resultado = aplicar(linhas, origem: origem)
|
|
auditar!(:criar, @romaneio, dados_novos: resultado.to_h)
|
|
|
|
# Dizer que a operação foi deduzida, e qual: dedução silenciosa que acerta é
|
|
# invisível, mas dedução silenciosa que erra é indefensável — o operador
|
|
# precisa ver de qual mês vieram os dados de APARELHO.
|
|
aviso = if @operacao_deduzida
|
|
" Operação #{Operacao.label(@operacao_deduzida)} vinculada pelo nome do plano."
|
|
else
|
|
''
|
|
end
|
|
|
|
redirect_to admin_romaneio_path(@romaneio),
|
|
notice: "Plano importado — #{resultado.resumo}.#{aviso}"
|
|
end
|
|
|
|
def aplicar(linhas, origem:)
|
|
Romaneios::Importador.new(romaneio: @romaneio, linhas: linhas,
|
|
usuario: current_user, origem: origem).aplicar!
|
|
end
|
|
|
|
# Um romaneio recém-criado que não chegou a receber nenhuma parada é lixo: some
|
|
# da lista para o operador não achar que a importação funcionou.
|
|
def descartar_romaneio_vazio
|
|
@romaneio&.destroy! if @romaneio&.persisted? && @romaneio.romaneio_linhas.none?
|
|
end
|
|
|
|
def veiculo_selecionado
|
|
pedido = params[:veiculo].to_s
|
|
return pedido if @veiculos.any? { |v| v.veiculo == pedido }
|
|
|
|
@veiculos.first&.veiculo
|
|
end
|
|
|
|
def nome_do_arquivo(veiculo)
|
|
partes = ['romaneio', @romaneio.rotulo_plano.presence || @romaneio.planned_date.to_s, veiculo]
|
|
"#{partes.compact_blank.join('_').parameterize(separator: '_')}.pdf"
|
|
end
|
|
end
|