Correção de horario e implantação da geração de Romaneio
This commit is contained in:
@@ -9,6 +9,13 @@
|
||||
# Use `development` só na sua máquina local.
|
||||
RAILS_ENV=production
|
||||
|
||||
# Fuso horário do sistema operacional dentro dos containers.
|
||||
# Já é o padrão no Dockerfile — só mexa se a operação mudar de estado.
|
||||
# ⚠️ Isto define o FUSO, não a HORA. Se a data/hora do servidor está errada
|
||||
# (atrasa, ou volta errada depois de reboot/deploy), o problema é o relógio
|
||||
# do HOST: rode uma vez `sudo bash deploy/ntp-seguro.sh`.
|
||||
TZ=America/Sao_Paulo
|
||||
|
||||
# ── Banco de dados (seu PostgreSQL já existente) ─────────────
|
||||
# Aponte DB_HOST para o IP ou hostname do seu servidor PostgreSQL
|
||||
DATABASE_URL=postgresql://postgres:senha_segura@SEU_IP:5432/logistica_db
|
||||
|
||||
23
Dockerfile
23
Dockerfile
@@ -1,6 +1,22 @@
|
||||
FROM ruby:3.2.2-slim
|
||||
|
||||
# Fuso horário do container.
|
||||
#
|
||||
# POR QUE ISSO IMPORTA (caso real): a imagem sobe em UTC. O Rails até mostra a
|
||||
# hora certa (`config.time_zone = "America/Sao_Paulo"`), mas TUDO que é do
|
||||
# sistema operacional continuava 3h adiantado — timestamp de log, `date` nos
|
||||
# scripts de bin/ e, o pior, o CRON: `every "*/30 8-18"` no config/schedule.rb
|
||||
# rodava das 05h às 15h de Brasília, não das 08h às 18h.
|
||||
#
|
||||
# O cron do Debian lê `/etc/localtime`, não a variável TZ — por isso o symlink
|
||||
# além do ENV. Os dois juntos cobrem processo Ruby e daemon de cron.
|
||||
ENV TZ=America/Sao_Paulo
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Dependências do sistema
|
||||
# tzdata: explícito de propósito. O zoneinfo já vem na imagem hoje, mas se um
|
||||
# rebuild futuro pegar uma base enxuta o symlink acima quebra em silêncio e a
|
||||
# hora volta a ficar errada — que é exatamente o bug que estamos fechando.
|
||||
RUN apt-get update -qq && apt-get install -y \
|
||||
build-essential \
|
||||
libpq-dev \
|
||||
@@ -10,6 +26,9 @@ RUN apt-get update -qq && apt-get install -y \
|
||||
git \
|
||||
libvips \
|
||||
cron \
|
||||
tzdata \
|
||||
&& ln -snf "/usr/share/zoneinfo/$TZ" /etc/localtime \
|
||||
&& echo "$TZ" > /etc/timezone \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Diretório da app
|
||||
@@ -27,4 +46,6 @@ COPY . .
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0"]
|
||||
# O boot real (migrations, cron, servidor) mora em bin/docker-boot — ver os
|
||||
# comentários lá. CMD aqui é só o fallback de quem roda a imagem sem o compose.
|
||||
CMD ["bin/docker-boot"]
|
||||
|
||||
9
Gemfile
9
Gemfile
@@ -29,6 +29,11 @@ gem "prawn-table"
|
||||
|
||||
# Planilhas (XLSX) — geração da carga de importação do SimpliRoute
|
||||
gem "caxlsx"
|
||||
# Leitura do .xlsx do PLANO do SimpliRoute (reserva do romaneio, quando a API não
|
||||
# responde ou o plano do dia ainda não foi publicado). A roo pina rubyzip < 3.0;
|
||||
# o único outro consumidor é o selenium-webdriver (grupo de teste, aceita >= 1.2.2,
|
||||
# < 4.0), então o bundler rebaixa o rubyzip sem quebrar nada.
|
||||
gem "roo", "~> 2.10"
|
||||
gem "rqrcode" # QR code no holerite (login rápido motorista)
|
||||
gem "chunky_png" # renderiza QR como PNG para embed no Prawn
|
||||
|
||||
@@ -47,6 +52,10 @@ group :development, :test do
|
||||
gem "rspec-rails"
|
||||
gem "factory_bot_rails"
|
||||
gem "faker"
|
||||
# Lê o PDF gerado (páginas e texto) nos specs. Sem ele, o único jeito de testar
|
||||
# um PDF do Prawn seria olhar o arquivo: as fontes TTF são subsetadas, então o
|
||||
# texto não é "grepável" no binário.
|
||||
gem "pdf-inspector", require: false
|
||||
end
|
||||
|
||||
group :development do
|
||||
|
||||
24
Gemfile.lock
24
Gemfile.lock
@@ -1,6 +1,7 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
Ascii85 (2.0.1)
|
||||
actioncable (7.1.6)
|
||||
actionpack (= 7.1.6)
|
||||
activesupport (= 7.1.6)
|
||||
@@ -82,6 +83,7 @@ GEM
|
||||
tzinfo (~> 2.0)
|
||||
addressable (2.9.0)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
afm (1.0.0)
|
||||
annotate (3.2.0)
|
||||
activerecord (>= 3.2, < 8.0)
|
||||
rake (>= 10.4, < 14.0)
|
||||
@@ -100,6 +102,11 @@ GEM
|
||||
rack-test (>= 0.6.3)
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
caxlsx (4.5.0)
|
||||
htmlentities (~> 4.3, >= 4.3.4)
|
||||
marcel (~> 1.0)
|
||||
nokogiri (~> 1.10, >= 1.10.4)
|
||||
rubyzip (>= 2.4, < 4)
|
||||
cgi (0.5.1)
|
||||
chronic (0.10.2)
|
||||
chunky_png (1.4.0)
|
||||
@@ -139,6 +146,8 @@ GEM
|
||||
net-http (~> 0.5)
|
||||
globalid (1.3.0)
|
||||
activesupport (>= 6.1)
|
||||
hashery (2.1.2)
|
||||
htmlentities (4.4.2)
|
||||
i18n (1.14.8)
|
||||
concurrent-ruby (~> 1.0)
|
||||
image_processing (2.0.2)
|
||||
@@ -192,6 +201,13 @@ GEM
|
||||
orm_adapter (0.5.0)
|
||||
pagy (9.4.0)
|
||||
pdf-core (0.10.0)
|
||||
pdf-inspector (1.3.0)
|
||||
pdf-reader (>= 1.0, < 3.0.a)
|
||||
pdf-reader (2.16.0)
|
||||
Ascii85 (>= 1.0, < 3.0, != 2.0.0)
|
||||
afm (>= 0.2.1, < 2)
|
||||
hashery (~> 2.0)
|
||||
ttfunk
|
||||
pg (1.6.3-x86_64-linux)
|
||||
pp (0.6.3)
|
||||
prettyprint
|
||||
@@ -269,6 +285,9 @@ GEM
|
||||
actionpack (>= 7.0)
|
||||
railties (>= 7.0)
|
||||
rexml (3.4.4)
|
||||
roo (2.10.1)
|
||||
nokogiri (~> 1)
|
||||
rubyzip (>= 1.3.0, < 3.0.0)
|
||||
rqrcode (3.2.0)
|
||||
chunky_png (~> 1.0)
|
||||
rqrcode_core (~> 2.0)
|
||||
@@ -290,7 +309,7 @@ GEM
|
||||
rspec-mocks (~> 3.13)
|
||||
rspec-support (~> 3.13)
|
||||
rspec-support (3.13.7)
|
||||
rubyzip (3.4.0)
|
||||
rubyzip (2.4.1)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.44.0)
|
||||
base64 (~> 0.2)
|
||||
@@ -344,6 +363,7 @@ PLATFORMS
|
||||
DEPENDENCIES
|
||||
annotate
|
||||
capybara
|
||||
caxlsx
|
||||
chunky_png
|
||||
debug
|
||||
devise
|
||||
@@ -354,6 +374,7 @@ DEPENDENCIES
|
||||
importmap-rails
|
||||
jbuilder
|
||||
pagy (~> 9.0)
|
||||
pdf-inspector
|
||||
pg (~> 1.1)
|
||||
prawn
|
||||
prawn-table
|
||||
@@ -362,6 +383,7 @@ DEPENDENCIES
|
||||
pundit
|
||||
rack-mini-profiler
|
||||
rails (~> 7.1.0)
|
||||
roo (~> 2.10)
|
||||
rqrcode
|
||||
rspec-rails
|
||||
selenium-webdriver
|
||||
|
||||
119
README.md
119
README.md
@@ -3068,3 +3068,122 @@ caminho do servidor e o trace inteiro — para **qualquer pessoa** que provocass
|
||||
celular, onde não há faixa lateral livre.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><strong>🕐 Relógio, restart automático e migrations no boot</strong></summary>
|
||||
|
||||
## O problema relatado
|
||||
|
||||
> "toda vez que faço o deploy ou reinicio o Docker a data e hora ficam erradas."
|
||||
|
||||
São **dois problemas diferentes** que se confundem porque aparecem no mesmo lugar:
|
||||
|
||||
| Sintoma | Causa | Onde se resolve |
|
||||
|---|---|---|
|
||||
| Hora **sempre 3h adiantada**, todo restart | O container subia em **UTC** | `Dockerfile` (fuso) |
|
||||
| Hora **derivando / errada depois de reboot** | Relógio do **host** sem sincronia | `deploy/ntp-seguro.sh` (NTS) |
|
||||
|
||||
### 1. Fuso do container (o "sempre 3h à frente")
|
||||
|
||||
A imagem `ruby:3.2.2-slim` sobe em UTC. O Rails até mostrava a hora certa
|
||||
(`config.time_zone = "America/Sao_Paulo"`), mas **tudo que era do sistema
|
||||
operacional continuava adiantado** — e o pior caso não era visual:
|
||||
|
||||
> **O cron rodava no horário errado.** `every "*/30 8-18"` no `config/schedule.rb`
|
||||
> executava das **05h às 15h** de Brasília, não das 08h às 18h.
|
||||
|
||||
Agora o `Dockerfile` fixa `TZ=America/Sao_Paulo` **e** o symlink de
|
||||
`/etc/localtime` — os dois, porque o cron do Debian lê o arquivo, não a
|
||||
variável. O mesmo foi feito no `whatsapp/Dockerfile` (Alpine precisa do pacote
|
||||
`tzdata`, que não vem na imagem). Dá para ajustar sem rebuild pelo `TZ` do `.env`.
|
||||
|
||||
### 2. Hora do host — NTP autenticado (NTS)
|
||||
|
||||
**Não dá para sincronizar o relógio de dentro do container**: ele lê o clock do
|
||||
kernel do host. Rodar NTP lá dentro exigiria `CAP_SYS_TIME` e mudaria a hora do
|
||||
servidor inteiro a partir de um processo da aplicação.
|
||||
|
||||
No servidor, uma vez:
|
||||
|
||||
```bash
|
||||
sudo bash deploy/ntp-seguro.sh
|
||||
```
|
||||
|
||||
O script instala e configura o **chrony com NTS** (RFC 8915): a troca de chaves
|
||||
é por TLS (TCP 4460) e os pacotes NTP vêm assinados, então uma resposta forjada
|
||||
no caminho é descartada. NTP comum é UDP sem autenticação — e hora errada aqui
|
||||
não é detalhe: ela muda **o dia da consolidação, o recorte do período financeiro
|
||||
e a validade da sessão**.
|
||||
|
||||
O script ainda: usa **3 fontes independentes** (Cloudflare, Netnod, PTB),
|
||||
**desliga as fontes não autenticadas** (deixar o `pool` padrão anularia o ganho),
|
||||
desativa o `systemd-timesyncd` (não fala NTS e brigaria pelo relógio), liga o
|
||||
`rtcsync` (é o relógio de hardware que dá a hora no boot) e mostra a
|
||||
conferência no fim. É idempotente e guarda `.bak` da config.
|
||||
|
||||
Conferir depois, a qualquer momento:
|
||||
|
||||
```bash
|
||||
chronyc tracking # 'System time' deve ficar na casa dos milissegundos
|
||||
chronyc -N authdata # NTS ativo em cada fonte (Cook > 0)
|
||||
```
|
||||
|
||||
> **Firewall:** precisa de saída em **UDP 123** e **TCP 4460**. Sem a 4460 o NTS
|
||||
> não fecha e o chrony fica sem fonte.
|
||||
|
||||
## Restart automático + migrations
|
||||
|
||||
`docker-compose.yml`, serviço `app`: **`restart: unless-stopped`**. Sobe sozinho
|
||||
depois de queda do processo e de reboot do servidor, e só fica parado se alguém
|
||||
der `docker compose stop/down`. (`always` foi descartado: ele reergue o container
|
||||
até depois de um `stop` deliberado, tirando do operador a chance de deixar o
|
||||
sistema fora do ar de propósito.)
|
||||
|
||||
O boot saiu do `command:` de uma linha só e virou **`bin/docker-boot`**, com o
|
||||
motivo de cada passo comentado:
|
||||
|
||||
1. **relógio** — imprime a hora e o fuso no log (se aparecer UTC, o rebuild não pegou);
|
||||
2. **pid órfão** — sem limpar, o Puma se recusa a subir depois de uma queda;
|
||||
3. **gems** — `bundle check || bundle install` (o volume `bundle_cache` sombreia os gems da imagem);
|
||||
4. **migrations** — `db:prepare`, **com espera pelo banco**;
|
||||
5. **cron** — `whenever --update-crontab` + daemon, *best-effort*;
|
||||
6. **Puma**.
|
||||
|
||||
Ou seja: **a migration é automática a todo restart** — `db:prepare` cria o banco
|
||||
se não existir, aplica as migrations pendentes e só faz seed em banco novo
|
||||
(idempotente). Não há passo manual depois do deploy.
|
||||
|
||||
> **Por que a espera no passo 4:** o PostgreSQL é externo ao compose. Num reboot
|
||||
> do servidor o Rails pode subir antes de o banco aceitar conexão — e agora, com
|
||||
> `restart: unless-stopped`, isso viraria um **loop de reinício** parecendo erro
|
||||
> de migration. São 10 tentativas × 6s. Se falhar depois disso (migration
|
||||
> quebrada, credencial errada), o container sai com erro **de propósito**: melhor
|
||||
> do que servir a aplicação contra um schema desatualizado.
|
||||
|
||||
## Deploy
|
||||
|
||||
```bash
|
||||
# 1. No servidor, uma única vez — relógio com NTP autenticado:
|
||||
sudo bash deploy/ntp-seguro.sh
|
||||
|
||||
# 2. Rebuild obrigatório (o fuso entra na imagem):
|
||||
docker compose up -d --build
|
||||
|
||||
# 3. Conferir no log que o container está em -03 e não em UTC:
|
||||
docker compose logs app | grep '\[boot\] relógio'
|
||||
```
|
||||
|
||||
## 📂 Arquivos
|
||||
|
||||
```
|
||||
Dockerfile # TZ=America/Sao_Paulo + tzdata + /etc/localtime; CMD -> bin/docker-boot
|
||||
whatsapp/Dockerfile # tzdata (Alpine) + mesmo fuso
|
||||
docker-compose.yml # restart: unless-stopped, TZ, command: bin/docker-boot
|
||||
bin/docker-boot # boot documentado: migrations com espera, cron, Puma — NOVO
|
||||
deploy/ntp-seguro.sh # chrony + NTS no host (rodar 1x, como root) — NOVO
|
||||
.env.example # TZ=America/Sao_Paulo + aviso "fuso ≠ hora"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
232
app/controllers/admin/romaneios_controller.rb
Normal file
232
app/controllers/admin/romaneios_controller.rb
Normal file
@@ -0,0 +1,232 @@
|
||||
# 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
|
||||
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
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
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)
|
||||
redirect_to admin_romaneio_path(@romaneio),
|
||||
notice: "Plano importado — #{resultado.resumo}."
|
||||
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
|
||||
163
app/javascript/controllers/romaneio_controller.js
Normal file
163
app/javascript/controllers/romaneio_controller.js
Normal file
@@ -0,0 +1,163 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
// Editor do romaneio: salva cada campo assim que ele muda e reatualiza a prévia
|
||||
// (que é o PDF de verdade, num iframe).
|
||||
//
|
||||
// Por que salvar POR CAMPO e não com um submit de formulário: `campos_editados`
|
||||
// precisa registrar exatamente o campo que um humano tocou. Um submit em bloco não
|
||||
// distingue "redigitei o mesmo valor" de "nunca olhei" — marcaria os 5 campos das 29
|
||||
// linhas como editados, e a reimportação seguinte preservaria endereços velhos no
|
||||
// plano inteiro. A regra de reconciliação (Romaneios::Importador) só funciona com
|
||||
// escrita por campo.
|
||||
export default class extends Controller {
|
||||
static targets = ["celula", "preview", "veiculo", "motorista", "rotulo", "status", "linha"]
|
||||
static values = { url: String, pdfUrl: String, veiculo: String }
|
||||
|
||||
// Recarregar o PDF a cada tecla travaria a tela; 600ms depois do último save é o
|
||||
// suficiente para parecer imediato sem regerar o documento à toa.
|
||||
static ATRASO_PREVIEW = 600
|
||||
|
||||
connect() {
|
||||
this.timerPreview = null
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.timerPreview) clearTimeout(this.timerPreview)
|
||||
}
|
||||
|
||||
// ── Edição ──────────────────────────────────────────────────────────────
|
||||
salvarCelula(event) {
|
||||
const input = event.currentTarget
|
||||
// Sair do campo sem mudar nada não é edição — marcar como editado aqui
|
||||
// congelaria o valor contra a próxima reimportação sem o operador querer.
|
||||
if (input.value === input.dataset.original) return
|
||||
|
||||
this.gravar(input.dataset.linhaId, input.dataset.campo, input.value, input)
|
||||
}
|
||||
|
||||
restaurar(event) {
|
||||
const botao = event.currentTarget
|
||||
const { linhaId, campo } = botao.dataset
|
||||
const input = this.celulaTargets.find(
|
||||
(c) => c.dataset.linhaId === linhaId && c.dataset.campo === campo
|
||||
)
|
||||
// valor null no corpo = "devolve o que o plano trouxe e destrava o campo"
|
||||
this.gravar(linhaId, campo, null, input, botao)
|
||||
}
|
||||
|
||||
alternarAparelho(event) {
|
||||
const botao = event.currentTarget
|
||||
const novo = botao.dataset.valor === "SIM" ? "" : "SIM"
|
||||
this.gravar(botao.dataset.linhaId, "aparelho", novo, null, null, (dados) => {
|
||||
botao.dataset.valor = dados.valor
|
||||
botao.textContent = dados.valor || "—"
|
||||
const estilo = dados.valor === "SIM"
|
||||
? "bg-green-600/20 text-green-300 border-green-600/40"
|
||||
: "bg-[#1a1a1a] text-gray-500 border-white/10"
|
||||
botao.className = `${estilo} w-full min-h-[40px] rounded-lg border font-semibold text-xs hover:border-orange-500 transition-colors`
|
||||
this.marcar(botao.dataset.linhaId, "aparelho", dados.editado)
|
||||
})
|
||||
}
|
||||
|
||||
salvarRotulo() {
|
||||
this.enviar(`${this.urlValue}/atualizar_rotulo`, { rotulo_plano: this.rotuloTarget.value })
|
||||
.then(() => this.agendarPreview())
|
||||
}
|
||||
|
||||
salvarMotorista() {
|
||||
// Não mexe na prévia: o nome do motorista não sai impresso — a linha do
|
||||
// rodapé é preenchida à mão na retirada.
|
||||
this.enviar(`${this.urlValue}/atualizar_veiculo`, {
|
||||
veiculo: this.veiculoValue,
|
||||
motorista: this.motoristaTarget.value
|
||||
})
|
||||
}
|
||||
|
||||
// ── Navegação e filtro ──────────────────────────────────────────────────
|
||||
trocarVeiculo() {
|
||||
window.location.search = `?veiculo=${encodeURIComponent(this.veiculoTarget.value)}`
|
||||
}
|
||||
|
||||
anterior() { this.mover(-1) }
|
||||
proximo() { this.mover(1) }
|
||||
|
||||
mover(passo) {
|
||||
const select = this.veiculoTarget
|
||||
const destino = select.selectedIndex + passo
|
||||
if (destino < 0 || destino >= select.options.length) return
|
||||
select.selectedIndex = destino
|
||||
this.trocarVeiculo()
|
||||
}
|
||||
|
||||
filtrar(event) {
|
||||
const termo = event.currentTarget.value.trim().toLowerCase()
|
||||
this.linhaTargets.forEach((linha) => {
|
||||
linha.hidden = termo !== "" && !linha.dataset.busca.includes(termo)
|
||||
})
|
||||
}
|
||||
|
||||
atualizarPreview() {
|
||||
if (!this.hasPreviewTarget) return
|
||||
// Cache-bust em vez de reload(): reload reinicializa o visualizador de PDF do
|
||||
// navegador e a tela pisca a cada salvamento.
|
||||
this.previewTarget.src =
|
||||
`${this.pdfUrlValue}?veiculo=${encodeURIComponent(this.veiculoValue)}&t=${Date.now()}`
|
||||
}
|
||||
|
||||
// ── Infra ───────────────────────────────────────────────────────────────
|
||||
gravar(linhaId, campo, valor, input, botao, aoConcluir) {
|
||||
this.status("Salvando…")
|
||||
|
||||
this.enviar(`${this.urlValue}/atualizar_linha`, { linha_id: linhaId, campo, valor })
|
||||
.then((dados) => {
|
||||
if (!dados || !dados.ok) return
|
||||
if (input) {
|
||||
// Escrevemos de volta o valor QUE O SERVIDOR GRAVOU: a escrita é
|
||||
// last-write-wins, e assim uma sobreposição de duas abas fica visível em
|
||||
// vez de silenciosa.
|
||||
input.value = dados.valor
|
||||
input.dataset.original = dados.valor
|
||||
}
|
||||
this.marcar(linhaId, campo, dados.editado)
|
||||
if (aoConcluir) aoConcluir(dados)
|
||||
this.status("Salvo")
|
||||
this.agendarPreview()
|
||||
})
|
||||
.catch(() => this.status("Não deu para salvar — tente de novo"))
|
||||
}
|
||||
|
||||
marcar(linhaId, campo, editado) {
|
||||
const alvo = this.element.querySelector(`[data-romaneio-marcador="${linhaId}-${campo}"]`)
|
||||
if (!alvo) return
|
||||
|
||||
if (!editado) {
|
||||
alvo.innerHTML = ""
|
||||
return
|
||||
}
|
||||
alvo.innerHTML =
|
||||
`<button type="button" title="Editado — não será sobrescrito na reimportação. Clique para restaurar o valor do plano." ` +
|
||||
`data-action="romaneio#restaurar" data-linha-id="${linhaId}" data-campo="${campo}" ` +
|
||||
`class="text-orange-400 hover:text-orange-200 text-xs leading-none">●</button>`
|
||||
}
|
||||
|
||||
agendarPreview() {
|
||||
if (this.timerPreview) clearTimeout(this.timerPreview)
|
||||
this.timerPreview = setTimeout(() => this.atualizarPreview(), this.constructor.ATRASO_PREVIEW)
|
||||
}
|
||||
|
||||
enviar(url, corpo) {
|
||||
return fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content
|
||||
},
|
||||
body: JSON.stringify(corpo)
|
||||
}).then((r) => (r.ok ? r.json() : Promise.reject(r)))
|
||||
}
|
||||
|
||||
status(texto) {
|
||||
if (this.hasStatusTarget) this.statusTarget.textContent = texto
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,11 @@ module Permissao
|
||||
'admin.planilha_simpli_route' => {
|
||||
grupo: 'admin', label: 'Gerar planilha de carga',
|
||||
descricao: 'Download do .xlsx de importação do SimpliRoute.'
|
||||
},
|
||||
'admin.romaneio' => {
|
||||
grupo: 'admin', label: 'Gerar romaneio de entrega',
|
||||
descricao: 'Importa o plano do dia, edita a tabela e imprime o "CONTROLE DE ' \
|
||||
'ENTREGA" que o motorista assina ao receber a carga.'
|
||||
}
|
||||
}.freeze
|
||||
|
||||
@@ -189,6 +194,7 @@ module Permissao
|
||||
consolidacao.gerir_motoristas consolidacao.exportar_pdf
|
||||
notificacao.contatos notificacao.contatos_gerenciar notificacao.envios
|
||||
admin.usuarios admin.configuracoes admin.auditoria admin.planilha_simpli_route
|
||||
admin.romaneio
|
||||
].freeze,
|
||||
'operador' => %w[
|
||||
dashboard.ver dashboard.financeiro dashboard.operacoes dashboard.exportar dashboard.metricas
|
||||
|
||||
71
app/models/romaneio.rb
Normal file
71
app/models/romaneio.rb
Normal file
@@ -0,0 +1,71 @@
|
||||
# app/models/romaneio.rb
|
||||
#
|
||||
# Um plano do SimpliRoute importado para virar o "CONTROLE DE ENTREGA" — a folha
|
||||
# que o motorista assina ao retirar a carga, uma página A4 paisagem por veículo.
|
||||
#
|
||||
# O romaneio é NOSSO (tabela gravável). As fontes continuam somente leitura: o
|
||||
# espelho de rastreio (Entrega) e as tabelas de operação (gade_entregas_*).
|
||||
class Romaneio < ApplicationRecord
|
||||
ORIGENS = %w[api espelho planilha].freeze
|
||||
|
||||
# Máximo de paradas que cabem numa folha. O número é da GEOMETRIA do PDF, então a
|
||||
# fonte é Pdf::RomaneioPdf — duplicar aqui criaria duas verdades, e o dia em que
|
||||
# alguém mexesse na margem a tela passaria a avisar "sai em 1 folha" para um
|
||||
# romaneio que sai em 2. Lido em tempo de chamada (e não numa constante) para não
|
||||
# amarrar a ordem de carga dos autoloads.
|
||||
def self.linhas_por_folha
|
||||
Pdf::RomaneioPdf::MAX_LINHAS
|
||||
end
|
||||
|
||||
belongs_to :importado_por, class_name: 'User', optional: true
|
||||
|
||||
has_many :romaneio_veiculos, -> { order(:posicao, :veiculo) },
|
||||
dependent: :destroy, inverse_of: :romaneio
|
||||
has_many :romaneio_linhas, dependent: :destroy, inverse_of: :romaneio
|
||||
|
||||
validates :planned_date, presence: true
|
||||
validates :origem, inclusion: { in: ORIGENS }
|
||||
validates :planned_date, uniqueness: { scope: :operacao_tabela }
|
||||
|
||||
scope :recentes, -> { order(planned_date: :desc, id: :desc) }
|
||||
|
||||
# Linhas que entram no PDF: as que continuam no plano, na ordem do roteiro.
|
||||
# `removido_em` marca a parada que saiu do plano mas tinha edição humana — ela
|
||||
# fica visível na tela para o operador decidir, e NUNCA sai impressa.
|
||||
def linhas_vigentes
|
||||
romaneio_linhas.where(removido_em: nil).order(:veiculo, :ordem, :id)
|
||||
end
|
||||
|
||||
def linhas_removidas
|
||||
romaneio_linhas.where.not(removido_em: nil).order(:veiculo, :ordem, :id)
|
||||
end
|
||||
|
||||
# { 'GADE_001' => 29, ... } — alimenta o seletor de veículo e o aviso de folhas.
|
||||
def contagem_por_veiculo
|
||||
@contagem_por_veiculo ||= linhas_vigentes.reorder(nil).group(:veiculo).count
|
||||
end
|
||||
|
||||
def folhas_de(veiculo)
|
||||
total = contagem_por_veiculo[veiculo].to_i
|
||||
return 1 if total.zero?
|
||||
|
||||
(total.to_f / self.class.linhas_por_folha).ceil
|
||||
end
|
||||
|
||||
# Veículos que estouram uma folha — a tela avisa antes de imprimir, porque
|
||||
# descobrir isso só no papel é descobrir tarde.
|
||||
def veiculos_multifolha
|
||||
contagem_por_veiculo.select { |_v, n| n > self.class.linhas_por_folha }
|
||||
end
|
||||
|
||||
def operacao_label
|
||||
operacao_tabela.present? ? Operacao.label(operacao_tabela) : nil
|
||||
end
|
||||
|
||||
# Rótulo do canto direito do PDF ("EMAD 09.2026"). Só uma SUGESTÃO: não existe
|
||||
# fonte canônica para esse texto, então ele é editável na tela.
|
||||
def self.rotulo_sugerido(tabela, data)
|
||||
base = tabela.present? ? Operacao.label(tabela).split.first : nil
|
||||
[base, data.to_date.strftime('%m.%Y')].compact.join(' ')
|
||||
end
|
||||
end
|
||||
68
app/models/romaneio_linha.rb
Normal file
68
app/models/romaneio_linha.rb
Normal file
@@ -0,0 +1,68 @@
|
||||
# app/models/romaneio_linha.rb
|
||||
#
|
||||
# Uma PARADA do romaneio — vira uma linha da tabela do PDF.
|
||||
#
|
||||
# ⚠️ Nome de tabela fixado: "RomaneioLinha".tableize já dá `romaneio_linhas`, mas a
|
||||
# declaração explícita é a trava contra alguém acrescentar um irregular ao
|
||||
# config/initializers/inflections.rb e mudar isso sem perceber (ConsolidacaoMotorista
|
||||
# já quebrou /motorista em produção exatamente assim). Coberto por
|
||||
# spec/models/table_names_spec.rb.
|
||||
class RomaneioLinha < ApplicationRecord
|
||||
self.table_name = 'romaneio_linhas'
|
||||
|
||||
# Os únicos campos que o operador edita — e a whitelist que o controller usa
|
||||
# para não deixar um PATCH escrever em `veiculo`, `ordem` ou `chave_origem`.
|
||||
#
|
||||
# `veiculo` e `ordem` NÃO são editáveis de propósito: mover uma parada de veículo
|
||||
# aqui produziria um romaneio que contradiz a rota que o motorista recebe no
|
||||
# celular — dois papéis, mesma entrega, respostas diferentes. Veículo errado se
|
||||
# corrige no SimpliRoute e se reimporta.
|
||||
CAMPOS_EDITAVEIS = %w[nota_fiscal nome endereco aparelho telefones].freeze
|
||||
|
||||
belongs_to :romaneio, inverse_of: :romaneio_linhas
|
||||
|
||||
validates :veiculo, :chave_origem, presence: true
|
||||
|
||||
scope :vigentes, -> { where(removido_em: nil) }
|
||||
scope :removidas, -> { where.not(removido_em: nil) }
|
||||
scope :do_veiculo, ->(veiculo) { where(veiculo: veiculo) }
|
||||
|
||||
# Identidade ESTÁVEL entre reimportações. A NF quando existe; senão um digest do
|
||||
# título + endereço, porque nem toda linha do plano casa "NF 90507 - FULANO" e
|
||||
# duas linhas sem NF colidiriam no índice único, derrubando a importação inteira.
|
||||
#
|
||||
# Veículo e ordem ficam FORA da chave: mudam quando o plano é reotimizado e a
|
||||
# parada continua sendo a mesma entrega.
|
||||
def self.chave_para(nota_fiscal:, titulo: nil, endereco: nil)
|
||||
nf = nota_fiscal.to_s.strip
|
||||
return nf if nf.present?
|
||||
|
||||
"T:#{Digest::MD5.hexdigest("#{titulo}|#{endereco}")}"
|
||||
end
|
||||
|
||||
def editado?(campo)
|
||||
campos_editados.include?(campo.to_s)
|
||||
end
|
||||
|
||||
def editada?
|
||||
campos_editados.present?
|
||||
end
|
||||
|
||||
# Valor que o PLANO trouxe na última importação — é o que o botão "restaurar"
|
||||
# devolve, e o que a reconciliação compara.
|
||||
def valor_do_plano(campo)
|
||||
origem[campo.to_s].to_s
|
||||
end
|
||||
|
||||
# Marca o campo como tocado por humano. É ISTO que sobrevive à reimportação:
|
||||
# o dirty tracking é POR CAMPO, não por linha — "corrigi o telefone" não pode
|
||||
# significar "também respondo pelo endereço" e congelar um endereço velho quando
|
||||
# o SimpliRoute regeocodificar.
|
||||
def marcar_editado!(campo)
|
||||
self.campos_editados = (campos_editados + [campo.to_s]).uniq
|
||||
end
|
||||
|
||||
def desmarcar_editado!(campo)
|
||||
self.campos_editados = campos_editados - [campo.to_s]
|
||||
end
|
||||
end
|
||||
17
app/models/romaneio_veiculo.rb
Normal file
17
app/models/romaneio_veiculo.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
# app/models/romaneio_veiculo.rb
|
||||
#
|
||||
# Uma FOLHA do romaneio: um veículo, uma página (ou mais, quando passa de
|
||||
# Romaneio::LINHAS_POR_FOLHA paradas).
|
||||
#
|
||||
# ⚠️ `motorista` NÃO é impresso. O rodapé do documento traz "MOTORISTA: ______"
|
||||
# para preenchimento à mão — é assim que a folha é assinada hoje, e o programa
|
||||
# Python que originou esta tela também coletava o nome sem usá-lo. O campo existe
|
||||
# para o operador saber, na tela, de quem é cada carro enquanto revisa.
|
||||
class RomaneioVeiculo < ApplicationRecord
|
||||
belongs_to :romaneio, inverse_of: :romaneio_veiculos
|
||||
|
||||
validates :veiculo, presence: true
|
||||
validates :veiculo, uniqueness: { scope: :romaneio_id }
|
||||
|
||||
scope :ordenados, -> { order(:posicao, :veiculo) }
|
||||
end
|
||||
@@ -129,6 +129,7 @@ class User < ApplicationRecord
|
||||
['admin.usuarios', :admin_usuarios_path],
|
||||
['admin.perfis', :admin_perfis_path],
|
||||
['admin.configuracoes', :admin_configuracoes_path],
|
||||
['admin.romaneio', :admin_romaneios_path],
|
||||
['notificacao.contatos', :admin_contatos_path],
|
||||
['notificacao.eventos', :admin_eventos_path],
|
||||
['notificacao.envios', :admin_envios_path]
|
||||
|
||||
19
app/policies/romaneio_policy.rb
Normal file
19
app/policies/romaneio_policy.rb
Normal file
@@ -0,0 +1,19 @@
|
||||
# app/policies/romaneio_policy.rb
|
||||
#
|
||||
# Uma chave por TELA, como manda o comentário de Permissao: importar o plano, editar
|
||||
# a tabela e imprimir são a MESMA operação do ponto de vista de quem carrega o
|
||||
# caminhão — quebrar mais fino encheria a tela de perfil de caixinhas que ninguém
|
||||
# sabe responder.
|
||||
class RomaneioPolicy < ApplicationPolicy
|
||||
def index? = pode?('admin.romaneio')
|
||||
def show? = index?
|
||||
def create? = index?
|
||||
def destroy? = index?
|
||||
|
||||
def importar_planilha? = index?
|
||||
def reimportar? = index?
|
||||
def pdf? = index?
|
||||
def atualizar_linha? = index?
|
||||
def atualizar_veiculo? = index?
|
||||
def atualizar_rotulo? = index?
|
||||
end
|
||||
@@ -9,16 +9,15 @@ module Pdf
|
||||
PRETO = '0A0A0A'
|
||||
CINZA = '6B7280'
|
||||
|
||||
# Fonte TrueType com suporte total a UTF-8. As fontes embutidas do Prawn
|
||||
# (AFM/Helvetica) só aceitam o charset Windows-1252 e estouram
|
||||
# Prawn::Errors::IncompatibleStringEncoding em caracteres como "✓".
|
||||
FONTS_DIR = Rails.root.join('app', 'assets', 'fonts')
|
||||
# Fonte TrueType com suporte total a UTF-8 — o porquê está em Pdf::FonteUtf8,
|
||||
# que também serve o romaneio (paisagem, marca da GADE, sem herdar daqui).
|
||||
include Pdf::FonteUtf8
|
||||
|
||||
def initialize
|
||||
# Margem inferior folgada (72) para o rodapé repetido (desenhado em y=20)
|
||||
# não sobrepor o conteúdo.
|
||||
@pdf = Prawn::Document.new(page_size: 'A4', margin: [40, 40, 72, 40])
|
||||
registrar_fonte_utf8
|
||||
registrar_fonte_utf8(@pdf)
|
||||
end
|
||||
|
||||
def render
|
||||
@@ -30,24 +29,6 @@ module Pdf
|
||||
|
||||
private
|
||||
|
||||
# Registra a família DejaVu Sans (TTF) e a define como fonte padrão.
|
||||
# Como os PDFs só usam normal e :bold, mapeamos italic/bold_italic para
|
||||
# as variantes existentes, evitando erro caso :italic seja solicitado.
|
||||
def registrar_fonte_utf8
|
||||
normal = FONTS_DIR.join('DejaVuSans.ttf').to_s
|
||||
bold = FONTS_DIR.join('DejaVuSans-Bold.ttf').to_s
|
||||
|
||||
@pdf.font_families.update(
|
||||
'DejaVu' => {
|
||||
normal: normal,
|
||||
bold: bold,
|
||||
italic: normal,
|
||||
bold_italic: bold
|
||||
}
|
||||
)
|
||||
@pdf.font 'DejaVu'
|
||||
end
|
||||
|
||||
def cabecalho
|
||||
@pdf.fill_color PRETO
|
||||
@pdf.fill_rectangle [-40, @pdf.cursor + 40], @pdf.bounds.width + 80, 70
|
||||
|
||||
42
app/services/pdf/fonte_utf8.rb
Normal file
42
app/services/pdf/fonte_utf8.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
# app/services/pdf/fonte_utf8.rb
|
||||
#
|
||||
# Registro da família DejaVu Sans (TrueType) num Prawn::Document.
|
||||
#
|
||||
# Por que existe: as fontes embutidas do Prawn (AFM/Helvetica) só aceitam o charset
|
||||
# Windows-1252 e estouram Prawn::Errors::IncompatibleStringEncoding em qualquer
|
||||
# caractere fora dele ("✓" no extrato do motorista; e, no romaneio, endereços que
|
||||
# chegam do cliente com "SÃO PAULO", "INDAIÁ", "JARDIM MIRIAM").
|
||||
#
|
||||
# Estava privado em Pdf::BasePdf, mas o motivo não tem nada a ver com o layout
|
||||
# retrato/preto-laranja da Reem — vale para todo PDF do app. Pdf::RomaneioPdf é
|
||||
# paisagem com a marca da GADE e não herda de BasePdf, então precisa do mesmo
|
||||
# registro sem arrastar o cabeçalho junto.
|
||||
module Pdf
|
||||
module FonteUtf8
|
||||
# Resolvido em tempo de CHAMADA, não de carga: Pdf::RomaneioPdf precisa ser
|
||||
# `require`-ável num container Ruby pelado (sem Rails) para a conferência de
|
||||
# layout em PNG — é o único jeito de validar PDF neste repositório, que não tem
|
||||
# Ruby local. Uma constante com Rails.root no corpo do módulo mataria isso.
|
||||
def self.dir_padrao
|
||||
if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
||||
Rails.root.join('app', 'assets', 'fonts').to_s
|
||||
else
|
||||
File.expand_path('../../assets/fonts', __dir__)
|
||||
end
|
||||
end
|
||||
|
||||
# Como os PDFs só usam normal e :bold, mapeamos italic/bold_italic para as
|
||||
# variantes existentes — assim um :italic pedido por engano não levanta erro.
|
||||
def registrar_fonte_utf8(documento, dir = Pdf::FonteUtf8.dir_padrao)
|
||||
documento.font_families.update(
|
||||
'DejaVu' => {
|
||||
normal: File.join(dir.to_s, 'DejaVuSans.ttf'),
|
||||
bold: File.join(dir.to_s, 'DejaVuSans-Bold.ttf'),
|
||||
italic: File.join(dir.to_s, 'DejaVuSans.ttf'),
|
||||
bold_italic: File.join(dir.to_s, 'DejaVuSans-Bold.ttf')
|
||||
}
|
||||
)
|
||||
documento.font 'DejaVu'
|
||||
end
|
||||
end
|
||||
end
|
||||
328
app/services/pdf/romaneio_pdf.rb
Normal file
328
app/services/pdf/romaneio_pdf.rb
Normal file
@@ -0,0 +1,328 @@
|
||||
# app/services/pdf/romaneio_pdf.rb
|
||||
#
|
||||
# "CONTROLE DE ENTREGA" — a folha que o motorista assina ao retirar a carga.
|
||||
# Uma página A4 PAISAGEM por veículo, no layout que a GADE já recebe hoje
|
||||
# (referência: Imagens para implantação/Romaneiro_EMAD_SETEMBRO_2026.pdf).
|
||||
#
|
||||
# NÃO herda de Pdf::BasePdf de propósito: aquela classe é retrato, chumba o
|
||||
# cabeçalho preto/laranja da REEM e instala um rodapé cinza repetido em todas as
|
||||
# páginas — os três errados aqui. Compartilham só Pdf::FonteUtf8.
|
||||
#
|
||||
# ── Por que desenho manual e não prawn-table ────────────────────────────────
|
||||
# A geometria aqui é RÍGIDA: altura de linha exata, sem quebra de texto, fonte que
|
||||
# encolhe por célula até caber. No prawn-table, `height` é PISO e não valor exato —
|
||||
# uma célula que quebrasse em duas linhas empurraria a tabela além de bounds.height
|
||||
# e o prawn-table dividiria em outra página, quebrando "uma folha por veículo" e
|
||||
# dessincronizando o cabeçalho (é exatamente o bug latente do programa Python que
|
||||
# originou esta tela: o contador de página avançava por PÁGINA, então um veículo de
|
||||
# duas páginas carimbava o veículo errado na segunda e em todas as seguintes).
|
||||
# O Prawn nativo resolve com `overflow: :shrink_to_fit, single_line: true` numa
|
||||
# caixa de altura fixa: depois que o corpo trava em MIN_FONTE o texto CORTA, em vez
|
||||
# de estourar a página.
|
||||
module Pdf
|
||||
class RomaneioPdf
|
||||
include Pdf::FonteUtf8
|
||||
|
||||
MM = 72 / 25.4
|
||||
|
||||
MARGEM = 10 * MM
|
||||
HEADER_H = 28 * MM # faixa do cabeçalho = margem superior do documento
|
||||
FOOTER_H = 22 * MM # bloco DECLARO + assinatura
|
||||
SPACER_H = 8 * MM # respiro entre a tabela e o DECLARO
|
||||
|
||||
# O ReportLab põe 6pt de topPadding no Frame do SimpleDocTemplate por padrão,
|
||||
# e o programa original nunca desconta isso. Medido contra o PDF de referência:
|
||||
# sem esses 6pt a tabela inteira sobe 9px (a 110 dpi) em relação ao documento
|
||||
# que a GADE conhece. O Prawn não tem esse padding, então ele entra à mão.
|
||||
PADDING_TOPO = 6
|
||||
|
||||
# Máximo de paradas por folha. Junto com as 2 linhas em branco e a linha de
|
||||
# cabeçalho, é o que faz a tabela ocupar exatamente uma página.
|
||||
MAX_LINHAS = 33
|
||||
LINHAS_BRANCO = 2
|
||||
TOTAL_LINHAS = MAX_LINHAS + LINHAS_BRANCO + 1
|
||||
|
||||
AZUL = '1B3A6B' # cabeçalho da tabela e títulos
|
||||
ZEBRA = 'EAF0FB' # fundo das linhas alternadas
|
||||
GRADE = 'AAAAAA'
|
||||
|
||||
# nil = "o resto da largura" (a coluna ENDEREÇO absorve a sobra)
|
||||
LARGURAS_MM = [8, 22, 50, nil, 22, 42].freeze
|
||||
CABECALHOS = ['#', 'NOTA FISCAL', 'NOME', 'ENDEREÇO', 'APARELHO', 'TELEFONES'].freeze
|
||||
CENTRALIZADAS = [0, 1, 4].freeze # #, NOTA FISCAL, APARELHO
|
||||
|
||||
MIN_FONTE = 5
|
||||
|
||||
# Espaçamentos do rodapé, CALIBRADOS contra o PDF de referência (render a 110 dpi
|
||||
# de Romaneiro_EMAD_SETEMBRO_2026.pdf, página 1). No original eles saem de três
|
||||
# tabelas do ReportLab com paddings próprios (TOPPADDING 4, 17 e 2 + o padding
|
||||
# padrão de 3 que ele não sobrescreve); reproduzir as tabelas só para chegar às
|
||||
# mesmas três linhas de texto não pagaria o custo — os números medidos, sim.
|
||||
RODAPE_TOPO = 5.3 # da base da tabela (após o SPACER_H) até o DECLARO
|
||||
RODAPE_ATE_ASSINATURA = 31.5 # do DECLARO até a linha de assinatura
|
||||
RODAPE_ATE_ROTULO = 17.2 # da linha de assinatura até "ASSINATURA DO MOTORISTA"
|
||||
RODAPE_ULTIMA_LINHA = 9 # altura da linha de "ASSINATURA DO MOTORISTA" (7,5pt)
|
||||
|
||||
DECLARACAO = 'DECLARO QUE RECEBI OS PRODUTOS PARA REALIZAR AS ENTREGAS NOS ENDEREÇOS ACIMA.'
|
||||
CAMPO_DATA = 'DATA: _______/_______/__________'
|
||||
CAMPO_MOT = 'MOTORISTA: ______________________________'
|
||||
|
||||
# `romaneio` só precisa responder a #rotulo_plano e #linhas_vigentes — assim o
|
||||
# arquivo carrega num container Ruby pelado (sem Rails) para a conferência de
|
||||
# layout em PNG, que é o único jeito de validar PDF neste repositório.
|
||||
# `veiculos: nil` = todos, na ordem do romaneio.
|
||||
def initialize(romaneio:, veiculos: nil, logo: nil)
|
||||
@romaneio = romaneio
|
||||
# Ruby puro (sem ActiveSupport) daqui para baixo: esta classe precisa
|
||||
# carregar num container sem Rails para a conferência de layout em PNG.
|
||||
lista = Array(veiculos).map(&:to_s).reject(&:empty?)
|
||||
@veiculos = lista.empty? ? nil : lista
|
||||
@logo = logo || self.class.logo_padrao
|
||||
|
||||
@pdf = Prawn::Document.new(page_size: 'A4', page_layout: :landscape,
|
||||
margin: [HEADER_H, MARGEM, MARGEM, MARGEM])
|
||||
registrar_fonte_utf8(@pdf)
|
||||
|
||||
altura_util = @pdf.bounds.height - SPACER_H - FOOTER_H
|
||||
@linha_h = altura_util / TOTAL_LINHAS
|
||||
@pad = [1.0, (@linha_h - 9) / 2].max
|
||||
@fonte_dados = [6.0, @linha_h - 2 * @pad - 2].max
|
||||
@fonte_hdr = [8.0, @fonte_dados + 0.5].min
|
||||
@larguras = calcular_larguras
|
||||
|
||||
# Quantas linhas (dados + branco) cabem numa folha SEM empurrar a última
|
||||
# linha do rodapé para fora da área útil.
|
||||
#
|
||||
# ⚠️ Não é decorativo: a conta do programa original (33 + 2 + 1 = 36 linhas)
|
||||
# ignora tanto o topPadding de 6pt do Frame do ReportLab quanto os 15% a mais
|
||||
# da linha de cabeçalho. Com 33 paradas de verdade o total estoura o orçamento
|
||||
# em ~8pt e o "ASSINATURA DO MOTORISTA" some da folha — sem erro nenhum, o
|
||||
# papel simplesmente sai sem o rótulo da assinatura. Medido aqui com 34
|
||||
# paradas; nunca disparou em produção porque o maior veículo real tem 31.
|
||||
altura_rodape = RODAPE_TOPO + RODAPE_ATE_ASSINATURA + RODAPE_ATE_ROTULO +
|
||||
RODAPE_ULTIMA_LINHA
|
||||
sobra = @pdf.bounds.height - SPACER_H - altura_rodape - PADDING_TOPO - @linha_h * 1.15
|
||||
@max_linhas_folha = (sobra / @linha_h).floor
|
||||
end
|
||||
|
||||
def self.logo_padrao
|
||||
if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
||||
Rails.root.join('public', 'logo-gade.png').to_s
|
||||
else
|
||||
File.expand_path('../../../public/logo-gade.png', __dir__)
|
||||
end
|
||||
end
|
||||
|
||||
def render
|
||||
paginas = montar_paginas
|
||||
return @pdf.render if paginas.empty?
|
||||
|
||||
paginas.each_with_index do |pagina, i|
|
||||
@pdf.start_new_page unless i.zero?
|
||||
desenhar_pagina(pagina)
|
||||
end
|
||||
|
||||
@pdf.render
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# [{ veiculo:, folha:, total_folhas:, primeira_ordem:, linhas: [...] }, ...]
|
||||
#
|
||||
# Veículo com mais de MAX_LINHAS paradas vira N folhas. As alternativas eram
|
||||
# descartar a parada #34 (pacote sem papel nenhum) ou deixar vazar para a página
|
||||
# seguinte sob o cabeçalho do veículo ERRADO — corrupção silenciosa de um
|
||||
# documento assinado. Cada folha leva o próprio bloco de assinatura, e a
|
||||
# numeração continua entre folhas para bater com a ordem de parada no celular
|
||||
# do motorista.
|
||||
def montar_paginas
|
||||
agrupadas.flat_map do |veiculo, linhas|
|
||||
fatias = linhas.each_slice(MAX_LINHAS).to_a
|
||||
fatias.each_with_index.map do |fatia, i|
|
||||
{ veiculo: veiculo, folha: i + 1, total_folhas: fatias.size,
|
||||
primeira_ordem: i * MAX_LINHAS + 1, linhas: fatia }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def agrupadas
|
||||
linhas = @romaneio.linhas_vigentes.to_a
|
||||
linhas = linhas.select { |l| @veiculos.include?(l.veiculo.to_s) } if @veiculos
|
||||
linhas.group_by { |l| l.veiculo.to_s }
|
||||
end
|
||||
|
||||
def desenhar_pagina(pagina)
|
||||
desenhar_cabecalho(pagina)
|
||||
@pdf.move_down PADDING_TOPO
|
||||
desenhar_tabela(pagina)
|
||||
@pdf.move_down SPACER_H
|
||||
desenhar_rodape
|
||||
end
|
||||
|
||||
# ── Cabeçalho ────────────────────────────────────────────────────────────
|
||||
# Desenhado no `canvas` (coordenadas absolutas da página): a faixa vive DENTRO
|
||||
# da margem superior, acima da área de conteúdo.
|
||||
def desenhar_cabecalho(pagina)
|
||||
titulo = "CONTROLE DE ENTREGA — #{pagina[:veiculo]}"
|
||||
titulo += " (FOLHA #{pagina[:folha]}/#{pagina[:total_folhas]})" if pagina[:total_folhas] > 1
|
||||
base_y = @pdf.bounds.absolute_top + HEADER_H - MARGEM - 11 * MM
|
||||
|
||||
@pdf.canvas do
|
||||
if @logo && File.exist?(@logo.to_s)
|
||||
@pdf.image @logo, at: [MARGEM, @pdf.bounds.absolute_top - MARGEM],
|
||||
fit: [50 * MM, 17 * MM]
|
||||
end
|
||||
|
||||
# draw_text (e não text_box) porque o `at` dele é a BASELINE, igual ao
|
||||
# drawCentredString do ReportLab — text_box ancoraria pelo topo da caixa e
|
||||
# o título subiria ~4pt em relação ao documento que a GADE conhece.
|
||||
@pdf.fill_color AZUL
|
||||
largura_titulo = @pdf.width_of(titulo, size: 15, style: :bold)
|
||||
@pdf.draw_text titulo, size: 15, style: :bold,
|
||||
at: [(@pdf.bounds.absolute_right - largura_titulo) / 2.0, base_y]
|
||||
|
||||
desenhar_rotulo_plano(base_y)
|
||||
@pdf.fill_color '000000'
|
||||
end
|
||||
end
|
||||
|
||||
# "PLANO:" em negrito azul + o rótulo em preto normal, ambos encostados na
|
||||
# direita. O rótulo é medido primeiro para o label parar exatamente antes dele.
|
||||
def desenhar_rotulo_plano(base_y)
|
||||
rotulo = @romaneio.rotulo_plano.to_s.strip
|
||||
return if rotulo.empty?
|
||||
|
||||
direita = @pdf.bounds.absolute_right - MARGEM
|
||||
largura = @pdf.width_of(rotulo, size: 9)
|
||||
|
||||
@pdf.fill_color '000000'
|
||||
@pdf.draw_text rotulo, at: [direita - largura, base_y], size: 9
|
||||
@pdf.fill_color AZUL
|
||||
@pdf.draw_text 'PLANO: ', at: [direita - largura - @pdf.width_of('PLANO: ', size: 9, style: :bold),
|
||||
base_y], size: 9, style: :bold
|
||||
end
|
||||
|
||||
# ── Tabela ───────────────────────────────────────────────────────────────
|
||||
def desenhar_tabela(pagina)
|
||||
topo = @pdf.cursor
|
||||
alt_h = @linha_h * 1.15
|
||||
y = topo
|
||||
|
||||
desenhar_linha_cabecalho(y, alt_h)
|
||||
y -= alt_h
|
||||
|
||||
pagina[:linhas].each_with_index do |linha, i|
|
||||
# No original o zebrado começa em EAF0FB: a primeira linha de DADOS é a
|
||||
# colorida. Inverter isso troca a paridade da tabela inteira.
|
||||
cor = i.even? ? ZEBRA : 'FFFFFF'
|
||||
desenhar_linha(y, cor, celulas(linha, pagina[:primeira_ordem] + i))
|
||||
y -= @linha_h
|
||||
end
|
||||
|
||||
# As linhas em branco existem para o operador anotar uma parada à mão. Numa
|
||||
# folha cheia não há espaço para elas — e insistir empurraria o rodapé para
|
||||
# fora do papel. Então elas entram até onde couberem.
|
||||
brancas = [LINHAS_BRANCO, @max_linhas_folha - pagina[:linhas].size].min
|
||||
brancas = 0 if brancas.negative?
|
||||
|
||||
brancas.times do |i|
|
||||
cor = (pagina[:linhas].size + i).even? ? ZEBRA : 'FFFFFF'
|
||||
desenhar_linha(y, cor, Array.new(6, ''))
|
||||
y -= @linha_h
|
||||
end
|
||||
|
||||
desenhar_grade(topo, y, alt_h, pagina[:linhas].size + brancas)
|
||||
@pdf.move_cursor_to y
|
||||
end
|
||||
|
||||
def celulas(linha, numero)
|
||||
[numero.to_s, linha.nota_fiscal.to_s, linha.nome.to_s,
|
||||
linha.endereco.to_s, linha.aparelho.to_s, linha.telefones.to_s]
|
||||
end
|
||||
|
||||
def desenhar_linha_cabecalho(y, altura)
|
||||
@pdf.fill_color AZUL
|
||||
@pdf.fill_rectangle [0, y], @pdf.bounds.width, altura
|
||||
@pdf.fill_color 'FFFFFF'
|
||||
escrever_celulas(y, altura, CABECALHOS, size: @fonte_hdr, style: :bold, tudo_centro: true)
|
||||
@pdf.fill_color '000000'
|
||||
end
|
||||
|
||||
def desenhar_linha(y, cor, valores)
|
||||
@pdf.fill_color cor
|
||||
@pdf.fill_rectangle [0, y], @pdf.bounds.width, @linha_h
|
||||
@pdf.fill_color '000000'
|
||||
escrever_celulas(y, @linha_h, valores, size: @fonte_dados)
|
||||
end
|
||||
|
||||
def escrever_celulas(y, altura, valores, size:, style: :normal, tudo_centro: false)
|
||||
x = 0
|
||||
valores.each_with_index do |valor, i|
|
||||
largura = @larguras[i]
|
||||
texto = valor.to_s
|
||||
unless texto.empty?
|
||||
alinhamento = tudo_centro || CENTRALIZADAS.include?(i) ? :center : :left
|
||||
@pdf.text_box texto, at: [x + 3, y], width: largura - 6, height: altura,
|
||||
size: size, style: style, valign: :center,
|
||||
align: alinhamento, overflow: :shrink_to_fit,
|
||||
min_font_size: MIN_FONTE, single_line: true
|
||||
end
|
||||
x += largura
|
||||
end
|
||||
end
|
||||
|
||||
def desenhar_grade(topo, base, altura_cabecalho, total_linhas)
|
||||
@pdf.line_width 0.4
|
||||
@pdf.stroke_color GRADE
|
||||
|
||||
y = topo - altura_cabecalho
|
||||
(total_linhas + 1).times do
|
||||
@pdf.stroke_horizontal_line 0, @pdf.bounds.width, at: y
|
||||
y -= @linha_h
|
||||
end
|
||||
|
||||
x = 0
|
||||
@larguras.each do |largura|
|
||||
@pdf.stroke_vertical_line base, topo, at: x
|
||||
x += largura
|
||||
end
|
||||
@pdf.stroke_vertical_line base, topo, at: @pdf.bounds.width
|
||||
|
||||
@pdf.line_width 0.8
|
||||
@pdf.stroke_color '000000'
|
||||
@pdf.stroke_rectangle [0, topo], @pdf.bounds.width, topo - base
|
||||
@pdf.stroke_color '000000'
|
||||
@pdf.line_width 1
|
||||
end
|
||||
|
||||
# ── Rodapé ───────────────────────────────────────────────────────────────
|
||||
# O nome do motorista NÃO entra: a linha "MOTORISTA: ____" é preenchida à mão
|
||||
# na retirada — é assim que o documento é assinado hoje.
|
||||
def desenhar_rodape
|
||||
col_l = @pdf.bounds.width * 0.62
|
||||
col_r = @pdf.bounds.width * 0.38
|
||||
|
||||
y = @pdf.cursor - RODAPE_TOPO
|
||||
@pdf.text_box DECLARACAO, at: [0, y], width: col_l, size: 7.5, style: :bold,
|
||||
overflow: :shrink_to_fit, single_line: true
|
||||
@pdf.text_box CAMPO_DATA, at: [col_l, y], width: col_r, size: 8, style: :bold,
|
||||
align: :right, single_line: true
|
||||
|
||||
y -= RODAPE_ATE_ASSINATURA
|
||||
@pdf.text_box '_' * 55, at: [0, y], width: col_l, size: 8, single_line: true
|
||||
@pdf.text_box CAMPO_MOT, at: [col_l, y], width: col_r, size: 8, style: :bold,
|
||||
align: :right, single_line: true
|
||||
|
||||
y -= RODAPE_ATE_ROTULO
|
||||
@pdf.text_box 'ASSINATURA DO MOTORISTA', at: [0, y], width: col_l,
|
||||
size: 7.5, style: :bold, single_line: true
|
||||
end
|
||||
|
||||
# A coluna nil (ENDEREÇO) recebe o que sobrar da largura útil.
|
||||
def calcular_larguras
|
||||
fixas = LARGURAS_MM.compact.sum * MM
|
||||
resto = @pdf.bounds.width - fixas
|
||||
LARGURAS_MM.map { |mm| mm.nil? ? resto : mm * MM }
|
||||
end
|
||||
end
|
||||
end
|
||||
101
app/services/romaneios/enriquecimento_gade.rb
Normal file
101
app/services/romaneios/enriquecimento_gade.rb
Normal file
@@ -0,0 +1,101 @@
|
||||
# app/services/romaneios/enriquecimento_gade.rb
|
||||
#
|
||||
# Preenche APARELHO, TELEFONES e NOME do romaneio a partir da tabela da operação
|
||||
# (gade_entregas_*). É o que dispensa a SEGUNDA PLANILHA do programa Python: lá o
|
||||
# operador subia um .xlsx só com NOTA FISCAL + STATUS para marcar quem é NOVO; aqui
|
||||
# o STATUS já está no banco, na própria operação.
|
||||
#
|
||||
# A tabela é SOMENTE LEITURA (apenas SELECT), o nome passa por Operacao.valida? +
|
||||
# quote_table_name e as NFs por conn.quote.
|
||||
module Romaneios
|
||||
class EnriquecimentoGade
|
||||
# chave usada no SELECT => coluna real na gade.
|
||||
#
|
||||
# ⚠️ `status` PRECISA sair aliasado: o espelho de rastreio também tem uma coluna
|
||||
# `status` (o estado da visita), e qualquer query que junte as duas trocaria uma
|
||||
# pela outra em silêncio. Analytics::OperacaoMetricas já faz esse mesmo alias
|
||||
# (GADE_OPCIONAIS) pelo mesmo motivo.
|
||||
COLUNAS = {
|
||||
'nota_fiscal' => 'nota_fiscal',
|
||||
'nome_completo' => 'nome_completo',
|
||||
'telefones' => 'telefones',
|
||||
'status' => 'status_gade'
|
||||
}.freeze
|
||||
|
||||
# Sem essas, não há o que enriquecer.
|
||||
ESSENCIAIS = %w[nota_fiscal].freeze
|
||||
|
||||
attr_reader :tabela
|
||||
|
||||
def initialize(tabela)
|
||||
@tabela = tabela.to_s
|
||||
raise ArgumentError, "Operação inválida: #{@tabela}" unless Operacao.valida?(@tabela)
|
||||
end
|
||||
|
||||
# { '90507' => { aparelho: 'SIM', telefones: '(11) ...', nome: 'CICERA ...' } }
|
||||
# NF que não estiver na operação simplesmente não vem no Hash — é assim que a
|
||||
# tela marca "fora da operação" (plano avulso / de inclusão), mesma semântica de
|
||||
# Analytics::NotasForaOperacao.
|
||||
def para_notas(notas)
|
||||
lista = Array(notas).map { |n| n.to_s.strip }.reject(&:empty?).uniq
|
||||
return {} if lista.empty?
|
||||
|
||||
registros(lista).each_with_object({}) do |r, memo|
|
||||
nf = r['nota_fiscal'].to_s.strip
|
||||
next if nf.empty?
|
||||
|
||||
memo[nf] = {
|
||||
aparelho: Normalizador.aparelho_por_status(r['status_gade']),
|
||||
telefones: r['telefones'].to_s.strip,
|
||||
nome: Normalizador.nome(r['nome_completo'])
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
# Colunas que esta operação NÃO tem — a tela precisa DIZER isso.
|
||||
#
|
||||
# O esquema das gade_entregas_* varia por operação (só `nota_fiscal` é
|
||||
# garantida). Quando falta `status`, o APARELHO sai vazio para TODO MUNDO — e um
|
||||
# romaneio em que ninguém recebe aparelho, entregue calado, é pior do que um
|
||||
# aviso feio na tela.
|
||||
def colunas_ausentes
|
||||
@colunas_ausentes ||= COLUNAS.keys - existentes
|
||||
end
|
||||
|
||||
def utilizavel?
|
||||
(ESSENCIAIS - existentes).empty?
|
||||
end
|
||||
|
||||
def label
|
||||
Operacao.label(@tabela)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def conn
|
||||
ActiveRecord::Base.connection
|
||||
end
|
||||
|
||||
def existentes
|
||||
@existentes ||= conn.columns(@tabela).map(&:name)
|
||||
end
|
||||
|
||||
def registros(lista)
|
||||
return [] unless utilizavel?
|
||||
|
||||
valores = lista.map { |n| conn.quote(n) }.join(', ')
|
||||
sql = "SELECT #{selects} FROM #{conn.quote_table_name(@tabela)} g " \
|
||||
"WHERE g.nota_fiscal IN (#{valores})"
|
||||
conn.select_all(sql).to_a
|
||||
end
|
||||
|
||||
# Coluna que não existir vira NULL com o mesmo alias, preservando a forma do
|
||||
# resultado — mesmo padrão de SimpliRoute::PlanilhaCarga#selects. As chaves de
|
||||
# COLUNAS são whitelist fixa (não há entrada de usuário no SQL).
|
||||
def selects
|
||||
COLUNAS.map do |coluna, apelido|
|
||||
existentes.include?(coluna) ? "g.#{coluna} AS #{apelido}" : "CAST(NULL AS text) AS #{apelido}"
|
||||
end.join(', ')
|
||||
end
|
||||
end
|
||||
end
|
||||
215
app/services/romaneios/importador.rb
Normal file
215
app/services/romaneios/importador.rb
Normal file
@@ -0,0 +1,215 @@
|
||||
# app/services/romaneios/importador.rb
|
||||
#
|
||||
# O ÚNICO lugar que sabe reconciliar um plano recebido com o romaneio já editado.
|
||||
# Os dois caminhos de entrada (API do SimpliRoute e upload do .xlsx) entregam a
|
||||
# mesma forma de linha e caem aqui — senão a mesma parada importada por caminhos
|
||||
# diferentes viraria duas.
|
||||
#
|
||||
# ── A REGRA (dirty tracking POR CAMPO, não por linha) ───────────────────────
|
||||
# 1. Identidade = chave_origem (NF, ou digest do título+endereço). Veículo e ordem
|
||||
# NÃO entram: eles mudam quando o plano é reotimizado e a parada continua sendo
|
||||
# a mesma entrega.
|
||||
# 2. Linha que já existe: `origem` (o snapshot do plano) é SEMPRE atualizada; os
|
||||
# campos são reescritos SÓ se o operador não os tiver tocado.
|
||||
# 3. Linha nova: insere.
|
||||
# 4. Linha que sumiu do plano: sem edição humana, apaga; com edição, marca
|
||||
# `removido_em` — apagar levaria junto o trabalho sem deixar rastro na tela.
|
||||
# 5. NF repetida no plano: fica a primeira, as outras são reportadas.
|
||||
#
|
||||
# Por que POR CAMPO e não um "bloqueado" na linha inteira: a edição comum é "o
|
||||
# operador corrigiu um telefone". Travar a linha congelaria um endereço velho quando
|
||||
# o SimpliRoute regeocodificasse, e o papel sairia com um endereço que o motorista
|
||||
# não acha. Por campo é a única regra em que "corrigi o telefone" não significa
|
||||
# "também respondo pelo endereço".
|
||||
module Romaneios
|
||||
class Importador
|
||||
Resultado = Struct.new(:novos, :atualizados, :preservados, :saidos, :duplicados,
|
||||
:veiculos, :colunas_ausentes, keyword_init: true) do
|
||||
def to_h
|
||||
super.transform_values { |v| v.is_a?(Array) ? v : v.to_i }
|
||||
end
|
||||
|
||||
# Frase que a tela mostra depois de importar. Números soltos não dizem se
|
||||
# deu certo; esta linha diz.
|
||||
def resumo
|
||||
partes = ["#{novos} nova(s)", "#{atualizados} atualizada(s)"]
|
||||
partes << "#{preservados} mantida(s) por edição" if preservados.to_i.positive?
|
||||
partes << "#{saidos} saíram do plano" if saidos.to_i.positive?
|
||||
partes << "#{duplicados} NF repetida(s) no plano" if duplicados.to_i.positive?
|
||||
partes.join(' · ')
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(romaneio:, linhas:, usuario: nil, origem: nil)
|
||||
@romaneio = romaneio
|
||||
@recebidas = Array(linhas)
|
||||
@usuario = usuario
|
||||
@origem = origem
|
||||
end
|
||||
|
||||
def aplicar!
|
||||
novas, duplicados = deduplicar(@recebidas)
|
||||
dados = enriquecer(novas)
|
||||
|
||||
novos = atualizados = preservados = saidos = 0
|
||||
vistas = []
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
existentes = @romaneio.romaneio_linhas.index_by(&:chave_origem)
|
||||
|
||||
dados.each do |linha|
|
||||
vistas << linha[:chave_origem]
|
||||
registro = existentes[linha[:chave_origem]]
|
||||
|
||||
if registro.nil?
|
||||
criar(linha)
|
||||
novos += 1
|
||||
else
|
||||
preservados += atualizar(registro, linha)
|
||||
atualizados += 1
|
||||
end
|
||||
end
|
||||
|
||||
saidos = remover_ausentes(existentes, vistas)
|
||||
sincronizar_veiculos(dados)
|
||||
|
||||
@romaneio.update!(
|
||||
origem: @origem || @romaneio.origem,
|
||||
importado_em: Time.current,
|
||||
importado_por: @usuario || @romaneio.importado_por
|
||||
)
|
||||
end
|
||||
|
||||
# Fora do bloco de propósito: `return` dentro de uma transaction do Rails 7
|
||||
# COMMITA em silêncio, o que é uma pegadinha esperando um leitor distraído.
|
||||
Resultado.new(
|
||||
novos: novos, atualizados: atualizados, preservados: preservados,
|
||||
saidos: saidos, duplicados: duplicados,
|
||||
veiculos: dados.map { |l| l[:veiculo] }.uniq.size,
|
||||
colunas_ausentes: @colunas_ausentes || []
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# A primeira ocorrência vence. Plano duplicado no SimpliRoute gera visita nova
|
||||
# com a MESMA reference — repetir a parada no papel faria o motorista levar dois
|
||||
# pacotes para o mesmo endereço.
|
||||
def deduplicar(linhas)
|
||||
vistas = {}
|
||||
repetidas = 0
|
||||
|
||||
linhas.each do |linha|
|
||||
chave = RomaneioLinha.chave_para(nota_fiscal: linha[:nota_fiscal],
|
||||
titulo: linha[:titulo], endereco: linha[:endereco])
|
||||
if vistas.key?(chave)
|
||||
repetidas += 1
|
||||
next
|
||||
end
|
||||
vistas[chave] = linha.merge(chave_origem: chave)
|
||||
end
|
||||
|
||||
[vistas.values, repetidas]
|
||||
end
|
||||
|
||||
# Aplica o que a operação sabe por cima do que o plano trouxe.
|
||||
# Precedência (decidida com o usuário): operação > plano.
|
||||
def enriquecer(linhas)
|
||||
tabela = @romaneio.operacao_tabela.to_s
|
||||
return linhas if tabela.empty?
|
||||
|
||||
gade = EnriquecimentoGade.new(tabela)
|
||||
@colunas_ausentes = gade.colunas_ausentes
|
||||
dados = gade.para_notas(linhas.map { |l| l[:nota_fiscal] })
|
||||
|
||||
linhas.map do |linha|
|
||||
extra = dados[linha[:nota_fiscal].to_s] || {}
|
||||
linha.merge(
|
||||
aparelho: extra[:aparelho].to_s,
|
||||
# A coluna `telefones` da operação é a fonte completa; o "TEL:" das
|
||||
# anotações do plano só entra quando ela não tem o número.
|
||||
telefones: extra[:telefones].to_s.empty? ? linha[:telefones].to_s : extra[:telefones],
|
||||
nome: extra[:nome].to_s.empty? ? linha[:nome].to_s : extra[:nome]
|
||||
)
|
||||
end
|
||||
rescue ArgumentError
|
||||
# Operação removida do banco entre a criação do romaneio e a reimportação:
|
||||
# segue com o que o plano trouxe, e a tela avisa.
|
||||
@colunas_ausentes = EnriquecimentoGade::COLUNAS.keys
|
||||
linhas
|
||||
end
|
||||
|
||||
def campos(linha)
|
||||
{ 'nota_fiscal' => linha[:nota_fiscal].to_s, 'nome' => linha[:nome].to_s,
|
||||
'endereco' => linha[:endereco].to_s, 'aparelho' => linha[:aparelho].to_s,
|
||||
'telefones' => linha[:telefones].to_s }
|
||||
end
|
||||
|
||||
def criar(linha)
|
||||
valores = campos(linha)
|
||||
@romaneio.romaneio_linhas.create!(
|
||||
valores.merge(
|
||||
veiculo: linha[:veiculo], ordem: linha[:ordem],
|
||||
chave_origem: linha[:chave_origem], origem: valores, campos_editados: []
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
# Devolve quantos campos foram PRESERVADOS por terem edição humana.
|
||||
def atualizar(registro, linha)
|
||||
valores = campos(linha)
|
||||
preservados = 0
|
||||
|
||||
valores.each do |campo, valor|
|
||||
if registro.editado?(campo)
|
||||
preservados += 1
|
||||
next
|
||||
end
|
||||
registro[campo] = valor
|
||||
end
|
||||
|
||||
# Veículo e ordem são a verdade do roteiro, não conteúdo do documento —
|
||||
# seguem o plano mesmo numa linha editada.
|
||||
registro.veiculo = linha[:veiculo]
|
||||
registro.ordem = linha[:ordem]
|
||||
registro.origem = valores
|
||||
registro.removido_em = nil
|
||||
registro.save!
|
||||
|
||||
preservados
|
||||
end
|
||||
|
||||
def remover_ausentes(existentes, vistas)
|
||||
ausentes = existentes.except(*vistas).values
|
||||
return 0 if ausentes.empty?
|
||||
|
||||
limpas, editadas = ausentes.partition { |l| l.campos_editados.blank? }
|
||||
RomaneioLinha.where(id: limpas.map(&:id)).delete_all if limpas.any?
|
||||
RomaneioLinha.where(id: editadas.map(&:id)).update_all(removido_em: Time.current) if editadas.any?
|
||||
|
||||
ausentes.size
|
||||
end
|
||||
|
||||
# Cria a folha de cada veículo novo e reordena as páginas. O `motorista` já
|
||||
# preenchido NÃO é tocado: ele é digitado na tela, não vem do plano (o export
|
||||
# real traz a coluna vazia em todas as linhas).
|
||||
def sincronizar_veiculos(linhas)
|
||||
ordem = linhas.map { |l| l[:veiculo] }.uniq.sort
|
||||
existentes = @romaneio.romaneio_veiculos.index_by(&:veiculo)
|
||||
|
||||
ordem.each_with_index do |veiculo, i|
|
||||
registro = existentes[veiculo] ||
|
||||
@romaneio.romaneio_veiculos.build(veiculo: veiculo)
|
||||
registro.posicao = i
|
||||
motorista = linhas.find { |l| l[:veiculo] == veiculo && l[:motorista].to_s != '' }
|
||||
registro.motorista = motorista[:motorista] if motorista && registro.motorista.to_s.empty?
|
||||
registro.save!
|
||||
end
|
||||
|
||||
# Veículo que saiu do plano e ficou sem nenhuma parada não tem folha para
|
||||
# imprimir.
|
||||
sobrando = existentes.keys - ordem
|
||||
@romaneio.romaneio_veiculos.where(veiculo: sobrando).destroy_all if sobrando.any?
|
||||
end
|
||||
end
|
||||
end
|
||||
87
app/services/romaneios/normalizador.rb
Normal file
87
app/services/romaneios/normalizador.rb
Normal file
@@ -0,0 +1,87 @@
|
||||
# app/services/romaneios/normalizador.rb
|
||||
#
|
||||
# Parsing compartilhado entre as duas fontes de plano (API e .xlsx). Existe para os
|
||||
# dois caminhos entregarem EXATAMENTE a mesma forma de linha ao Romaneios::Importador
|
||||
# — se cada um normalizasse do seu jeito, o mesmo plano importado por caminhos
|
||||
# diferentes geraria linhas com chave_origem diferente e a reconciliação duplicaria
|
||||
# tudo na primeira reimportação.
|
||||
#
|
||||
# Namespace no plural (Romaneios::) para não colidir com a constante do model
|
||||
# Romaneio.
|
||||
module Romaneios
|
||||
module Normalizador
|
||||
# "NF 90507 - CICERA BESERRA DOS ANJOS" -> ['90507', 'CICERA BESERRA DOS ANJOS']
|
||||
NF_TITULO = /\ANF\s+(\d+)\s*-\s*(.+)\z/i
|
||||
|
||||
# "TEL: (11) 99262-0594 / (11) 99824-8099" — o plano guarda o telefone dentro
|
||||
# das Anotações, em texto livre. Pegamos TODOS os que casarem: uma parada com
|
||||
# dois números precisa dos dois no papel (o motorista liga do portão).
|
||||
TEL_ANOTACAO = /\(?\d{2}\)?\s*\d{4,5}-?\d{4}/
|
||||
|
||||
module_function
|
||||
|
||||
# Devolve [nf, nome]. Título fora do padrão vira [nil, título] — não é erro:
|
||||
# plano avulso e de inclusão às vezes chegam sem o prefixo "NF n - ".
|
||||
def nf_e_nome(titulo)
|
||||
texto = titulo.to_s.strip
|
||||
m = NF_TITULO.match(texto)
|
||||
return [m[1], m[2].strip] if m
|
||||
|
||||
[nil, texto]
|
||||
end
|
||||
|
||||
# O SimpliRoute devolve o endereço com sufixo ", Brazil"; o documento impresso
|
||||
# nunca teve isso. Caixa alta porque é como o romaneio sai desde sempre.
|
||||
def endereco(valor)
|
||||
valor.to_s
|
||||
.gsub(/,\s*Brazil\s*\z/i, '')
|
||||
.strip
|
||||
.gsub(/\s+/, ' ')
|
||||
.upcase
|
||||
end
|
||||
|
||||
# Telefones a partir das anotações do plano. Só é usado quando a operação não
|
||||
# tem o número — a coluna `telefones` da gade_entregas_* é a fonte preferida
|
||||
# (ver Romaneios::EnriquecimentoGade).
|
||||
def telefones_de_anotacao(anotacoes)
|
||||
texto = anotacoes.to_s
|
||||
return '' unless texto.match?(/TEL/i)
|
||||
|
||||
texto.scan(TEL_ANOTACAO).map(&:strip).uniq.join(' / ')
|
||||
end
|
||||
|
||||
def nome(valor)
|
||||
valor.to_s.strip.gsub(/\s+/, ' ').upcase
|
||||
end
|
||||
|
||||
# 'SIM' quando o paciente é NOVO na operação; vazio caso contrário.
|
||||
# É o que a segunda planilha do programa Python fazia à mão.
|
||||
def aparelho_por_status(status_gade)
|
||||
status_gade.to_s.strip.casecmp('NOVO').zero? ? 'SIM' : ''
|
||||
end
|
||||
|
||||
# Uma linha crua do plano (de qualquer fonte) vira a forma canônica que o
|
||||
# Importador consome.
|
||||
def linha(veiculo:, ordem:, titulo:, endereco:, anotacoes: nil,
|
||||
nota_fiscal: nil, tracking_id: nil, motorista: nil)
|
||||
# `endereco` aqui é a variável local do kwarg — o método homônimo continua
|
||||
# acessível com parênteses, mas renomeamos para ninguém tropeçar.
|
||||
endereco_cru = endereco
|
||||
nf_titulo, nome_titulo = nf_e_nome(titulo)
|
||||
nf = nota_fiscal.to_s.strip
|
||||
nf = nf_titulo.to_s if nf.empty?
|
||||
|
||||
{
|
||||
veiculo: veiculo.to_s.strip,
|
||||
ordem: ordem.to_i,
|
||||
motorista: motorista.to_s.strip,
|
||||
nota_fiscal: nf.to_s,
|
||||
nome: nome(nome_titulo),
|
||||
endereco: endereco(endereco_cru),
|
||||
telefones: telefones_de_anotacao(anotacoes),
|
||||
tracking_id: tracking_id.to_s,
|
||||
titulo: titulo.to_s
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
153
app/services/romaneios/plano_do_dia.rb
Normal file
153
app/services/romaneios/plano_do_dia.rb
Normal file
@@ -0,0 +1,153 @@
|
||||
# app/services/romaneios/plano_do_dia.rb
|
||||
#
|
||||
# Busca o plano de UMA data e devolve as paradas na forma canônica do
|
||||
# Romaneios::Normalizador — a mesma que Romaneios::PlanoPlanilha entrega.
|
||||
#
|
||||
# ⚠️ NÃO está verificado se a visita de /v1/routes/visits/ carrega veículo/ordem, e
|
||||
# isso não é testável na máquina de desenvolvimento (sem token, sem .env). Por isso
|
||||
# aqui existe uma ESCADA DE QUEDA explícita, e não um caminho feliz único:
|
||||
#
|
||||
# a) rotas da API trazem vehicle + lista ordenada de visitas -> origem :api
|
||||
# b) as visitas trazem alguma chave de veículo/ordem -> origem :api
|
||||
# c) nada disso (ou sem token) -> ESPELHO local
|
||||
# d) espelho vazio na data -> NotFound, com a
|
||||
# mensagem que manda o operador usar o .xlsx do plano
|
||||
#
|
||||
# A rung (c) não é consolo: o romaneio é impresso ANTES do dia começar, e ela cobre
|
||||
# "plano publicado e já sincronizado, mas a API fora do ar" — a falha mais provável.
|
||||
# Rode bin/sondar_plano_do_dia no servidor para fechar (a)/(b).
|
||||
module Romaneios
|
||||
class PlanoDoDia
|
||||
# Chaves candidatas nos hashes da API. Só as usamos se vierem PREENCHIDAS —
|
||||
# a API devolve chave existente com valor nil o tempo todo.
|
||||
CHAVES_VEICULO = %w[vehicle vehicle_name vehicle_id].freeze
|
||||
CHAVES_MOTORISTA = %w[driver driver_name].freeze
|
||||
CHAVES_ORDEM = %w[order sequence position route_order].freeze
|
||||
|
||||
attr_reader :origem
|
||||
|
||||
def initialize(data:, client: nil)
|
||||
@data = data.to_date
|
||||
@client = client
|
||||
@origem = nil
|
||||
end
|
||||
|
||||
def linhas
|
||||
@linhas ||= carregar
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def carregar
|
||||
linhas = da_api
|
||||
return linhas if linhas.any?
|
||||
|
||||
linhas = do_espelho
|
||||
return linhas if linhas.any?
|
||||
|
||||
raise SimpliRoute::NotFound,
|
||||
"O plano de #{@data.strftime('%d/%m/%Y')} não está disponível no SimpliRoute " \
|
||||
'nem no espelho de rastreio. Envie a planilha do plano (.xlsx).'
|
||||
end
|
||||
|
||||
# ── (a) e (b) ────────────────────────────────────────────────────────────
|
||||
def da_api
|
||||
return [] unless SimpliRoute.configurado?
|
||||
|
||||
cliente = @client || SimpliRoute::Client.new
|
||||
visitas = cliente.visitas_da_data(@data)
|
||||
return [] if visitas.empty?
|
||||
|
||||
linhas = por_rotas(cliente, visitas)
|
||||
linhas = por_visitas(visitas) if linhas.empty?
|
||||
@origem = 'api' if linhas.any?
|
||||
linhas
|
||||
rescue SimpliRoute::Error
|
||||
# Token ausente/expirado ou API fora: cai para o espelho, que é a mesma
|
||||
# informação já sincronizada.
|
||||
[]
|
||||
end
|
||||
|
||||
# (a) A rota traz o veículo e a lista de visitas na ordem do roteiro.
|
||||
def por_rotas(cliente, visitas)
|
||||
rotas = cliente.rotas_da_data(@data)
|
||||
return [] if rotas.empty?
|
||||
|
||||
por_id = visitas.index_by { |v| v['id'] }
|
||||
linhas = []
|
||||
|
||||
rotas.each do |rota|
|
||||
veiculo = primeiro_valor(rota, CHAVES_VEICULO)
|
||||
next if veiculo.empty?
|
||||
|
||||
ids = Array(rota['visits']).map { |v| v.is_a?(Hash) ? v['id'] : v }
|
||||
ids = visitas.select { |v| v['route'].to_s == rota['id'].to_s }.map { |v| v['id'] } if ids.empty?
|
||||
|
||||
ids.each_with_index do |id, i|
|
||||
visita = por_id[id]
|
||||
next if visita.nil?
|
||||
|
||||
linhas << monta(visita, veiculo, primeiro_valor(rota, CHAVES_MOTORISTA), i + 1)
|
||||
end
|
||||
end
|
||||
|
||||
linhas
|
||||
end
|
||||
|
||||
# (b) A visita traz o veículo direto; a ordem sai de `order`/`sequence` ou,
|
||||
# na falta, do horário estimado de chegada.
|
||||
def por_visitas(visitas)
|
||||
com_veiculo = visitas.reject { |v| primeiro_valor(v, CHAVES_VEICULO).empty? }
|
||||
return [] if com_veiculo.empty?
|
||||
|
||||
com_veiculo.group_by { |v| primeiro_valor(v, CHAVES_VEICULO) }.flat_map do |veiculo, lista|
|
||||
ordenadas(lista).each_with_index.map do |visita, i|
|
||||
monta(visita, veiculo, primeiro_valor(visita, CHAVES_MOTORISTA), i + 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def ordenadas(lista)
|
||||
lista.sort_by.with_index do |v, i|
|
||||
chave = CHAVES_ORDEM.filter_map { |k| v[k] }.first
|
||||
[chave.nil? ? Float::INFINITY : chave.to_i, v['estimated_time_arrival'].to_s, i]
|
||||
end
|
||||
end
|
||||
|
||||
def monta(visita, veiculo, motorista, ordem)
|
||||
Normalizador.linha(
|
||||
veiculo: veiculo, ordem: ordem, motorista: motorista,
|
||||
titulo: visita['title'], endereco: visita['address'],
|
||||
anotacoes: visita['notes'], nota_fiscal: visita['reference'],
|
||||
tracking_id: visita['tracking_id']
|
||||
)
|
||||
end
|
||||
|
||||
def primeiro_valor(hash, chaves)
|
||||
chaves.filter_map { |k| hash[k] }.map(&:to_s).find { |v| !v.strip.empty? }.to_s.strip
|
||||
end
|
||||
|
||||
# ── (c) Espelho local ────────────────────────────────────────────────────
|
||||
# `db_reem_simplerout_2026` já tem driver, vehicle, title, address, notes e eta.
|
||||
# Só leitura, como sempre.
|
||||
def do_espelho
|
||||
registros = Entrega.da_conta_gade
|
||||
.no_periodo(@data, @data)
|
||||
.where.not(vehicle: [nil, ''])
|
||||
.order(:vehicle, :eta)
|
||||
.to_a
|
||||
return [] if registros.empty?
|
||||
|
||||
@origem = 'espelho'
|
||||
registros.group_by(&:vehicle).flat_map do |veiculo, lista|
|
||||
lista.each_with_index.map do |e, i|
|
||||
Normalizador.linha(
|
||||
veiculo: veiculo, ordem: i + 1, motorista: e.driver,
|
||||
titulo: e.title, endereco: e.address, anotacoes: e.notes,
|
||||
nota_fiscal: e.reference_id, tracking_id: e.tracking_id
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
140
app/services/romaneios/plano_planilha.rb
Normal file
140
app/services/romaneios/plano_planilha.rb
Normal file
@@ -0,0 +1,140 @@
|
||||
# app/services/romaneios/plano_planilha.rb
|
||||
#
|
||||
# Lê o .xlsx do PLANO exportado do SimpliRoute e devolve as linhas na mesma forma
|
||||
# que SimpliRoute::PlanoDoDia — é o caminho B do romaneio, para quando a API não
|
||||
# responde ou o plano do dia ainda não foi publicado.
|
||||
#
|
||||
# ⚠️ O arquivo NÃO é persistido em lugar nenhum. Ele tem nome, endereço e telefone
|
||||
# de paciente; o ActiveStorage aqui é configurado condicionalmente e não há política
|
||||
# de retenção para pendurar isso. Lemos do tempfile e descartamos.
|
||||
require 'roo'
|
||||
|
||||
module Romaneios
|
||||
class PlanoPlanilha
|
||||
class Erro < StandardError; end
|
||||
|
||||
# Cabeçalhos aceitos, já normalizados (sem acento, minúsculo, espaço colapsado).
|
||||
#
|
||||
# ⚠️ Casamos por NOME e nunca por posição: o export do cliente varia. Medido no
|
||||
# arquivo real (SimpliRoute_Plan_ 2026-08-31.xlsx, 2047 linhas / 72 veículos):
|
||||
# a coluna "Motorista" vem VAZIA em todas as linhas e NÃO existem as colunas
|
||||
# "Aparelho", "Pessoa de contato" nem "Telefone de contato" — que o programa
|
||||
# Python original lia. Faltar qualquer uma delas é o caso NORMAL, não erro.
|
||||
#
|
||||
# "id de referencua" é um typo do próprio SimpliRoute; aceitamos os dois porque
|
||||
# eles podem corrigir a qualquer momento e aí o romaneio pararia de achar a NF.
|
||||
COLUNAS = {
|
||||
veiculo: ['veiculo', 'vehicle'],
|
||||
motorista: ['motorista', 'driver'],
|
||||
indice: ['indice de rota', 'route index'],
|
||||
ordem: ['ordem de entrega', 'delivery order'],
|
||||
titulo: ['titulo', 'title'],
|
||||
endereco: ['endereco', 'address'],
|
||||
anotacoes: ['anotacoes', 'notes'],
|
||||
nota_fiscal: ['id de referencua', 'id de referencia', 'reference id', 'nota fiscal']
|
||||
}.freeze
|
||||
|
||||
TAMANHO_MAXIMO = 20 * 1024 * 1024
|
||||
LINHAS_MAXIMO = 20_000
|
||||
ASSINATURA_ZIP = "PK\x03\x04".b.freeze
|
||||
|
||||
# `entrada` é o tempfile do upload (ou um caminho, nos specs).
|
||||
def initialize(entrada)
|
||||
@caminho = caminho_de(entrada)
|
||||
validar_arquivo!
|
||||
end
|
||||
|
||||
def linhas
|
||||
@linhas ||= montar
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def caminho_de(entrada)
|
||||
return entrada.tempfile.path if entrada.respond_to?(:tempfile)
|
||||
return entrada.path if entrada.respond_to?(:path)
|
||||
|
||||
entrada.to_s
|
||||
end
|
||||
|
||||
# Guardas antes de entregar o arquivo para a roo: um .xlsx é um zip, e um zip
|
||||
# inesperado (ou gigante) vira consumo de memória sem limite dentro do processo
|
||||
# que atende a requisição.
|
||||
def validar_arquivo!
|
||||
raise Erro, 'Envie a planilha do plano (.xlsx).' unless File.exist?(@caminho)
|
||||
raise Erro, 'A planilha passa de 20 MB — não parece ser o plano do dia.' if File.size(@caminho) > TAMANHO_MAXIMO
|
||||
|
||||
assinatura = File.binread(@caminho, 4)
|
||||
return if assinatura == ASSINATURA_ZIP
|
||||
|
||||
raise Erro, 'O arquivo não é um .xlsx. Baixe o plano do SimpliRoute em Excel e envie de novo.'
|
||||
end
|
||||
|
||||
def montar
|
||||
planilha = Roo::Excelx.new(@caminho)
|
||||
cabecalho = mapear_colunas(planilha.row(1))
|
||||
|
||||
if cabecalho[:veiculo].nil?
|
||||
raise Erro, 'A planilha não tem a coluna "Veículo" — confira se é o plano ' \
|
||||
'do SimpliRoute e não a planilha de carga.'
|
||||
end
|
||||
|
||||
total = planilha.last_row.to_i
|
||||
raise Erro, "A planilha tem #{total} linhas — acima do limite de #{LINHAS_MAXIMO}." if total > LINHAS_MAXIMO
|
||||
|
||||
registros = (2..total).filter_map { |i| montar_linha(planilha.row(i), cabecalho, i) }
|
||||
ordenar(registros)
|
||||
ensure
|
||||
planilha&.close if planilha.respond_to?(:close)
|
||||
end
|
||||
|
||||
# { chave => índice da coluna }. Coluna ausente simplesmente não entra no hash.
|
||||
def mapear_colunas(cabecalho)
|
||||
normalizados = Array(cabecalho).map { |c| normalizar_cabecalho(c) }
|
||||
|
||||
COLUNAS.each_with_object({}) do |(chave, aceitos), memo|
|
||||
idx = normalizados.index { |c| aceitos.include?(c) }
|
||||
memo[chave] = idx if idx
|
||||
end
|
||||
end
|
||||
|
||||
# Sem acento, minúsculo, espaço colapsado — o export alterna maiúscula/minúscula
|
||||
# e o acento varia com a versão do arquivo.
|
||||
def normalizar_cabecalho(valor)
|
||||
I18n.transliterate(valor.to_s).downcase.strip.gsub(/\s+/, ' ')
|
||||
end
|
||||
|
||||
def montar_linha(linha, cabecalho, numero_da_linha)
|
||||
valor = ->(chave) { cabecalho[chave] ? linha[cabecalho[chave]] : nil }
|
||||
|
||||
veiculo = valor.call(:veiculo).to_s.strip
|
||||
titulo = valor.call(:titulo).to_s.strip
|
||||
ender = valor.call(:endereco).to_s.strip
|
||||
# Linha sem veículo, ou sem nada que identifique a parada, é lixo de rodapé
|
||||
# do próprio export — pular em silêncio é o certo.
|
||||
return nil if veiculo.empty? || (titulo.empty? && ender.empty?)
|
||||
|
||||
Normalizador.linha(
|
||||
veiculo: veiculo,
|
||||
# A ordem definitiva é atribuída em #ordenar; aqui guardamos o que o
|
||||
# arquivo trouxer para poder ordenar depois.
|
||||
ordem: valor.call(:ordem) || valor.call(:indice) || numero_da_linha,
|
||||
titulo: titulo,
|
||||
endereco: ender,
|
||||
anotacoes: valor.call(:anotacoes),
|
||||
nota_fiscal: valor.call(:nota_fiscal),
|
||||
motorista: valor.call(:motorista)
|
||||
)
|
||||
end
|
||||
|
||||
# Ordem final: por veículo, pela ordem de entrega do plano. Renumeramos de 1 a N
|
||||
# dentro de cada veículo porque é isso que vira a coluna "#" do papel — e o
|
||||
# "Índice de rota" do export não é 1..N por veículo.
|
||||
def ordenar(registros)
|
||||
registros.group_by { |r| r[:veiculo] }.flat_map do |_veiculo, linhas|
|
||||
linhas.sort_by.with_index { |r, i| [r[:ordem].to_i, i] }
|
||||
.each_with_index.map { |r, i| r.merge(ordem: i + 1) }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -64,6 +64,22 @@ module SimpliRoute
|
||||
Array(get(caminho))
|
||||
end
|
||||
|
||||
# Rotas PLANEJADAS da data (Array de hashes). É aqui que costuma morar o par
|
||||
# veículo/motorista, que a visita talvez não carregue — e o romaneio precisa do
|
||||
# veículo para saber quantas folhas imprimir.
|
||||
#
|
||||
# ⚠️ NÃO VERIFICADO contra a API real: não há token na máquina de
|
||||
# desenvolvimento. Por isso é best-effort (endpoint inexistente devolve [], não
|
||||
# explode) e por isso Romaneios::PlanoDoDia tem uma escada de queda até o
|
||||
# espelho local. Rode `bin/sondar_plano_do_dia` no servidor antes de confiar
|
||||
# nesta chamada — e anote o resultado no cabeçalho daquele script, como foi
|
||||
# feito em bin/sondar_busca_nf.
|
||||
def rotas_da_data(data)
|
||||
Array(get("/v1/routes/routes/?planned_date=#{data.to_date.iso8601}"))
|
||||
rescue Error
|
||||
[]
|
||||
end
|
||||
|
||||
# Resolve o `id` numérico (usado na URL de escrita) a partir de uma Entrega
|
||||
# do espelho local, que só tem tracking_id (SR...) + reference_id (NF) +
|
||||
# planned_date. Casa pelo tracking_id; se não achar, tenta pela NF.
|
||||
|
||||
48
app/views/admin/romaneios/_avisos.html.erb
Normal file
48
app/views/admin/romaneios/_avisos.html.erb
Normal file
@@ -0,0 +1,48 @@
|
||||
<%# Divergências explicadas ONDE elas aparecem — não em documentação. %>
|
||||
<% multifolha = @romaneio.veiculos_multifolha %>
|
||||
|
||||
<% if @colunas_ausentes.include?('status') && @romaneio.operacao_tabela.present? %>
|
||||
<div class="p-4 bg-amber-900/20 border border-amber-500/40 rounded-xl text-amber-200/90 text-sm flex items-start gap-2">
|
||||
<span class="text-base leading-none">⚠️</span>
|
||||
<p>
|
||||
A operação <strong><%= @romaneio.operacao_label %></strong> não tem a coluna
|
||||
<strong>STATUS</strong>, então não dá para saber quem é paciente novo — a coluna
|
||||
<strong>APARELHO</strong> saiu vazia para todos e precisa ser preenchida à mão.
|
||||
Um romaneio em que ninguém recebe aparelho, entregue calado, vira aparelho não entregue.
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @colunas_ausentes.include?('telefones') && @romaneio.operacao_tabela.present? %>
|
||||
<div class="p-4 bg-amber-900/20 border border-amber-500/40 rounded-xl text-amber-200/90 text-sm flex items-start gap-2">
|
||||
<span class="text-base leading-none">☎️</span>
|
||||
<p>
|
||||
A operação <strong><%= @romaneio.operacao_label %></strong> não tem a coluna
|
||||
<strong>TELEFONES</strong>. O que aparece na tabela veio do “TEL:” das anotações do
|
||||
plano, que nem sempre traz todos os números.
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @romaneio.operacao_tabela.blank? %>
|
||||
<div class="p-4 bg-blue-900/20 border border-blue-500/40 rounded-xl text-blue-200/90 text-sm flex items-start gap-2">
|
||||
<span class="text-base leading-none">ℹ️</span>
|
||||
<p>
|
||||
Este romaneio foi importado <strong>sem operação vinculada</strong>: a coluna APARELHO
|
||||
não é preenchida sozinha e o telefone vem só das anotações do plano.
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if multifolha.any? %>
|
||||
<div class="p-4 bg-blue-900/20 border border-blue-500/40 rounded-xl text-blue-200/90 text-sm flex items-start gap-2">
|
||||
<span class="text-base leading-none">🖨️</span>
|
||||
<p>
|
||||
<% multifolha.each do |veiculo, total| %>
|
||||
<strong><%= veiculo %></strong> tem <%= total %> paradas →
|
||||
sai em <%= @romaneio.folhas_de(veiculo) %> folhas<%= ',' unless veiculo == multifolha.keys.last %>
|
||||
<% end %>
|
||||
— cada folha vai com o próprio bloco de assinatura, e a numeração continua entre elas.
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
13
app/views/admin/romaneios/_celula.html.erb
Normal file
13
app/views/admin/romaneios/_celula.html.erb
Normal file
@@ -0,0 +1,13 @@
|
||||
<%# Uma célula editável + o marcador de "editado". %>
|
||||
<div class="flex items-center gap-1">
|
||||
<input type="text"
|
||||
value="<%= linha.public_send(campo) %>"
|
||||
data-romaneio-target="celula"
|
||||
data-action="change->romaneio#salvarCelula"
|
||||
data-linha-id="<%= linha.id %>"
|
||||
data-campo="<%= campo %>"
|
||||
data-original="<%= linha.public_send(campo) %>"
|
||||
class="w-full bg-transparent border border-transparent hover:border-white/10 focus:border-orange-500
|
||||
focus:bg-[#1a1a1a] rounded-lg px-2 py-2 text-gray-100 focus:outline-none min-h-[40px]">
|
||||
<%= render 'marcador', linha: linha, campo: campo %>
|
||||
</div>
|
||||
55
app/views/admin/romaneios/_controles.html.erb
Normal file
55
app/views/admin/romaneios/_controles.html.erb
Normal file
@@ -0,0 +1,55 @@
|
||||
<%# Seletor de veículo + os dois campos de cabeçalho da folha. %>
|
||||
<div class="bg-[#111] border border-white/5 rounded-2xl p-4 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5">Veículo</label>
|
||||
<div class="flex gap-2">
|
||||
<%# ‹ › ao lado do select: com 72 veículos, uma lista lateral vira armadilha
|
||||
de scroll, mas percorrer o plano carro a carro é o que o operador faz. %>
|
||||
<button type="button" data-action="romaneio#anterior" aria-label="Veículo anterior"
|
||||
class="px-3 min-w-[48px] min-h-[48px] bg-[#1a1a1a] border border-white/10 rounded-xl
|
||||
text-white hover:bg-[#242424]">‹</button>
|
||||
<select data-romaneio-target="veiculo" data-action="romaneio#trocarVeiculo"
|
||||
class="flex-1 bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white
|
||||
focus:outline-none focus:border-orange-500 min-h-[48px]">
|
||||
<% @veiculos.each do |v| %>
|
||||
<option value="<%= v.veiculo %>" <%= 'selected' if v.veiculo == @veiculo %>>
|
||||
<%= v.veiculo %> (<%= @romaneio.contagem_por_veiculo[v.veiculo].to_i %>)
|
||||
</option>
|
||||
<% end %>
|
||||
</select>
|
||||
<button type="button" data-action="romaneio#proximo" aria-label="Próximo veículo"
|
||||
class="px-3 min-w-[48px] min-h-[48px] bg-[#1a1a1a] border border-white/10 rounded-xl
|
||||
text-white hover:bg-[#242424]">›</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
PLANO <span class="text-gray-500 font-normal">— texto do canto direito do PDF</span>
|
||||
</label>
|
||||
<input type="text" value="<%= @romaneio.rotulo_plano %>"
|
||||
data-romaneio-target="rotulo" data-action="change->romaneio#salvarRotulo"
|
||||
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white
|
||||
focus:outline-none focus:border-orange-500 min-h-[48px]">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
Motorista <span class="text-gray-500 font-normal">— só na tela, não sai impresso</span>
|
||||
</label>
|
||||
<% atual = @veiculos.find { |v| v.veiculo == @veiculo } %>
|
||||
<input type="text" value="<%= atual&.motorista %>" placeholder="Quem leva este carro"
|
||||
data-romaneio-target="motorista" data-action="change->romaneio#salvarMotorista"
|
||||
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white
|
||||
placeholder-gray-600 focus:outline-none focus:border-orange-500 min-h-[48px]">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5">Buscar nesta folha</label>
|
||||
<input type="search" placeholder="NF, nome ou endereço…"
|
||||
data-action="input->romaneio#filtrar"
|
||||
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white
|
||||
placeholder-gray-600 focus:outline-none focus:border-orange-500 min-h-[48px]">
|
||||
</div>
|
||||
</div>
|
||||
12
app/views/admin/romaneios/_marcador.html.erb
Normal file
12
app/views/admin/romaneios/_marcador.html.erb
Normal file
@@ -0,0 +1,12 @@
|
||||
<%# Ponto laranja = campo tocado por humano, e por isso protegido na reimportação.
|
||||
O botão ↺ devolve o valor do plano (e destrava o campo de novo) — sem ele, uma
|
||||
tecla acidental seria permanente. %>
|
||||
<span class="shrink-0 w-4 flex items-center justify-center"
|
||||
data-romaneio-marcador="<%= linha.id %>-<%= campo %>">
|
||||
<% if linha.editado?(campo) %>
|
||||
<button type="button" title="Editado — não será sobrescrito na reimportação. Clique para restaurar o valor do plano."
|
||||
data-action="romaneio#restaurar"
|
||||
data-linha-id="<%= linha.id %>" data-campo="<%= campo %>"
|
||||
class="text-orange-400 hover:text-orange-200 text-xs leading-none">●</button>
|
||||
<% end %>
|
||||
</span>
|
||||
21
app/views/admin/romaneios/_removidas.html.erb
Normal file
21
app/views/admin/romaneios/_removidas.html.erb
Normal file
@@ -0,0 +1,21 @@
|
||||
<%# Paradas que saíram do plano mas tinham edição humana. Não são impressas, e não
|
||||
são apagadas: quem decide o que fazer com elas é o operador. %>
|
||||
<% if linhas.any? %>
|
||||
<div class="bg-[#111] border border-amber-500/30 rounded-2xl p-4">
|
||||
<h2 class="text-amber-200 font-semibold text-sm mb-1">
|
||||
<%= linhas.size %> parada(s) saíram do plano
|
||||
</h2>
|
||||
<p class="text-gray-400 text-xs mb-3">
|
||||
Elas tinham edição sua, então foram guardadas em vez de apagadas — e não entram no PDF.
|
||||
Se voltarem ao plano numa próxima importação, voltam sozinhas para a folha.
|
||||
</p>
|
||||
<ul class="space-y-1 text-sm">
|
||||
<% linhas.each do |linha| %>
|
||||
<li class="text-gray-500 line-through">
|
||||
<span class="text-gray-400"><%= linha.veiculo %></span> ·
|
||||
NF <%= linha.nota_fiscal.presence || '—' %> · <%= linha.nome %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
59
app/views/admin/romaneios/_tabela.html.erb
Normal file
59
app/views/admin/romaneios/_tabela.html.erb
Normal file
@@ -0,0 +1,59 @@
|
||||
<%# Tabela editável do veículo selecionado.
|
||||
SEM paginação de propósito: acima de 1366px a lista inteira é o certo — esconder
|
||||
item em monitor grande atrapalha até o Ctrl+F do navegador, e conferir romaneio é
|
||||
exatamente procurar uma NF na lista. A busca acima filtra sem esconder nada.
|
||||
|
||||
<input> por célula, e não contenteditable: colar do Excel num contenteditable
|
||||
traz HTML junto. %>
|
||||
<div class="bg-[#111] border border-white/5 rounded-2xl overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-white/5 flex items-center justify-between">
|
||||
<h2 class="text-white font-semibold text-sm"><%= linhas.size %> paradas</h2>
|
||||
<span class="text-xs text-gray-500" data-romaneio-target="status">Alterações salvam sozinhas</span>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-[#1a1a1a] text-gray-400 text-xs uppercase tracking-wider">
|
||||
<tr>
|
||||
<th class="text-left px-2 py-2 w-10">#</th>
|
||||
<th class="text-left px-2 py-2 w-28">Nota fiscal</th>
|
||||
<th class="text-left px-2 py-2 w-56">Nome</th>
|
||||
<th class="text-left px-2 py-2">Endereço</th>
|
||||
<th class="text-center px-2 py-2 w-24">Aparelho</th>
|
||||
<th class="text-left px-2 py-2 w-44">Telefones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/5">
|
||||
<% linhas.each_with_index do |linha, i| %>
|
||||
<tr data-romaneio-target="linha"
|
||||
data-busca="<%= [linha.nota_fiscal, linha.nome, linha.endereco].join(' ').downcase %>">
|
||||
<td class="px-2 py-1 text-gray-500 text-xs"><%= i + 1 %></td>
|
||||
<% %w[nota_fiscal nome endereco].each do |campo| %>
|
||||
<td class="px-1 py-1">
|
||||
<%= render 'celula', linha: linha, campo: campo %>
|
||||
</td>
|
||||
<% end %>
|
||||
|
||||
<%# APARELHO só assume 'SIM' ou vazio: um botão de dois estados é alvo de
|
||||
toque de verdade e não deixa digitar um terceiro valor. %>
|
||||
<td class="px-1 py-1 text-center">
|
||||
<button type="button"
|
||||
data-action="romaneio#alternarAparelho"
|
||||
data-linha-id="<%= linha.id %>"
|
||||
data-valor="<%= linha.aparelho %>"
|
||||
class="<%= linha.aparelho == 'SIM' ? 'bg-green-600/20 text-green-300 border-green-600/40' : 'bg-[#1a1a1a] text-gray-500 border-white/10' %>
|
||||
w-full min-h-[40px] rounded-lg border font-semibold text-xs hover:border-orange-500 transition-colors">
|
||||
<%= linha.aparelho.presence || '—' %>
|
||||
</button>
|
||||
<%= render 'marcador', linha: linha, campo: 'aparelho' %>
|
||||
</td>
|
||||
|
||||
<td class="px-1 py-1">
|
||||
<%= render 'celula', linha: linha, campo: 'telefones' %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
144
app/views/admin/romaneios/index.html.erb
Normal file
144
app/views/admin/romaneios/index.html.erb
Normal file
@@ -0,0 +1,144 @@
|
||||
<%# app/views/admin/romaneios/index.html.erb %>
|
||||
<div class="space-y-6">
|
||||
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Romaneio de entrega</h1>
|
||||
<p class="text-gray-400 text-sm mt-0.5">
|
||||
Monta o "CONTROLE DE ENTREGA" — uma folha por veículo, assinada pelo motorista na retirada
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%= render 'shared/flash' %>
|
||||
|
||||
<div class="p-4 bg-blue-900/20 border border-blue-500/40 rounded-xl text-blue-200/90 text-sm flex items-start gap-2">
|
||||
<span class="text-base leading-none">ℹ️</span>
|
||||
<p>
|
||||
A coluna <strong>APARELHO</strong> é marcada sozinha para quem está como
|
||||
<strong>NOVO</strong> na operação do mês, e o <strong>telefone</strong> vem da própria
|
||||
operação — não é preciso subir uma segunda planilha. Tudo continua editável na tela
|
||||
antes de imprimir.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%# Importação %>
|
||||
<div class="bg-[#111] border border-white/5 rounded-2xl p-5 space-y-4">
|
||||
<h2 class="text-white font-semibold">Importar plano</h2>
|
||||
|
||||
<%= form_with url: admin_romaneios_path, method: :post, multipart: true,
|
||||
data: { turbo: false }, class: 'space-y-4' do %>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5">Data do plano</label>
|
||||
<input type="date" name="planned_date" required value="<%= @data_padrao %>"
|
||||
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white
|
||||
focus:outline-none focus:border-orange-500 min-h-[48px]">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
Operação do mês
|
||||
<span class="text-gray-500 font-normal">(APARELHO e telefone)</span>
|
||||
</label>
|
||||
<select name="operacao_tabela"
|
||||
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white
|
||||
focus:outline-none focus:border-orange-500 min-h-[48px]">
|
||||
<option value="">Sem operação — só o que o plano trouxer</option>
|
||||
<% @operacoes_agrupadas.each do |titulo, ops| %>
|
||||
<optgroup label="<%= titulo %>">
|
||||
<% ops.each do |op| %>
|
||||
<option value="<%= op[:tabela] %>"><%= op[:label] %></option>
|
||||
<% end %>
|
||||
</optgroup>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
PLANO <span class="text-gray-500 font-normal">(canto direito do PDF)</span>
|
||||
</label>
|
||||
<input type="text" name="rotulo_plano" placeholder="Ex.: EMAD 09.2026"
|
||||
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white
|
||||
placeholder-gray-600 focus:outline-none focus:border-orange-500 min-h-[48px]">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-3 sm:items-center">
|
||||
<button type="submit"
|
||||
class="px-6 py-3 bg-[#f97316] hover:bg-orange-500 text-white font-semibold rounded-xl
|
||||
transition-colors min-h-[48px] whitespace-nowrap">
|
||||
<%= icone :buscar, cor: nil %> Buscar no SimpliRoute
|
||||
</button>
|
||||
|
||||
<%# Reserva: o plano do dia pode ainda não estar publicado. %>
|
||||
<details class="flex-1">
|
||||
<summary class="cursor-pointer text-sm text-gray-400 hover:text-white select-none py-3">
|
||||
Ou enviar a planilha do plano (.xlsx)
|
||||
</summary>
|
||||
<div class="mt-2 flex flex-col sm:flex-row gap-3">
|
||||
<input type="file" name="arquivo" accept=".xlsx"
|
||||
class="flex-1 text-sm text-gray-300 file:mr-3 file:py-2.5 file:px-4 file:rounded-lg
|
||||
file:border-0 file:bg-[#1a1a1a] file:text-gray-200 file:cursor-pointer">
|
||||
<button type="submit" formaction="<%= importar_planilha_admin_romaneios_path %>"
|
||||
class="px-5 py-3 bg-[#1a1a1a] hover:bg-[#242424] text-white border border-white/10
|
||||
rounded-xl min-h-[48px] whitespace-nowrap">
|
||||
<%= icone :baixar, cor: nil %> Enviar planilha
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
Use quando o plano do dia ainda não estiver publicado no SimpliRoute.
|
||||
O arquivo é lido na hora e não fica guardado no sistema.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%# Romaneios já importados %>
|
||||
<div class="bg-[#111] border border-white/5 rounded-2xl overflow-hidden">
|
||||
<% if @romaneios.empty? %>
|
||||
<div class="p-8 text-center">
|
||||
<p class="text-gray-400">Nenhum romaneio ainda.</p>
|
||||
<p class="text-gray-500 text-sm mt-1">Escolha a data do plano acima e clique em “Buscar no SimpliRoute”.</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-[#1a1a1a] text-gray-400 text-xs uppercase tracking-wider">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-3">Data</th>
|
||||
<th class="text-left px-4 py-3">Plano</th>
|
||||
<th class="text-left px-4 py-3">Operação</th>
|
||||
<th class="text-left px-4 py-3">Veículos</th>
|
||||
<th class="text-left px-4 py-3">Origem</th>
|
||||
<th class="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/5">
|
||||
<% @romaneios.each do |r| %>
|
||||
<tr class="hover:bg-white/5">
|
||||
<td class="px-4 py-3 text-white"><%= l r.planned_date, format: '%d/%m/%Y' %></td>
|
||||
<td class="px-4 py-3 text-gray-200"><%= r.rotulo_plano.presence || '—' %></td>
|
||||
<td class="px-4 py-3 text-gray-400"><%= r.operacao_label || '—' %></td>
|
||||
<td class="px-4 py-3 text-gray-400"><%= r.romaneio_veiculos.size %></td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="px-2 py-0.5 rounded-md text-xs bg-white/5 text-gray-300"><%= r.origem %></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right whitespace-nowrap">
|
||||
<%= link_to rotulo(:editar, 'Abrir'), admin_romaneio_path(r),
|
||||
class: 'text-orange-400 hover:text-orange-300 font-medium' %>
|
||||
<%# button_to em BLOCO: sem bloco ele vira <input type="submit">, que
|
||||
não aceita o SVG do ícone dentro (ver ApplicationHelper#rotulo). %>
|
||||
<%= button_to admin_romaneio_path(r), method: :delete,
|
||||
form: { data: { turbo_confirm: 'Excluir este romaneio e todas as edições feitas nele?' },
|
||||
class: 'inline-block ml-3' },
|
||||
class: 'text-gray-500 hover:text-red-400' do %>
|
||||
<%= icone :excluir, cor: nil %> Excluir
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
89
app/views/admin/romaneios/show.html.erb
Normal file
89
app/views/admin/romaneios/show.html.erb
Normal file
@@ -0,0 +1,89 @@
|
||||
<%# app/views/admin/romaneios/show.html.erb
|
||||
Editor do romaneio: tabela editável à esquerda, o PDF REAL à direita.
|
||||
|
||||
A prévia é um <iframe> de mesma origem, e isso não é preferência: o CSP deste
|
||||
app está em modo enforcing com `object_src :none` (config/initializers/
|
||||
content_security_policy.rb), o que bloqueia <embed> e <object>; não há
|
||||
`frame_src`, então ele cai em `default_src :self` e o iframe passa. Uma data:
|
||||
URL seria barrada pelo mesmo fallback. %>
|
||||
<div class="space-y-5"
|
||||
data-controller="romaneio"
|
||||
data-romaneio-url-value="<%= admin_romaneio_path(@romaneio) %>"
|
||||
data-romaneio-pdf-url-value="<%= pdf_admin_romaneio_path(@romaneio) %>"
|
||||
data-romaneio-veiculo-value="<%= @veiculo %>">
|
||||
|
||||
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">
|
||||
Romaneio de <%= l @romaneio.planned_date, format: '%d/%m/%Y' %>
|
||||
</h1>
|
||||
<p class="text-gray-400 text-sm mt-0.5">
|
||||
<%= @romaneio.operacao_label || 'Sem operação vinculada' %> ·
|
||||
<%= @romaneio.contagem_por_veiculo.values.sum %> paradas em
|
||||
<%= @veiculos.size %> veículo(s) ·
|
||||
importado de <%= @romaneio.origem %>
|
||||
<%= "em #{l @romaneio.importado_em, format: '%d/%m %H:%M'}" if @romaneio.importado_em %>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<%= link_to rotulo(:baixar, 'Baixar todos'),
|
||||
pdf_admin_romaneio_path(@romaneio),
|
||||
class: 'px-4 py-2.5 bg-[#f97316] hover:bg-orange-500 text-white font-semibold rounded-xl min-h-[44px] inline-flex items-center' %>
|
||||
<%# Em BLOCO: button_to sem bloco gera <input type="submit">, que não aceita
|
||||
o SVG do ícone (ver ApplicationHelper#rotulo). %>
|
||||
<%= button_to reimportar_admin_romaneio_path(@romaneio, veiculo: @veiculo), method: :post,
|
||||
form: { data: { turbo_confirm: 'Rebuscar o plano? Os campos que você editou são preservados.' } },
|
||||
class: 'px-4 py-2.5 bg-[#1a1a1a] hover:bg-[#242424] text-white border border-white/10 rounded-xl min-h-[44px]' do %>
|
||||
<%= icone :restaurar, cor: nil %> Reimportar plano
|
||||
<% end %>
|
||||
<%= link_to 'Voltar', admin_romaneios_path,
|
||||
class: 'px-4 py-2.5 text-gray-400 hover:text-white min-h-[44px] inline-flex items-center' %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= render 'shared/flash' %>
|
||||
|
||||
<% if @veiculos.empty? %>
|
||||
<div class="bg-[#111] border border-white/5 rounded-2xl p-8 text-center">
|
||||
<p class="text-gray-400">Este romaneio está sem paradas.</p>
|
||||
<p class="text-gray-500 text-sm mt-1">Clique em “Reimportar plano” ou envie a planilha do plano na tela anterior.</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<%= render 'avisos' %>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_minmax(0,560px)] gap-5 items-start">
|
||||
<div class="space-y-4">
|
||||
<%= render 'controles' %>
|
||||
<%= render 'tabela', linhas: @linhas %>
|
||||
<%= render 'removidas', linhas: @removidas %>
|
||||
</div>
|
||||
|
||||
<%# Prévia — no celular vira um bloco recolhido: 6 colunas em tela pequena
|
||||
exigiriam zoom, e o que importa ali é editar, não conferir o layout. %>
|
||||
<div class="lg:sticky lg:top-4">
|
||||
<details class="lg:hidden bg-[#111] border border-white/5 rounded-2xl p-4 mb-4">
|
||||
<summary class="cursor-pointer text-white font-medium select-none min-h-[44px] flex items-center">
|
||||
<%= icone :visualizar %> Ver prévia do PDF
|
||||
</summary>
|
||||
<%= link_to 'Abrir o PDF deste veículo',
|
||||
pdf_admin_romaneio_path(@romaneio, veiculo: @veiculo),
|
||||
class: 'block mt-3 text-orange-400 hover:text-orange-300', target: '_blank', rel: 'noopener' %>
|
||||
</details>
|
||||
|
||||
<div class="hidden lg:block bg-[#111] border border-white/5 rounded-2xl p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-white font-semibold text-sm">Prévia — <%= @veiculo %></h2>
|
||||
<button type="button" data-action="romaneio#atualizarPreview"
|
||||
class="text-xs text-gray-400 hover:text-white px-3 py-2 rounded-lg hover:bg-white/5">
|
||||
<%= icone :restaurar, tamanho: 'w-4 h-4' %> Atualizar prévia
|
||||
</button>
|
||||
</div>
|
||||
<iframe data-romaneio-target="preview"
|
||||
src="<%= pdf_admin_romaneio_path(@romaneio, veiculo: @veiculo) %>"
|
||||
class="w-full h-[78vh] rounded-xl border border-white/10 bg-white"
|
||||
title="Pré-visualização do romaneio do veículo <%= @veiculo %>"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -56,7 +56,8 @@
|
||||
|
||||
<%# Administração — a seção inteira some quando nenhum item dela está liberado. %>
|
||||
<% itens_admin = %w[admin.usuarios admin.perfis admin.configuracoes admin.auditoria
|
||||
admin.planilha_simpli_route admin.edicao_lancamento] %>
|
||||
admin.planilha_simpli_route admin.edicao_lancamento
|
||||
admin.romaneio] %>
|
||||
<% if itens_admin.any? { |chave| current_user.pode?(chave) } %>
|
||||
<div class="pt-4 mt-4 border-t border-[#2a2a2a]">
|
||||
<p class="px-3 text-xs text-gray-600 uppercase tracking-wider mb-2">Administração</p>
|
||||
@@ -75,6 +76,9 @@
|
||||
<% if current_user.pode?('admin.planilha_simpli_route') %>
|
||||
<%= nav_link_to 'Planilha SimpliRoute', admin_planilha_simpli_route_path, icon: :planilha %>
|
||||
<% end %>
|
||||
<% if current_user.pode?('admin.romaneio') %>
|
||||
<%= nav_link_to 'Romaneio de entrega', admin_romaneios_path, icon: :imprimir %>
|
||||
<% end %>
|
||||
<% if current_user.pode?('admin.edicao_lancamento') %>
|
||||
<%= nav_link_to 'Editar Lançamento', admin_edicao_lancamento_path, icon: :editar_lancamento %>
|
||||
<% end %>
|
||||
|
||||
76
bin/docker-boot
Executable file
76
bin/docker-boot
Executable file
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Boot do container do Rails (deploy e todo restart do Docker).
|
||||
#
|
||||
# POR QUE ISSO VIROU UM SCRIPT: antes era um `command:` de uma linha só no
|
||||
# docker-compose.yml. Com `restart: unless-stopped` o boot passou a rodar
|
||||
# sozinho — depois de queda, de reboot do servidor e de cada deploy — e um
|
||||
# passo que falha em silêncio vira loop de reinício sem ninguém entender por
|
||||
# quê. Aqui cada etapa diz o que está fazendo no log do container
|
||||
# (`docker compose logs -f app`).
|
||||
#
|
||||
# ORDEM DOS PASSOS (não troque sem ler o motivo):
|
||||
# 1. relógio — só imprime, para diagnóstico (ver comentário abaixo).
|
||||
# 2. pid órfão — se o container morreu sem limpar, o Puma se recusa a subir.
|
||||
# 3. gems — o volume bundle_cache SOMBREIA os gems da imagem, então
|
||||
# gem nova no Gemfile não chega no container só com --build.
|
||||
# 4. migrations — db:prepare: cria o banco se não existir, roda as migrations
|
||||
# pendentes e só faz seed em banco recém-criado (idempotente).
|
||||
# 5. cron — grava o crontab do config/schedule.rb e sobe o daemon.
|
||||
# 6. Puma — processo em primeiro plano (é ele que segura o container).
|
||||
set -uo pipefail
|
||||
|
||||
log() { echo "[boot] $*"; }
|
||||
|
||||
# ── 1. Relógio ───────────────────────────────────────────────────────────────
|
||||
# O container NÃO tem relógio próprio: ele lê o clock do kernel do HOST. Não
|
||||
# adianta rodar NTP aqui dentro (exigiria CAP_SYS_TIME e mexeria na hora do
|
||||
# servidor inteiro) — quem sincroniza é o host, via chrony/NTS; ver
|
||||
# deploy/ntp-seguro.sh. O que é do container é só o FUSO (TZ), fixado no
|
||||
# Dockerfile. Se a linha abaixo aparecer em UTC, o rebuild da imagem não pegou.
|
||||
log "relógio do container: $(date '+%Y-%m-%d %H:%M:%S %Z (%z)') | UTC: $(date -u '+%H:%M:%S')"
|
||||
|
||||
# ── 2. PID órfão do Puma ─────────────────────────────────────────────────────
|
||||
rm -f tmp/pids/server.pid
|
||||
|
||||
# ── 3. Gems ──────────────────────────────────────────────────────────────────
|
||||
if ! bundle check >/dev/null 2>&1; then
|
||||
log "gem faltando no volume bundle_cache — rodando bundle install"
|
||||
bundle install || { log "ERRO: bundle install falhou"; exit 1; }
|
||||
fi
|
||||
|
||||
# ── 4. Migrations ────────────────────────────────────────────────────────────
|
||||
# O PostgreSQL é EXTERNO ao compose: num reboot do servidor o Rails pode subir
|
||||
# antes do banco aceitar conexão. Sem a espera abaixo o container sairia com
|
||||
# erro e o `restart: unless-stopped` ficaria reiniciando em loop — parecendo
|
||||
# problema de migration quando é só ordem de boot. 10 tentativas × 6s = 1 min.
|
||||
tentativas=10
|
||||
for i in $(seq 1 $tentativas); do
|
||||
log "migrations (db:prepare) — tentativa $i/$tentativas"
|
||||
if bundle exec rails db:prepare; then
|
||||
log "banco pronto e migrations aplicadas"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq "$tentativas" ]; then
|
||||
# Aqui é falha DE VERDADE (migration quebrada ou credencial errada): sair
|
||||
# com erro é melhor do que subir a aplicação contra um schema desatualizado.
|
||||
log "ERRO: db:prepare falhou após $tentativas tentativas — veja o erro acima"
|
||||
exit 1
|
||||
fi
|
||||
log "banco indisponível ou migration falhou; nova tentativa em 6s"
|
||||
sleep 6
|
||||
done
|
||||
|
||||
# ── 5. Cron (best-effort) ────────────────────────────────────────────────────
|
||||
# Best-effort de propósito: agendamento quebrado atrasa um relatório, mas NUNCA
|
||||
# pode impedir o sistema de subir. As variáveis do banco chegam ao job pelo
|
||||
# dotenv quando o rake carrega o Rails — o cron não precisa herdar o ENV.
|
||||
if bundle exec whenever --update-crontab && cron; then
|
||||
log "cron no ar (jobs do config/schedule.rb, no fuso ${TZ:-?})"
|
||||
else
|
||||
log "AVISO: cron/crontab indisponível (rode com --build) — seguindo sem agendamento"
|
||||
fi
|
||||
|
||||
# ── 6. Puma ──────────────────────────────────────────────────────────────────
|
||||
log "subindo o Rails"
|
||||
exec bundle exec rails s -b 0.0.0.0
|
||||
149
bin/sondar_plano_do_dia
Executable file
149
bin/sondar_plano_do_dia
Executable file
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env ruby
|
||||
# frozen_string_literal: true
|
||||
#
|
||||
# Descobre COMO a API do SimpliRoute expõe o par veículo/ordem de um plano — que é
|
||||
# o que o romaneio ("CONTROLE DE ENTREGA") precisa para saber quantas folhas
|
||||
# imprimir e em que sequência.
|
||||
#
|
||||
# ── POR QUE ESTE SCRIPT EXISTE ──────────────────────────────────────────────
|
||||
# Romaneios::PlanoDoDia tem uma escada de queda:
|
||||
# (a) /v1/routes/routes/ traz vehicle + lista ordenada de visitas
|
||||
# (b) a própria visita traz alguma chave de veículo/ordem
|
||||
# (c) nada disso -> espelho local (db_reem_simplerout_2026), por vehicle + eta
|
||||
# (d) espelho vazio -> manda o operador subir o .xlsx do plano
|
||||
# As rungs (a) e (b) NUNCA foram verificadas: não há SIMPLIROUTE_TOKEN na máquina
|
||||
# de desenvolvimento. Este script fecha isso.
|
||||
#
|
||||
# ── RESULTADO DA RODADA DE __/__/____ ───────────────────────────────────────
|
||||
# (preencher depois de rodar, como está em bin/sondar_busca_nf)
|
||||
# [ ] /v1/routes/routes/ existe? status: ___ rotas: ___
|
||||
# [ ] a rota traz vehicle? ___
|
||||
# [ ] a rota embute a lista de visitas? ___
|
||||
# [ ] a visita traz vehicle/order? ___
|
||||
# [ ] veículos pela API x pelo espelho: ___ x ___
|
||||
# => rung usada: ___
|
||||
#
|
||||
# SÓ LEITURA: o script faz apenas GET. Não altera nada.
|
||||
#
|
||||
# Rode NO SERVIDOR (é lá que vive o SIMPLIROUTE_TOKEN):
|
||||
#
|
||||
# SIMPLIROUTE_TOKEN=xxx bin/sondar_plano_do_dia --data 2026-08-31
|
||||
#
|
||||
# --data precisa ser um dia com plano PUBLICADO (senão a resposta vem vazia e não
|
||||
# prova nada).
|
||||
#
|
||||
# ⚠️ Por que olhar os VALORES e não só as chaves: a API é Django REST e devolve
|
||||
# chave existente com valor nil o tempo todo. "A visita tem a chave `vehicle`" NÃO
|
||||
# significa que ela venha preenchida — a prova é o valor.
|
||||
|
||||
require 'net/http'
|
||||
require 'json'
|
||||
require 'uri'
|
||||
require 'date'
|
||||
|
||||
BASE = ENV.fetch('SIMPLIROUTE_BASE_URL', 'https://api.simpliroute.com')
|
||||
TOKEN = ENV['SIMPLIROUTE_TOKEN'].to_s
|
||||
|
||||
CHAVES_INTERESSE = /vehicle|route|driver|order|sequence|position/i.freeze
|
||||
|
||||
def sair(msg)
|
||||
warn msg
|
||||
exit 1
|
||||
end
|
||||
|
||||
sair('Defina SIMPLIROUTE_TOKEN no ambiente.') if TOKEN.empty?
|
||||
|
||||
data = nil
|
||||
ARGV.each_with_index { |a, i| data = ARGV[i + 1] if a == '--data' }
|
||||
sair('Uso: bin/sondar_plano_do_dia --data <YYYY-MM-DD>') if data.to_s.empty?
|
||||
Date.parse(data) rescue sair("Data inválida: #{data}")
|
||||
|
||||
def get(caminho)
|
||||
uri = URI.join(BASE, caminho)
|
||||
req = Net::HTTP::Get.new(uri)
|
||||
req['Authorization'] = "Token #{TOKEN}"
|
||||
req['Accept'] = 'application/json'
|
||||
|
||||
inicio = Time.now
|
||||
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https',
|
||||
open_timeout: 10, read_timeout: 120) do |http|
|
||||
http.request(req)
|
||||
end
|
||||
[res.code.to_i, res.body.to_s, Time.now - inicio]
|
||||
rescue StandardError => e
|
||||
[0, e.message, Time.now - inicio]
|
||||
end
|
||||
|
||||
def json(corpo)
|
||||
JSON.parse(corpo)
|
||||
rescue JSON::ParserError
|
||||
nil
|
||||
end
|
||||
|
||||
def amostra(hash)
|
||||
hash.select { |k, _| k.to_s.match?(CHAVES_INTERESSE) }
|
||||
.map { |k, v| " #{k} = #{v.inspect.slice(0, 90)}" }
|
||||
end
|
||||
|
||||
puts "== SONDAGEM DO PLANO DE #{data} =="
|
||||
puts
|
||||
|
||||
# ── 1. Visitas do dia ───────────────────────────────────────────────────────
|
||||
codigo, corpo, seg = get("/v1/routes/visits/?planned_date=#{data}")
|
||||
puts "[1] GET /v1/routes/visits/?planned_date=#{data}"
|
||||
puts " HTTP #{codigo} · #{corpo.bytesize} bytes · #{seg.round(2)}s"
|
||||
|
||||
visitas = json(corpo)
|
||||
sair(' Resposta não é JSON — abortando.') unless visitas.is_a?(Array)
|
||||
puts " visitas: #{visitas.size}"
|
||||
sair(' Nenhuma visita nessa data. Escolha um dia com plano publicado.') if visitas.empty?
|
||||
|
||||
primeira = visitas.first
|
||||
puts " chaves: #{primeira.keys.sort.join(', ')}"
|
||||
puts ' chaves de interesse (com VALOR — chave nil não serve):'
|
||||
linhas = amostra(primeira)
|
||||
puts(linhas.empty? ? ' (nenhuma)' : linhas)
|
||||
|
||||
preenchidas = primeira.select { |k, v| k.to_s.match?(CHAVES_INTERESSE) && !v.nil? && v.to_s.strip != '' }
|
||||
puts " => rung (b) viável? #{preenchidas.keys.any? { |k| k.to_s.match?(/vehicle/i) } ? 'SIM' : 'NÃO'}"
|
||||
puts
|
||||
|
||||
# ── 2. Rotas do dia ─────────────────────────────────────────────────────────
|
||||
codigo, corpo, seg = get("/v1/routes/routes/?planned_date=#{data}")
|
||||
puts "[2] GET /v1/routes/routes/?planned_date=#{data}"
|
||||
puts " HTTP #{codigo} · #{corpo.bytesize} bytes · #{seg.round(2)}s"
|
||||
|
||||
rotas = json(corpo)
|
||||
if rotas.is_a?(Array) && rotas.any?
|
||||
rota = rotas.first
|
||||
puts " rotas: #{rotas.size}"
|
||||
puts " chaves: #{rota.keys.sort.join(', ')}"
|
||||
puts ' chaves de interesse:'
|
||||
puts(amostra(rota).then { |l| l.empty? ? ' (nenhuma)' : l })
|
||||
visits = rota['visits']
|
||||
puts " embute 'visits'? #{visits.nil? ? 'NÃO' : "SIM (#{Array(visits).size} itens, #{Array(visits).first.class})"}"
|
||||
tem_veiculo = %w[vehicle vehicle_name vehicle_id].any? { |k| rota[k].to_s.strip != '' }
|
||||
puts " => rung (a) viável? #{tem_veiculo && !visits.nil? ? 'SIM' : 'NÃO'}"
|
||||
else
|
||||
puts " sem rotas utilizáveis (corpo: #{corpo.slice(0, 200)})"
|
||||
puts ' => rung (a) NÃO viável'
|
||||
end
|
||||
puts
|
||||
|
||||
# ── 3. Cruzamento com o espelho ─────────────────────────────────────────────
|
||||
# Quantos veículos a API enxerga? O número tem que bater com o do espelho local;
|
||||
# se não bater, alguma rota está ficando de fora e o romaneio sairia incompleto.
|
||||
veiculos_api = visitas.filter_map { |v| %w[vehicle vehicle_name].filter_map { |k| v[k] }.first }
|
||||
.map(&:to_s).reject(&:empty?).uniq
|
||||
if rotas.is_a?(Array)
|
||||
veiculos_api |= rotas.filter_map { |r| %w[vehicle vehicle_name].filter_map { |k| r[k] }.first }
|
||||
.map(&:to_s).reject(&:empty?)
|
||||
end
|
||||
puts "[3] veículos distintos vistos pela API: #{veiculos_api.size}"
|
||||
puts " #{veiculos_api.sort.first(10).join(', ')}#{'…' if veiculos_api.size > 10}"
|
||||
puts
|
||||
puts ' Compare com o espelho, no console do app:'
|
||||
puts " Entrega.da_conta_gade.no_periodo(Date.parse('#{data}'), Date.parse('#{data}'))" \
|
||||
'.distinct.count(:vehicle)'
|
||||
puts
|
||||
puts 'Anote o resultado no cabeçalho deste arquivo antes de commitar.'
|
||||
@@ -130,6 +130,26 @@ Rails.application.routes.draw do
|
||||
get :baixar
|
||||
end
|
||||
|
||||
# Romaneio "CONTROLE DE ENTREGA": uma folha A4 paisagem por veículo, assinada
|
||||
# pelo motorista na retirada. O plano vem do SimpliRoute (API ou espelho de
|
||||
# rastreio); o .xlsx é o plano B para quando o plano do dia ainda não foi
|
||||
# publicado. As bases externas continuam somente leitura — o que gravamos são
|
||||
# as tabelas `romaneio*`, nossas.
|
||||
resources :romaneios, only: %i[index show create destroy] do
|
||||
collection do
|
||||
post :importar_planilha # caminho B: upload do .xlsx do plano
|
||||
end
|
||||
member do
|
||||
# ?veiculo=GADE_001 -> inline (é a prévia do editor); sem veiculo -> download
|
||||
# do romaneio inteiro. Uma rota só porque é o mesmo documento.
|
||||
get :pdf
|
||||
post :reimportar
|
||||
patch :atualizar_linha # JSON — um campo de uma parada
|
||||
patch :atualizar_veiculo # JSON — motorista da folha
|
||||
patch :atualizar_rotulo # JSON — o texto "PLANO:" do cabeçalho
|
||||
end
|
||||
end
|
||||
|
||||
# ── Notificações: quem recebe, o quê e por qual canal ──────────────────
|
||||
# Grupos e contatos são a lista de destinatários cadastrada à mão; eventos
|
||||
# ligam um gatilho aos grupos; a sessão do WhatsApp é o pareamento por QR.
|
||||
|
||||
33
db/migrate/20260827000001_create_romaneios.rb
Normal file
33
db/migrate/20260827000001_create_romaneios.rb
Normal file
@@ -0,0 +1,33 @@
|
||||
# Romaneio = UM plano do SimpliRoute importado para virar o "CONTROLE DE ENTREGA"
|
||||
# que o motorista assina ao retirar a carga.
|
||||
#
|
||||
# Chave (planned_date, operacao_tabela): o eixo de busca da API é a data, e a fonte
|
||||
# do enriquecimento (APARELHO/telefones) é exatamente UMA tabela gade_entregas_*.
|
||||
# Duas operações podem dividir a mesma data com veículos distintos, daí a dupla.
|
||||
#
|
||||
# `rotulo_plano` ("EMAD 09.2026") é APRESENTAÇÃO — sai no canto direito do PDF e o
|
||||
# operador reescreve à vontade. Por isso NÃO entra na chave: renomear o rótulo não
|
||||
# pode criar um segundo romaneio do mesmo dia.
|
||||
#
|
||||
# `operacao_tabela` é NOT NULL com default '' de propósito: no Postgres dois NULLs
|
||||
# são distintos entre si, e um índice único sobre coluna anulável deixaria passar
|
||||
# duplicata justamente no caso "romaneio sem operação escolhida".
|
||||
class CreateRomaneios < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :romaneios do |t|
|
||||
t.date :planned_date, null: false
|
||||
t.string :operacao_tabela, null: false, default: ''
|
||||
t.string :rotulo_plano, null: false, default: ''
|
||||
# 'api' (rotas do SimpliRoute) | 'espelho' (db_reem_simplerout_2026) |
|
||||
# 'planilha' (upload do .xlsx do plano)
|
||||
t.string :origem, null: false, default: 'api'
|
||||
t.datetime :importado_em
|
||||
t.references :importado_por, foreign_key: { to_table: :users }
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :romaneios, %i[planned_date operacao_tabela],
|
||||
unique: true, name: 'idx_romaneios_data_operacao_unico'
|
||||
end
|
||||
end
|
||||
26
db/migrate/20260827000002_create_romaneio_veiculos.rb
Normal file
26
db/migrate/20260827000002_create_romaneio_veiculos.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
# Uma FOLHA do romaneio: um veículo, uma página A4 paisagem.
|
||||
#
|
||||
# Motorista e ordem de página são atributos da FOLHA, não da linha de entrega.
|
||||
# Guardá-los em cada linha significaria um UPDATE de 30 linhas a cada tecla e uma
|
||||
# ambiguidade que o PDF não sabe resolver ("qual dos 30 nomes eu imprimo?").
|
||||
# É a mesma lição de 20260703000001 (vehicle desnormalizado em consolidacao_entregas)
|
||||
# → 20260703000002 (create_consolidacao_veiculos), no dia seguinte.
|
||||
#
|
||||
# ⚠️ O nome do motorista NÃO é impresso: o rodapé traz a linha "MOTORISTA: ____"
|
||||
# para preenchimento à mão (é assim que o documento é assinado hoje). O campo existe
|
||||
# para o operador saber, NA TELA, de quem é cada carro enquanto revisa.
|
||||
class CreateRomaneioVeiculos < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :romaneio_veiculos do |t|
|
||||
t.references :romaneio, null: false, foreign_key: true
|
||||
t.string :veiculo, null: false
|
||||
t.string :motorista, null: false, default: ''
|
||||
t.integer :posicao, null: false, default: 0 # ordem das páginas no PDF
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :romaneio_veiculos, %i[romaneio_id veiculo],
|
||||
unique: true, name: 'idx_romaneio_veiculo_unico'
|
||||
end
|
||||
end
|
||||
51
db/migrate/20260827000003_create_romaneio_linhas.rb
Normal file
51
db/migrate/20260827000003_create_romaneio_linhas.rb
Normal file
@@ -0,0 +1,51 @@
|
||||
# Uma PARADA do romaneio — vira uma linha da tabela do PDF.
|
||||
#
|
||||
# ⚠️ "linhas" e não "itens": "RomaneioItem".tableize dá `romaneio_items`, e o
|
||||
# inflector pt-BR do projeto (config/initializers/inflections.rb) não conhece
|
||||
# item/itens — ensiná-lo renomearia todo `item`/`items` do app. `RomaneioLinha`
|
||||
# pluraliza sozinho, e `linhas` já é a palavra que os serviços usam
|
||||
# (PlanilhaEntregas#linhas, PlanilhaCarga#linhas). Travado em
|
||||
# spec/models/table_names_spec.rb, que existe porque ConsolidacaoMotorista já
|
||||
# quebrou em produção por causa do inflector.
|
||||
#
|
||||
# `chave_origem` é a identidade ESTÁVEL entre reimportações: a NF quando existe,
|
||||
# senão "T:<md5(titulo|endereco)>". Sem esse fallback, duas linhas cujo "Título" não
|
||||
# casa o padrão "NF n - NOME" colidiriam no índice único e a importação inteira
|
||||
# estouraria. Veículo e ordem NÃO entram na chave: eles mudam quando o plano é
|
||||
# reotimizado, e a linha continua sendo a mesma entrega.
|
||||
#
|
||||
# `origem` = snapshot do que o PLANO trouxe na última importação (para o
|
||||
# diff e para o botão "restaurar do plano").
|
||||
# `campos_editados` = nomes dos campos que o operador sobrescreveu. É ISTO que
|
||||
# sobrevive à reimportação — dirty tracking POR CAMPO, não por
|
||||
# linha: "corrigi o telefone" não pode significar "também
|
||||
# respondo pelo endereço" e congelar um endereço velho.
|
||||
# `removido_em` = a linha saiu do plano mas tinha edição humana. Soft, porque
|
||||
# apagar levaria junto o trabalho sem deixar rastro na tela.
|
||||
class CreateRomaneioLinhas < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :romaneio_linhas do |t|
|
||||
t.references :romaneio, null: false, foreign_key: true
|
||||
t.string :veiculo, null: false
|
||||
t.integer :ordem, null: false, default: 0
|
||||
t.string :chave_origem, null: false
|
||||
|
||||
t.string :nota_fiscal, null: false, default: ''
|
||||
t.string :nome, null: false, default: ''
|
||||
t.text :endereco, null: false, default: ''
|
||||
t.string :aparelho, null: false, default: '' # 'SIM' ou ''
|
||||
t.string :telefones, null: false, default: ''
|
||||
|
||||
t.jsonb :origem, null: false, default: {}
|
||||
t.jsonb :campos_editados, null: false, default: []
|
||||
t.datetime :removido_em
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :romaneio_linhas, %i[romaneio_id chave_origem],
|
||||
unique: true, name: 'idx_romaneio_linha_chave_unico'
|
||||
add_index :romaneio_linhas, %i[romaneio_id veiculo ordem],
|
||||
name: 'idx_romaneio_linha_ordem'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,43 @@
|
||||
# Leva a chave nova `admin.romaneio` para os perfis de sistema JÁ SALVOS.
|
||||
#
|
||||
# Por que existe (mesma armadilha de 20260826000005): 20260826000003 é idempotente,
|
||||
# então uma permissão acrescentada ao catálogo DEPOIS daquele deploy nunca chega às
|
||||
# linhas de perfis_acesso. O sintoma é mudo — o item some do menu e a URL responde
|
||||
# "sem permissão" sem nada explicando o porquê.
|
||||
#
|
||||
# Diferença deliberada para 20260826000005: aqui é ADITIVO (`permissoes || '[...]'`)
|
||||
# em vez de reescrever a lista inteira. Reescrever é aceitável para o Administrador
|
||||
# ("acesso total" por definição); para o Gerente desfaria qualquer ajuste que o ADM
|
||||
# tenha feito na tela de Perfis.
|
||||
#
|
||||
# Gerente entra porque `admin.romaneio` é a mesma classe de ferramenta operacional
|
||||
# que `admin.planilha_simpli_route`, que ele já tem. Operador NÃO entra por padrão —
|
||||
# quem carrega o caminhão recebe a chave pela tela de Perfis, que existe para isso.
|
||||
class AddPermissaoRomaneioAosPerfis < ActiveRecord::Migration[7.1]
|
||||
CHAVE = 'admin.romaneio'.freeze
|
||||
|
||||
def up
|
||||
return unless table_exists?(:perfis_acesso)
|
||||
|
||||
execute(<<~SQL.squish)
|
||||
UPDATE perfis_acesso
|
||||
SET permissoes = permissoes || #{quote([CHAVE].to_json)}::jsonb,
|
||||
updated_at = NOW()
|
||||
WHERE sistema = true
|
||||
AND LOWER(nome) IN ('administrador', 'gerente')
|
||||
AND NOT (permissoes ? #{quote(CHAVE)})
|
||||
SQL
|
||||
end
|
||||
|
||||
# Reversível: tirar a chave devolve os perfis ao estado anterior sem tocar em
|
||||
# mais nada (o `-` de jsonb remove o elemento do array, se estiver lá).
|
||||
def down
|
||||
return unless table_exists?(:perfis_acesso)
|
||||
|
||||
execute(<<~SQL.squish)
|
||||
UPDATE perfis_acesso
|
||||
SET permissoes = permissoes - #{quote(CHAVE)}, updated_at = NOW()
|
||||
WHERE permissoes ? #{quote(CHAVE)}
|
||||
SQL
|
||||
end
|
||||
end
|
||||
135
deploy/ntp-seguro.sh
Executable file
135
deploy/ntp-seguro.sh
Executable file
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Relógio do SERVIDOR sincronizado com NTP autenticado (NTS).
|
||||
# Rodar UMA VEZ no servidor, como root: sudo bash deploy/ntp-seguro.sh
|
||||
#
|
||||
# ─── POR QUE NO HOST E NÃO NO CONTAINER ──────────────────────────────────────
|
||||
# Container não tem relógio próprio: ele lê o clock do kernel do host. Rodar
|
||||
# chrony/ntpd dentro do container exigiria CAP_SYS_TIME e mudaria a hora do
|
||||
# SERVIDOR INTEIRO a partir de dentro de um processo da aplicação — por isso
|
||||
# não se faz. Dentro do container a gente fixa só o FUSO (TZ, no Dockerfile);
|
||||
# a HORA vem daqui.
|
||||
#
|
||||
# ─── POR QUE NTS E NÃO NTP COMUM ─────────────────────────────────────────────
|
||||
# NTP puro é UDP sem autenticação: qualquer um no caminho pode responder no
|
||||
# lugar do servidor e empurrar a hora da máquina. Isso não é teórico para este
|
||||
# sistema — hora errada muda o dia da consolidação, o recorte do período
|
||||
# financeiro e a validade de sessão/cookie. NTS (RFC 8915) faz uma troca de
|
||||
# chaves por TLS (porta TCP 4460) e assina os pacotes NTP, então uma resposta
|
||||
# forjada é descartada. O `chronyc authdata` no fim mostra se está valendo.
|
||||
#
|
||||
# ─── REDE ────────────────────────────────────────────────────────────────────
|
||||
# Precisa de saída para: UDP 123 (NTP) e TCP 4460 (NTS-KE).
|
||||
# Se o firewall bloquear a 4460, o NTS não fecha e o chrony fica sem fonte.
|
||||
set -euo pipefail
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || { echo "ERRO: rode com sudo."; exit 1; }
|
||||
command -v apt-get >/dev/null || { echo "ERRO: script escrito para Debian/Ubuntu."; exit 1; }
|
||||
|
||||
echo "==> Hora ANTES:"; date; echo
|
||||
|
||||
# ── 1. chrony ────────────────────────────────────────────────────────────────
|
||||
# NTS só existe no chrony >= 4.0 (Debian 11+ / Ubuntu 22.04+ já têm).
|
||||
if ! command -v chronyd >/dev/null; then
|
||||
echo "==> Instalando chrony"
|
||||
apt-get update -qq
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y chrony
|
||||
fi
|
||||
|
||||
versao="$(chronyd --version 2>&1 | grep -oE '[0-9]+\.[0-9]+' | head -1)"
|
||||
if [ "${versao%%.*}" -lt 4 ]; then
|
||||
echo "ERRO: chrony $versao não fala NTS (precisa >= 4.0). Atualize o sistema."
|
||||
exit 1
|
||||
fi
|
||||
echo "==> chrony $versao (NTS disponível)"
|
||||
|
||||
# ── 2. Fontes NTS ────────────────────────────────────────────────────────────
|
||||
# Três fontes independentes de propósito: com uma só, ela cai e o relógio fica
|
||||
# à deriva sem ninguém notar; com três, o chrony ainda descarta a que divergir.
|
||||
CONF_NTS="$(cat <<'CONF'
|
||||
# Gerado por deploy/ntp-seguro.sh — fontes de tempo AUTENTICADAS (NTS).
|
||||
# `iburst`: acerta rápido no boot, em vez de levar minutos.
|
||||
server time.cloudflare.com iburst nts
|
||||
server nts.netnod.se iburst nts
|
||||
server ptbtime1.ptb.de iburst nts
|
||||
|
||||
# Cache das chaves NTS: sem isto, todo restart refaz a troca TLS e o relógio
|
||||
# demora a fechar quando a internet ainda não subiu.
|
||||
ntsdumpdir /var/lib/chrony
|
||||
|
||||
# Sincroniza também o relógio de hardware (RTC), que é de onde a máquina lê a
|
||||
# hora ao ligar — é o que evita voltar errado depois de um reboot/queda de luz.
|
||||
rtcsync
|
||||
|
||||
# makestep: corrige de uma vez (em vez de "escorregar" devagar) quando o erro
|
||||
# é grande, mas só nas 3 primeiras medições. É a linha certa para servidor
|
||||
# físico. SE ESTE SERVIDOR FOR VM QUE HIBERNA/PAUSA e a hora só aparece errada
|
||||
# depois de uma pausa longa, troque pela linha comentada abaixo — ela permite
|
||||
# o salto a qualquer momento (inclusive para trás, então saiba o que ganha):
|
||||
makestep 1.0 3
|
||||
# makestep 1.0 -1
|
||||
CONF
|
||||
)"
|
||||
|
||||
CHRONY_CONF=/etc/chrony/chrony.conf
|
||||
[ -f "$CHRONY_CONF" ] || CHRONY_CONF=/etc/chrony.conf
|
||||
|
||||
cp -n "$CHRONY_CONF" "$CHRONY_CONF.bak-ntp-seguro" 2>/dev/null || true
|
||||
|
||||
# Onde escrever: o Debian/Ubuntu moderno lê o diretório conf.d. Se essa linha
|
||||
# não existir na config, escrever lá seria IGNORADO EM SILÊNCIO — nesse caso a
|
||||
# gente anexa no próprio chrony.conf, entre marcadores, para continuar sendo
|
||||
# possível rodar o script de novo sem duplicar.
|
||||
if grep -qE '^\s*confdir\s+.*conf\.d' "$CHRONY_CONF" 2>/dev/null; then
|
||||
mkdir -p /etc/chrony/conf.d
|
||||
printf '%s\n' "$CONF_NTS" > /etc/chrony/conf.d/nts-reem.conf
|
||||
echo "==> Fontes NTS em /etc/chrony/conf.d/nts-reem.conf"
|
||||
else
|
||||
sed -i '/# >>> nts-reem >>>/,/# <<< nts-reem <<</d' "$CHRONY_CONF"
|
||||
{ echo "# >>> nts-reem >>>"; printf '%s\n' "$CONF_NTS"; echo "# <<< nts-reem <<<"; } >> "$CHRONY_CONF"
|
||||
echo "==> Fontes NTS anexadas em $CHRONY_CONF"
|
||||
fi
|
||||
|
||||
# ── 3. Desligar as fontes NÃO autenticadas ───────────────────────────────────
|
||||
# Deixar o `pool` padrão junto anularia o ganho: o chrony poderia se decidir
|
||||
# por uma fonte sem assinatura. Só comentamos (o .bak fica ao lado).
|
||||
sed -i -E 's/^([[:space:]]*(pool|server)[[:space:]]+.*)$/# [nts-reem] \1/' "$CHRONY_CONF"
|
||||
sed -i -E '/# >>> nts-reem >>>/,/# <<< nts-reem <<</ s/^# \[nts-reem\] (.*)$/\1/' "$CHRONY_CONF"
|
||||
for f in /etc/chrony/sources.d/*.sources; do
|
||||
[ -e "$f" ] || continue
|
||||
mv "$f" "$f.desativado-ntp-seguro"
|
||||
done
|
||||
|
||||
# ── 4. Um serviço de hora só ─────────────────────────────────────────────────
|
||||
# systemd-timesyncd e chrony brigam pelo relógio; o timesyncd não fala NTS.
|
||||
systemctl disable --now systemd-timesyncd 2>/dev/null || true
|
||||
systemctl enable --now chrony 2>/dev/null || systemctl enable --now chronyd
|
||||
|
||||
echo "==> Reiniciando chrony"
|
||||
systemctl restart chrony 2>/dev/null || systemctl restart chronyd
|
||||
sleep 5
|
||||
chronyc makestep >/dev/null 2>&1 || true
|
||||
|
||||
# ── 5. Conferência ───────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo "==> Fontes (o '^*' marca a que está valendo):"
|
||||
chronyc -N sources -v || true
|
||||
echo
|
||||
echo "==> NTS (queremos 'KeyID/Type/KLen' preenchido e Cook > 0 nas 3 linhas):"
|
||||
chronyc -N authdata || true
|
||||
echo
|
||||
echo "==> Estado:"; chronyc tracking || true
|
||||
timedatectl || true
|
||||
echo
|
||||
echo "==> Hora DEPOIS:"; date
|
||||
cat <<'FIM'
|
||||
|
||||
Pronto. O relógio agora se mantém sozinho, inclusive depois de reboot.
|
||||
|
||||
Se a hora tinha saltado, reinicie os containers para os processos longos
|
||||
pegarem o clock corrigido: docker compose restart
|
||||
|
||||
Para conferir depois, a qualquer momento:
|
||||
chronyc tracking # 'System time' deve ficar em milissegundos
|
||||
chronyc -N authdata # NTS ativo em cada fonte
|
||||
FIM
|
||||
@@ -17,27 +17,30 @@ services:
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
|
||||
RAILS_ENV: ${RAILS_ENV:-development}
|
||||
# Fuso do SISTEMA OPERACIONAL dentro do container (o do Rails vem do
|
||||
# config.time_zone). Já fixado no Dockerfile; repetido aqui para dar o
|
||||
# ajuste sem rebuild — basta TZ=... no .env. Ver deploy/ntp-seguro.sh:
|
||||
# a HORA em si é do host, isto aqui só define o fuso de leitura.
|
||||
TZ: ${TZ:-America/Sao_Paulo}
|
||||
volumes:
|
||||
- ".:/app"
|
||||
- "bundle_cache:/usr/local/bundle"
|
||||
# No boot/deploy, em ordem:
|
||||
# 1. db:prepare — cria o banco se não existir, roda migrations pendentes e
|
||||
# só faz seed se o banco acabou de ser criado (idempotente, seguro).
|
||||
# 2. whenever --update-crontab — grava no crontab os jobs do config/schedule.rb
|
||||
# (ex.: atualizar histórico a cada 30 min, 08h-18h).
|
||||
# 3. cron — sobe o daemon de cron (em background) que executa esses jobs.
|
||||
# As variáveis de ambiente do banco vêm do .env via dotenv-rails quando
|
||||
# o rake carrega o Rails, então o cron não precisa herdar o ENV.
|
||||
# 4. rails s — sobe a aplicação (processo em primeiro plano).
|
||||
# O passo do cron é "best-effort": se o crontab/cron não estiver disponível
|
||||
# (ex.: imagem antiga sem o pacote `cron`), apenas avisa no log e SEGUE —
|
||||
# nunca impede o Rails de subir. (Requer rebuild: docker compose up -d --build)
|
||||
# `bundle check || bundle install`: os gems ficam num VOLUME nomeado
|
||||
# (bundle_cache) que persiste e SOMBREIA os gems da imagem — então adicionar
|
||||
# uma gem nova ao Gemfile não chega no container só com `up`/`--build`. Este
|
||||
# passo instala o que faltar no volume no boot (idempotente e rápido quando já
|
||||
# está tudo instalado), evitando o Bundler::GemNotFound ao subir.
|
||||
command: bash -c "rm -f tmp/pids/server.pid && (bundle check || bundle install) && bundle exec rails db:prepare && { bundle exec whenever --update-crontab && cron || echo '[boot] AVISO cron/crontab indisponivel (rode com --build), seguindo sem agendamento'; } && bundle exec rails s -b 0.0.0.0"
|
||||
# Todo o boot (migrations, cron, Puma) mora em bin/docker-boot, com o
|
||||
# motivo de cada passo comentado lá. Ficava aqui como uma linha só de
|
||||
# `bash -c`, ilegível e impossível de depurar quando um passo falhava.
|
||||
command: ["bin/docker-boot"]
|
||||
|
||||
# `unless-stopped`: sobe sozinho depois de queda do processo, de reboot do
|
||||
# servidor e de `docker compose up`, e SÓ fica parado se alguém der
|
||||
# `docker compose stop/down` explicitamente. Como o bin/docker-boot roda
|
||||
# `db:prepare` no começo, isso já entrega a migration automática a cada
|
||||
# restart — não existe passo manual depois do deploy.
|
||||
#
|
||||
# Escolhido em vez de `always` porque `always` reergue o container até
|
||||
# depois de um `stop` deliberado (num reboot do servidor), tirando do
|
||||
# operador a chance de deixar o sistema fora do ar de propósito.
|
||||
restart: unless-stopped
|
||||
|
||||
# O banco PostgreSQL já existe externamente.
|
||||
# Configure DB_HOST no .env com o IP/hostname do seu servidor PostgreSQL.
|
||||
#
|
||||
@@ -62,6 +65,9 @@ services:
|
||||
WHATSAPP_TOKEN: ${WHATSAPP_TOKEN}
|
||||
WHATSAPP_DATA_DIR: /data
|
||||
PORT: 3001
|
||||
# Mesmo fuso do Rails: log de envio com hora diferente da do sistema
|
||||
# torna impossível cruzar os dois na hora de investigar uma falha.
|
||||
TZ: ${TZ:-America/Sao_Paulo}
|
||||
volumes:
|
||||
# A sessão pareada vive aqui. Sem este volume, cada deploy exige escanear
|
||||
# o QR de novo.
|
||||
|
||||
BIN
public/logo-gade.png
Normal file
BIN
public/logo-gade.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -101,3 +101,36 @@ FactoryBot.define do
|
||||
entregas_contadas { 10 }
|
||||
end
|
||||
end
|
||||
|
||||
FactoryBot.define do
|
||||
factory :romaneio do
|
||||
planned_date { Date.new(2026, 8, 31) }
|
||||
operacao_tabela { '' }
|
||||
rotulo_plano { 'EMAD 09.2026' }
|
||||
origem { 'planilha' }
|
||||
end
|
||||
|
||||
factory :romaneio_linha do
|
||||
romaneio
|
||||
veiculo { 'GADE_001' }
|
||||
sequence(:ordem)
|
||||
sequence(:nota_fiscal) { |n| (90_000 + n).to_s }
|
||||
chave_origem { nota_fiscal }
|
||||
nome { 'FULANO DE TAL' }
|
||||
endereco { 'RUA X, 1, BAIRRO, SÃO PAULO, SP' }
|
||||
aparelho { '' }
|
||||
telefones { '(11) 90000-0000' }
|
||||
origem do
|
||||
{ 'nota_fiscal' => nota_fiscal, 'nome' => nome, 'endereco' => endereco,
|
||||
'aparelho' => aparelho, 'telefones' => telefones }
|
||||
end
|
||||
campos_editados { [] }
|
||||
end
|
||||
|
||||
factory :romaneio_veiculo do
|
||||
romaneio
|
||||
veiculo { 'GADE_001' }
|
||||
motorista { '' }
|
||||
posicao { 0 }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,7 +9,11 @@ RSpec.describe 'Nomes de tabela dos models' do
|
||||
ConsolidacaoMotorista => 'consolidacao_motoristas',
|
||||
ConsolidacaoEntrega => 'consolidacao_entregas',
|
||||
Configuracao => 'configuracoes',
|
||||
ConfiguracaoNotificacao => 'configuracao_notificacoes'
|
||||
ConfiguracaoNotificacao => 'configuracao_notificacoes',
|
||||
RomaneioVeiculo => 'romaneio_veiculos',
|
||||
# "RomaneioItem" daria `romaneio_items` (o inflector pt-BR não conhece
|
||||
# item/itens); por isso a tabela é `romaneio_linhas`.
|
||||
RomaneioLinha => 'romaneio_linhas'
|
||||
}.each do |model, tabela|
|
||||
it "#{model} aponta para #{tabela}" do
|
||||
expect(model.table_name).to eq(tabela)
|
||||
|
||||
21
spec/policies/romaneio_policy_spec.rb
Normal file
21
spec/policies/romaneio_policy_spec.rb
Normal file
@@ -0,0 +1,21 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe RomaneioPolicy do
|
||||
subject(:policy) { described_class.new(usuario, Romaneio) }
|
||||
|
||||
context 'com a permissão admin.romaneio' do
|
||||
let(:usuario) { build(:gerente) }
|
||||
|
||||
it { is_expected.to be_index }
|
||||
it { is_expected.to be_pdf }
|
||||
it { is_expected.to be_atualizar_linha }
|
||||
end
|
||||
|
||||
context 'sem a permissão' do
|
||||
let(:usuario) { build(:operador) }
|
||||
|
||||
it { is_expected.not_to be_index }
|
||||
it { is_expected.not_to be_pdf }
|
||||
it { is_expected.not_to be_destroy }
|
||||
end
|
||||
end
|
||||
82
spec/requests/admin/romaneios_spec.rb
Normal file
82
spec/requests/admin/romaneios_spec.rb
Normal file
@@ -0,0 +1,82 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Admin::Romaneios', type: :request do
|
||||
let(:romaneio) { create(:romaneio) }
|
||||
|
||||
describe 'permissão' do
|
||||
it 'exige login' do
|
||||
get admin_romaneios_path
|
||||
expect(response).to redirect_to(new_user_session_path)
|
||||
end
|
||||
|
||||
it 'bloqueia operador (não tem admin.romaneio por padrão)' do
|
||||
sign_in create(:operador)
|
||||
get admin_romaneios_path
|
||||
|
||||
expect(response).to have_http_status(:redirect)
|
||||
expect(flash[:alert]).to match(/permissão/i)
|
||||
end
|
||||
|
||||
it 'libera gerente' do
|
||||
sign_in create(:gerente)
|
||||
get admin_romaneios_path
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET #pdf' do
|
||||
before do
|
||||
create(:romaneio_linha, romaneio: romaneio, veiculo: 'GADE_001')
|
||||
sign_in create(:admin)
|
||||
end
|
||||
|
||||
it 'sai INLINE quando um veículo é pedido — é a prévia do editor' do
|
||||
get pdf_admin_romaneio_path(romaneio, veiculo: 'GADE_001')
|
||||
|
||||
expect(response.media_type).to eq('application/pdf')
|
||||
expect(response.headers['Content-Disposition']).to match(/inline/)
|
||||
end
|
||||
|
||||
it 'sai como download quando nenhum veículo é pedido' do
|
||||
get pdf_admin_romaneio_path(romaneio)
|
||||
|
||||
expect(response.headers['Content-Disposition']).to match(/attachment/)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH #atualizar_linha' do
|
||||
let(:linha) { create(:romaneio_linha, romaneio: romaneio, telefones: '(11) 1111-1111') }
|
||||
|
||||
before { sign_in create(:admin) }
|
||||
|
||||
it 'grava o campo e o marca como editado' do
|
||||
patch atualizar_linha_admin_romaneio_path(romaneio),
|
||||
params: { linha_id: linha.id, campo: 'telefones', valor: '(11) 99999-0000' },
|
||||
as: :json
|
||||
|
||||
expect(response.parsed_body['valor']).to eq('(11) 99999-0000')
|
||||
expect(response.parsed_body['editado']).to be(true)
|
||||
expect(linha.reload.campos_editados).to include('telefones')
|
||||
end
|
||||
|
||||
it 'com valor null restaura o que o plano trouxe e destrava o campo' do
|
||||
patch atualizar_linha_admin_romaneio_path(romaneio),
|
||||
params: { linha_id: linha.id, campo: 'telefones', valor: '(11) 99999-0000' }, as: :json
|
||||
|
||||
patch atualizar_linha_admin_romaneio_path(romaneio),
|
||||
params: { linha_id: linha.id, campo: 'telefones', valor: nil }, as: :json
|
||||
|
||||
expect(linha.reload.telefones).to eq('(11) 1111-1111')
|
||||
expect(linha.campos_editados).to be_empty
|
||||
end
|
||||
|
||||
it 'recusa campo fora da whitelist — veiculo e ordem são a verdade do roteiro' do
|
||||
patch atualizar_linha_admin_romaneio_path(romaneio),
|
||||
params: { linha_id: linha.id, campo: 'veiculo', valor: 'GADE_999' }, as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(linha.reload.veiculo).not_to eq('GADE_999')
|
||||
end
|
||||
end
|
||||
end
|
||||
107
spec/services/pdf/romaneio_pdf_spec.rb
Normal file
107
spec/services/pdf/romaneio_pdf_spec.rb
Normal file
@@ -0,0 +1,107 @@
|
||||
require 'rails_helper'
|
||||
# A gem entra com `require: false` no Gemfile (é ferramenta de teste, não de runtime).
|
||||
require 'pdf/inspector'
|
||||
|
||||
# As constantes derivadas SÃO o contrato do documento: elas foram conferidas pixel a
|
||||
# pixel contra Imagens para implantação/Romaneiro_EMAD_SETEMBRO_2026.pdf (render a
|
||||
# 110 dpi). Se alguém mexer numa margem "só um pouquinho", é aqui que aparece.
|
||||
RSpec.describe Pdf::RomaneioPdf do
|
||||
let(:romaneio) { create(:romaneio) }
|
||||
|
||||
def semear(veiculo, quantidade)
|
||||
quantidade.times do |i|
|
||||
create(:romaneio_linha, romaneio: romaneio, veiculo: veiculo, ordem: i + 1)
|
||||
end
|
||||
end
|
||||
|
||||
def paginas(pdf)
|
||||
PDF::Inspector::Page.analyze(pdf).pages.size
|
||||
end
|
||||
|
||||
describe 'geometria' do
|
||||
subject(:documento) { described_class.new(romaneio: romaneio) }
|
||||
|
||||
it 'é A4 paisagem' do
|
||||
medidas = documento.instance_variable_get(:@pdf).bounds
|
||||
expect([medidas.width.round(2), medidas.height.round(2)]).to eq([785.2, 487.56])
|
||||
end
|
||||
|
||||
it 'mantém a altura de linha e os corpos de fonte do documento original' do
|
||||
expect(documento.instance_variable_get(:@linha_h)).to be_within(0.001).of(11.1811)
|
||||
expect(documento.instance_variable_get(:@fonte_dados)).to eq(7.0)
|
||||
expect(documento.instance_variable_get(:@fonte_hdr)).to eq(7.5)
|
||||
end
|
||||
|
||||
it 'usa a largura útil inteira nas seis colunas' do
|
||||
larguras = documento.instance_variable_get(:@larguras)
|
||||
expect(larguras.size).to eq(6)
|
||||
expect(larguras.sum).to be_within(0.01).of(785.2)
|
||||
end
|
||||
|
||||
it 'reserva espaço para o rodapé caber na folha cheia' do
|
||||
# 33 paradas + as 2 linhas em branco estouravam o orçamento e o
|
||||
# "ASSINATURA DO MOTORISTA" sumia do papel, sem erro nenhum.
|
||||
expect(documento.instance_variable_get(:@max_linhas_folha))
|
||||
.to be >= described_class::MAX_LINHAS
|
||||
end
|
||||
end
|
||||
|
||||
it 'gera uma página por veículo' do
|
||||
semear('GADE_001', 5)
|
||||
semear('GADE_002', 3)
|
||||
|
||||
expect(paginas(described_class.new(romaneio: romaneio).render)).to eq(2)
|
||||
end
|
||||
|
||||
it 'imprime só o veículo pedido (é o que a prévia do editor usa)' do
|
||||
semear('GADE_001', 5)
|
||||
semear('GADE_002', 3)
|
||||
|
||||
expect(paginas(described_class.new(romaneio: romaneio, veiculos: ['GADE_002']).render)).to eq(1)
|
||||
end
|
||||
|
||||
it 'quebra em folhas de continuação acima de MAX_LINHAS' do
|
||||
semear('GADE_008', described_class::MAX_LINHAS + 1)
|
||||
|
||||
pdf = described_class.new(romaneio: romaneio).render
|
||||
expect(paginas(pdf)).to eq(2)
|
||||
texto = PDF::Inspector::Text.analyze(pdf).strings.join(' ')
|
||||
expect(texto).to include('FOLHA 1/2').and include('FOLHA 2/2')
|
||||
end
|
||||
|
||||
it 'assina TODAS as folhas — inclusive a de continuação' do
|
||||
semear('GADE_008', described_class::MAX_LINHAS + 1)
|
||||
|
||||
texto = PDF::Inspector::Text.analyze(described_class.new(romaneio: romaneio).render).strings
|
||||
expect(texto.count { |t| t.include?('ASSINATURA DO MOTORISTA') }).to eq(2)
|
||||
end
|
||||
|
||||
it 'numera as paradas continuando entre folhas (bate com a ordem no celular)' do
|
||||
semear('GADE_008', described_class::MAX_LINHAS + 1)
|
||||
|
||||
texto = PDF::Inspector::Text.analyze(described_class.new(romaneio: romaneio).render).strings
|
||||
expect(texto).to include((described_class::MAX_LINHAS + 1).to_s)
|
||||
end
|
||||
|
||||
it 'não imprime a parada que saiu do plano' do
|
||||
semear('GADE_001', 3)
|
||||
romaneio.romaneio_linhas.first.update!(removido_em: Time.current, nome: 'NAO DEVE SAIR')
|
||||
|
||||
texto = PDF::Inspector::Text.analyze(described_class.new(romaneio: romaneio).render).strings.join(' ')
|
||||
expect(texto).not_to include('NAO DEVE SAIR')
|
||||
end
|
||||
|
||||
it 'escreve o rótulo do plano no cabeçalho' do
|
||||
semear('GADE_001', 2)
|
||||
|
||||
texto = PDF::Inspector::Text.analyze(described_class.new(romaneio: romaneio).render).strings.join(' ')
|
||||
expect(texto).to include('PLANO:').and include('EMAD 09.2026')
|
||||
end
|
||||
|
||||
it 'aguenta acento nos endereços (a fonte AFM do Prawn estouraria aqui)' do
|
||||
create(:romaneio_linha, romaneio: romaneio, veiculo: 'GADE_001',
|
||||
endereco: 'RUA INÁCIO DE OLIVEIRA CAMPOS, 182, SÃO PAULO')
|
||||
|
||||
expect { described_class.new(romaneio: romaneio).render }.not_to raise_error
|
||||
end
|
||||
end
|
||||
47
spec/services/romaneios/enriquecimento_gade_spec.rb
Normal file
47
spec/services/romaneios/enriquecimento_gade_spec.rb
Normal file
@@ -0,0 +1,47 @@
|
||||
require 'rails_helper'
|
||||
|
||||
# É o que dispensa a SEGUNDA PLANILHA do programa Python: o STATUS (NOVO/RECORRENTE)
|
||||
# já está na tabela da operação.
|
||||
RSpec.describe Romaneios::EnriquecimentoGade do
|
||||
include_context 'tabela de operação'
|
||||
|
||||
let(:tabela) { "#{Operacao::PREFIXO}teste_jan_2026" }
|
||||
|
||||
it 'marca APARELHO = SIM só para quem está como NOVO' do
|
||||
criar_operacao!(tabela)
|
||||
semear_operacao!(tabela, [
|
||||
{ nota_fiscal: '1', nome_completo: 'Novo Paciente', telefones: '(11) 1',
|
||||
status: 'NOVO' },
|
||||
{ nota_fiscal: '2', nome_completo: 'Velho Paciente', telefones: '(11) 2',
|
||||
status: 'RECORRENTE' }
|
||||
])
|
||||
|
||||
dados = described_class.new(tabela).para_notas(%w[1 2])
|
||||
|
||||
expect(dados['1'][:aparelho]).to eq('SIM')
|
||||
expect(dados['2'][:aparelho]).to eq('')
|
||||
expect(dados['1'][:nome]).to eq('NOVO PACIENTE')
|
||||
expect(dados['2'][:telefones]).to eq('(11) 2')
|
||||
end
|
||||
|
||||
it 'não quebra quando a operação não tem a coluna status — e AVISA qual falta' do
|
||||
criar_operacao!(tabela, colunas: %i[nome_completo telefones])
|
||||
semear_operacao!(tabela, [{ nota_fiscal: '1', nome_completo: 'Fulano', telefones: '(11) 1' }])
|
||||
|
||||
servico = described_class.new(tabela)
|
||||
|
||||
expect(servico.colunas_ausentes).to eq(['status'])
|
||||
expect(servico.para_notas(['1'])['1'][:aparelho]).to eq('')
|
||||
end
|
||||
|
||||
it 'omite a NF que não está na operação (é o plano avulso / de inclusão)' do
|
||||
criar_operacao!(tabela)
|
||||
semear_operacao!(tabela, [{ nota_fiscal: '1', status: 'NOVO' }])
|
||||
|
||||
expect(described_class.new(tabela).para_notas(%w[1 999])).not_to have_key('999')
|
||||
end
|
||||
|
||||
it 'recusa nome de tabela fora da whitelist de operações' do
|
||||
expect { described_class.new('users; DROP TABLE users') }.to raise_error(ArgumentError)
|
||||
end
|
||||
end
|
||||
115
spec/services/romaneios/importador_spec.rb
Normal file
115
spec/services/romaneios/importador_spec.rb
Normal file
@@ -0,0 +1,115 @@
|
||||
require 'rails_helper'
|
||||
|
||||
# As regras de reconciliação — é onde mora o risco da funcionalidade: errar aqui
|
||||
# significa ou perder a correção que o operador fez à mão, ou imprimir um endereço
|
||||
# velho que o SimpliRoute já corrigiu.
|
||||
RSpec.describe Romaneios::Importador do
|
||||
let(:romaneio) { create(:romaneio) }
|
||||
|
||||
def linha_do_plano(nf:, nome: 'FULANO', endereco: 'RUA A, 1', telefones: '(11) 1111-1111',
|
||||
veiculo: 'GADE_001', ordem: 1)
|
||||
{ veiculo: veiculo, ordem: ordem, motorista: '', nota_fiscal: nf, nome: nome,
|
||||
endereco: endereco, telefones: telefones, aparelho: '', tracking_id: '',
|
||||
titulo: "NF #{nf} - #{nome}" }
|
||||
end
|
||||
|
||||
def importar(linhas)
|
||||
described_class.new(romaneio: romaneio, linhas: linhas, origem: 'planilha').aplicar!
|
||||
end
|
||||
|
||||
it 'insere as paradas novas e cria a folha de cada veículo' do
|
||||
resultado = importar([linha_do_plano(nf: '1'),
|
||||
linha_do_plano(nf: '2', veiculo: 'GADE_002', ordem: 1)])
|
||||
|
||||
expect(resultado.novos).to eq(2)
|
||||
expect(romaneio.romaneio_linhas.count).to eq(2)
|
||||
expect(romaneio.romaneio_veiculos.pluck(:veiculo)).to contain_exactly('GADE_001', 'GADE_002')
|
||||
end
|
||||
|
||||
it 'PRESERVA o campo que o operador editou e atualiza os demais' do
|
||||
importar([linha_do_plano(nf: '1', nome: 'FULANO', endereco: 'RUA A, 1')])
|
||||
linha = romaneio.romaneio_linhas.first
|
||||
linha.telefones = '(11) 99999-0000'
|
||||
linha.marcar_editado!('telefones')
|
||||
linha.save!
|
||||
|
||||
resultado = importar([linha_do_plano(nf: '1', nome: 'FULANO DE TAL',
|
||||
endereco: 'RUA B, 2', telefones: '(11) 1111-1111')])
|
||||
|
||||
linha.reload
|
||||
expect(linha.telefones).to eq('(11) 99999-0000') # intocado: tem edição humana
|
||||
expect(linha.endereco).to eq('RUA B, 2') # o plano manda no resto
|
||||
expect(resultado.preservados).to eq(1)
|
||||
expect(resultado.atualizados).to eq(1)
|
||||
end
|
||||
|
||||
it 'faz veículo e ordem seguirem o plano mesmo numa linha editada' do
|
||||
importar([linha_do_plano(nf: '1', veiculo: 'GADE_001', ordem: 1)])
|
||||
linha = romaneio.romaneio_linhas.first
|
||||
linha.marcar_editado!('nome')
|
||||
linha.save!
|
||||
|
||||
importar([linha_do_plano(nf: '1', veiculo: 'GADE_009', ordem: 7)])
|
||||
|
||||
expect(linha.reload.veiculo).to eq('GADE_009')
|
||||
expect(linha.ordem).to eq(7)
|
||||
end
|
||||
|
||||
it 'APAGA a parada que saiu do plano sem edição humana' do
|
||||
importar([linha_do_plano(nf: '1'), linha_do_plano(nf: '2', ordem: 2)])
|
||||
|
||||
resultado = importar([linha_do_plano(nf: '1')])
|
||||
|
||||
expect(romaneio.romaneio_linhas.pluck(:nota_fiscal)).to eq(['1'])
|
||||
expect(resultado.saidos).to eq(1)
|
||||
end
|
||||
|
||||
it 'GUARDA (sem apagar) a parada que saiu do plano mas tinha edição' do
|
||||
importar([linha_do_plano(nf: '1'), linha_do_plano(nf: '2', ordem: 2)])
|
||||
editada = romaneio.romaneio_linhas.find_by(nota_fiscal: '2')
|
||||
editada.marcar_editado!('endereco')
|
||||
editada.save!
|
||||
|
||||
importar([linha_do_plano(nf: '1')])
|
||||
|
||||
expect(editada.reload.removido_em).to be_present
|
||||
expect(romaneio.linhas_vigentes.pluck(:nota_fiscal)).to eq(['1'])
|
||||
end
|
||||
|
||||
it 'devolve a parada guardada para a folha quando ela volta ao plano' do
|
||||
importar([linha_do_plano(nf: '1'), linha_do_plano(nf: '2', ordem: 2)])
|
||||
editada = romaneio.romaneio_linhas.find_by(nota_fiscal: '2')
|
||||
editada.marcar_editado!('endereco')
|
||||
editada.save!
|
||||
importar([linha_do_plano(nf: '1')])
|
||||
|
||||
importar([linha_do_plano(nf: '1'), linha_do_plano(nf: '2', ordem: 2)])
|
||||
|
||||
expect(editada.reload.removido_em).to be_nil
|
||||
end
|
||||
|
||||
it 'descarta a NF repetida no plano e reporta, em vez de duplicar a parada' do
|
||||
resultado = importar([linha_do_plano(nf: '1', nome: 'PRIMEIRO'),
|
||||
linha_do_plano(nf: '1', nome: 'SEGUNDO', ordem: 2)])
|
||||
|
||||
expect(romaneio.romaneio_linhas.count).to eq(1)
|
||||
expect(romaneio.romaneio_linhas.first.nome).to eq('PRIMEIRO')
|
||||
expect(resultado.duplicados).to eq(1)
|
||||
end
|
||||
|
||||
it 'não colide duas paradas sem NF (a chave cai para o digest do título)' do
|
||||
sem_nf = linha_do_plano(nf: '').merge(titulo: 'PONTO DE APOIO')
|
||||
outra = linha_do_plano(nf: '', endereco: 'RUA Z, 9', ordem: 2).merge(titulo: 'OUTRO PONTO')
|
||||
|
||||
expect { importar([sem_nf, outra]) }.not_to raise_error
|
||||
expect(romaneio.romaneio_linhas.count).to eq(2)
|
||||
end
|
||||
|
||||
it 'apaga a folha do veículo que sumiu do plano' do
|
||||
importar([linha_do_plano(nf: '1'), linha_do_plano(nf: '2', veiculo: 'GADE_002')])
|
||||
|
||||
importar([linha_do_plano(nf: '1')])
|
||||
|
||||
expect(romaneio.romaneio_veiculos.pluck(:veiculo)).to eq(['GADE_001'])
|
||||
end
|
||||
end
|
||||
102
spec/services/romaneios/plano_planilha_spec.rb
Normal file
102
spec/services/romaneios/plano_planilha_spec.rb
Normal file
@@ -0,0 +1,102 @@
|
||||
require 'rails_helper'
|
||||
require 'caxlsx'
|
||||
|
||||
# A planilha do plano é o caminho B do romaneio — e é o que o cliente usa quando o
|
||||
# plano do dia ainda não está publicado. Os casos aqui são os que o export REAL
|
||||
# apresenta (medido em Imagens para implantação/SimpliRoute_Plan_ 2026-08-31.xlsx):
|
||||
# coluna "Motorista" vazia, sem "Aparelho", e a NF numa coluna com typo do próprio
|
||||
# SimpliRoute ("ID de referêncua").
|
||||
RSpec.describe Romaneios::PlanoPlanilha do
|
||||
def planilha(cabecalho, linhas)
|
||||
pacote = Axlsx::Package.new
|
||||
pacote.workbook.add_worksheet(name: 'Plan') do |aba|
|
||||
aba.add_row(cabecalho)
|
||||
linhas.each { |l| aba.add_row(l) }
|
||||
end
|
||||
arquivo = Tempfile.new(['plano', '.xlsx'])
|
||||
arquivo.binmode
|
||||
arquivo.write(pacote.to_stream.read)
|
||||
arquivo.flush
|
||||
arquivo
|
||||
end
|
||||
|
||||
it 'lê o formato real do export (sem Motorista, sem Aparelho, NF com o typo)' do
|
||||
arquivo = planilha(
|
||||
['Motorista', 'Veículo', 'Índice de rota', 'Ordem de entrega', 'Título', 'Endereço',
|
||||
'ID de referêncua', 'Anotações'],
|
||||
[[nil, 'GADE_038', 1, 1, 'NF 90507 - Cicera Beserra dos Anjos',
|
||||
'Rua Amapola, 206, Jardim Vista Linda, São Paulo, SP, CEP: 05159-100',
|
||||
'90507', 'TEL: (11) 99262-0594']]
|
||||
)
|
||||
|
||||
linha = described_class.new(arquivo).linhas.first
|
||||
|
||||
expect(linha[:veiculo]).to eq('GADE_038')
|
||||
expect(linha[:nota_fiscal]).to eq('90507')
|
||||
expect(linha[:nome]).to eq('CICERA BESERRA DOS ANJOS')
|
||||
expect(linha[:endereco]).to eq('RUA AMAPOLA, 206, JARDIM VISTA LINDA, SÃO PAULO, SP, CEP: 05159-100')
|
||||
expect(linha[:telefones]).to eq('(11) 99262-0594')
|
||||
expect(linha[:motorista]).to eq('')
|
||||
end
|
||||
|
||||
it 'aceita a grafia corrigida da coluna de referência' do
|
||||
arquivo = planilha(['Veículo', 'Título', 'Endereço', 'ID de referência'],
|
||||
[['GADE_001', 'NF 1 - Fulano', 'Rua A, 1', '1']])
|
||||
|
||||
expect(described_class.new(arquivo).linhas.first[:nota_fiscal]).to eq('1')
|
||||
end
|
||||
|
||||
it 'aceita título fora do padrão "NF n - NOME" sem perder a parada' do
|
||||
arquivo = planilha(['Veículo', 'Título', 'Endereço'],
|
||||
[['GADE_001', 'PONTO DE APOIO', 'Rua A, 1']])
|
||||
|
||||
linha = described_class.new(arquivo).linhas.first
|
||||
expect(linha[:nota_fiscal]).to eq('')
|
||||
expect(linha[:nome]).to eq('PONTO DE APOIO')
|
||||
end
|
||||
|
||||
it 'tira o sufixo ", Brazil" do endereço e junta vários telefones' do
|
||||
arquivo = planilha(['Veículo', 'Título', 'Endereço', 'Anotações'],
|
||||
[['GADE_001', 'NF 1 - Fulano', 'Rua A, 1, Bairro, Brazil',
|
||||
'TEL: (11) 95440-5780 / (11) 99824-8099']])
|
||||
|
||||
linha = described_class.new(arquivo).linhas.first
|
||||
expect(linha[:endereco]).to eq('RUA A, 1, BAIRRO')
|
||||
expect(linha[:telefones]).to eq('(11) 95440-5780 / (11) 99824-8099')
|
||||
end
|
||||
|
||||
it 'renumera a ordem de 1..N dentro de cada veículo, na ordem de entrega do plano' do
|
||||
arquivo = planilha(['Veículo', 'Ordem de entrega', 'Título', 'Endereço'],
|
||||
[['GADE_001', 3, 'NF 3 - C', 'Rua C'],
|
||||
['GADE_002', 9, 'NF 9 - I', 'Rua I'],
|
||||
['GADE_001', 1, 'NF 1 - A', 'Rua A']])
|
||||
|
||||
linhas = described_class.new(arquivo).linhas
|
||||
primeiro = linhas.select { |l| l[:veiculo] == 'GADE_001' }
|
||||
expect(primeiro.map { |l| [l[:ordem], l[:nota_fiscal]] }).to eq([[1, '1'], [2, '3']])
|
||||
expect(linhas.find { |l| l[:veiculo] == 'GADE_002' }[:ordem]).to eq(1)
|
||||
end
|
||||
|
||||
it 'pula linha sem veículo (rodapé do próprio export) sem levantar erro' do
|
||||
arquivo = planilha(['Veículo', 'Título', 'Endereço'],
|
||||
[['GADE_001', 'NF 1 - A', 'Rua A'], [nil, nil, nil]])
|
||||
|
||||
expect(described_class.new(arquivo).linhas.size).to eq(1)
|
||||
end
|
||||
|
||||
it 'recusa arquivo que não é .xlsx com uma mensagem acionável' do
|
||||
arquivo = Tempfile.new(['plano', '.xlsx'])
|
||||
arquivo.write('isto não é um zip')
|
||||
arquivo.flush
|
||||
|
||||
expect { described_class.new(arquivo) }
|
||||
.to raise_error(described_class::Erro, /não é um \.xlsx/)
|
||||
end
|
||||
|
||||
it 'recusa planilha sem a coluna Veículo — não dá para montar folha sem carro' do
|
||||
arquivo = planilha(['Título', 'Endereço'], [['NF 1 - A', 'Rua A']])
|
||||
|
||||
expect { described_class.new(arquivo).linhas }
|
||||
.to raise_error(described_class::Erro, /Veículo/)
|
||||
end
|
||||
end
|
||||
84
spec/support/tabela_operacao.rb
Normal file
84
spec/support/tabela_operacao.rb
Normal file
@@ -0,0 +1,84 @@
|
||||
# Harness de uma tabela de operação (gade_entregas_*) para os specs.
|
||||
#
|
||||
# A tabela é EXTERNA: não tem migration (o cliente a carrega no banco) e não tem
|
||||
# factory. Sem este harness, testar o enriquecimento do romaneio só daria para
|
||||
# mockar o método sob teste — o que não pega regressão de SQL, que é onde moram os
|
||||
# bugs de coluna ausente e de alias (`status` da gade x `status` do espelho).
|
||||
#
|
||||
# Guardas copiadas de spec/support/espelho_rastreio.rb, pelo mesmo motivo: se a
|
||||
# tabela já existir COM DADOS, estamos apontados para dados de verdade e o spec tem
|
||||
# que abortar em vez de destruí-los.
|
||||
#
|
||||
# Uso:
|
||||
#
|
||||
# RSpec.describe Romaneios::EnriquecimentoGade do
|
||||
# include_context 'tabela de operação'
|
||||
#
|
||||
# it '...' do
|
||||
# criar_operacao!('gade_entregas_teste_jan_2026', colunas: %i[nome_completo telefones status])
|
||||
# semear_operacao!('gade_entregas_teste_jan_2026', [{ nota_fiscal: '1', status: 'NOVO' }])
|
||||
# end
|
||||
# end
|
||||
module TabelaOperacao
|
||||
COLUNAS_PADRAO = %i[nome_completo telefones status].freeze
|
||||
|
||||
class << self
|
||||
def criar!(nome, colunas: COLUNAS_PADRAO)
|
||||
raise "Tabela de operação de teste exige RAILS_ENV=test (está em #{Rails.env})" unless Rails.env.test?
|
||||
|
||||
if conexao.table_exists?(nome)
|
||||
linhas = conexao.select_value("SELECT COUNT(*) FROM #{conexao.quote_table_name(nome)}").to_i
|
||||
if linhas.positive?
|
||||
raise "#{nome} já existe no banco de teste com #{linhas} linha(s) — o spec NÃO vai " \
|
||||
'derrubá-la. Aponte RAILS_ENV=test para um banco descartável.'
|
||||
end
|
||||
conexao.drop_table(nome)
|
||||
end
|
||||
|
||||
conexao.create_table(nome, id: false) do |t|
|
||||
t.string :nota_fiscal
|
||||
colunas.each { |c| t.string c }
|
||||
end
|
||||
criadas << nome
|
||||
nome
|
||||
end
|
||||
|
||||
def semear!(nome, linhas)
|
||||
colunas = conexao.columns(nome).map(&:name)
|
||||
linhas.each do |linha|
|
||||
dados = linha.transform_keys(&:to_s).slice(*colunas)
|
||||
conexao.execute(
|
||||
"INSERT INTO #{conexao.quote_table_name(nome)} (#{dados.keys.join(', ')}) " \
|
||||
"VALUES (#{dados.values.map { |v| conexao.quote(v) }.join(', ')})"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def derrubar_todas!
|
||||
criadas.each { |nome| conexao.drop_table(nome, if_exists: true) }
|
||||
criadas.clear
|
||||
end
|
||||
|
||||
def criadas
|
||||
@criadas ||= []
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def conexao
|
||||
ActiveRecord::Base.connection
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
RSpec.shared_context 'tabela de operação' do
|
||||
after { TabelaOperacao.derrubar_todas! }
|
||||
|
||||
def criar_operacao!(nome, colunas: TabelaOperacao::COLUNAS_PADRAO)
|
||||
TabelaOperacao.criar!(nome, colunas: colunas)
|
||||
end
|
||||
|
||||
def semear_operacao!(nome, linhas)
|
||||
TabelaOperacao.semear!(nome, linhas)
|
||||
end
|
||||
end
|
||||
@@ -9,7 +9,10 @@ WORKDIR /app
|
||||
# registry do npm. Sem git na imagem o install morre logo no inicio com
|
||||
# `npm error syscall spawn git / ENOENT` — nao e erro de rede nem de versao.
|
||||
# python3/make/g++: node-gyp, para as dependencias nativas de cripto.
|
||||
RUN apk add --no-cache git python3 make g++
|
||||
# tzdata: o Alpine nao traz zoneinfo. Sem ele, TZ=America/Sao_Paulo e ignorado
|
||||
# em silencio e o log da ponte fica 3h adiantado (UTC), atrapalhando o
|
||||
# cruzamento com o log do Rails na hora de investigar um envio.
|
||||
RUN apk add --no-cache git python3 make g++ tzdata
|
||||
|
||||
COPY package.json ./
|
||||
# --no-audit --no-fund: so tiram ruido do log de build, que e onde se enxerga o
|
||||
@@ -23,5 +26,9 @@ COPY server.js ./
|
||||
VOLUME ["/data"]
|
||||
ENV WHATSAPP_DATA_DIR=/data
|
||||
|
||||
# Mesmo fuso do container do Rails — ver o comentario no Dockerfile da raiz.
|
||||
ENV TZ=America/Sao_Paulo
|
||||
RUN ln -snf "/usr/share/zoneinfo/$TZ" /etc/localtime && echo "$TZ" > /etc/timezone
|
||||
|
||||
EXPOSE 3001
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
Reference in New Issue
Block a user