From ad1a071e4b4230f8935aa2ad854517cb407667747866e238f5132041227b058b Mon Sep 17 00:00:00 2001 From: victor Date: Thu, 27 Aug 2026 16:21:56 -0300 Subject: [PATCH] =?UTF-8?q?Corre=C3=A7=C3=A3o=20de=20horario=20e=20implant?= =?UTF-8?q?a=C3=A7=C3=A3o=20da=20gera=C3=A7=C3=A3o=20de=20Romaneio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 7 + Dockerfile | 23 +- Gemfile | 9 + Gemfile.lock | 24 +- README.md | 119 +++++++ app/controllers/admin/romaneios_controller.rb | 232 +++++++++++++ .../controllers/romaneio_controller.js | 163 +++++++++ app/models/permissao.rb | 6 + app/models/romaneio.rb | 71 ++++ app/models/romaneio_linha.rb | 68 ++++ app/models/romaneio_veiculo.rb | 17 + app/models/user.rb | 1 + app/policies/romaneio_policy.rb | 19 + app/services/pdf/base_pdf.rb | 27 +- app/services/pdf/fonte_utf8.rb | 42 +++ app/services/pdf/romaneio_pdf.rb | 328 ++++++++++++++++++ app/services/romaneios/enriquecimento_gade.rb | 101 ++++++ app/services/romaneios/importador.rb | 215 ++++++++++++ app/services/romaneios/normalizador.rb | 87 +++++ app/services/romaneios/plano_do_dia.rb | 153 ++++++++ app/services/romaneios/plano_planilha.rb | 140 ++++++++ app/services/simpli_route/client.rb | 16 + app/views/admin/romaneios/_avisos.html.erb | 48 +++ app/views/admin/romaneios/_celula.html.erb | 13 + app/views/admin/romaneios/_controles.html.erb | 55 +++ app/views/admin/romaneios/_marcador.html.erb | 12 + app/views/admin/romaneios/_removidas.html.erb | 21 ++ app/views/admin/romaneios/_tabela.html.erb | 59 ++++ app/views/admin/romaneios/index.html.erb | 144 ++++++++ app/views/admin/romaneios/show.html.erb | 89 +++++ app/views/layouts/_navbar.html.erb | 6 +- bin/docker-boot | 76 ++++ bin/sondar_plano_do_dia | 149 ++++++++ config/routes.rb | 20 ++ db/migrate/20260827000001_create_romaneios.rb | 33 ++ ...20260827000002_create_romaneio_veiculos.rb | 26 ++ .../20260827000003_create_romaneio_linhas.rb | 51 +++ ...00004_add_permissao_romaneio_aos_perfis.rb | 43 +++ deploy/ntp-seguro.sh | 135 +++++++ docker-compose.yml | 42 ++- public/logo-gade.png | Bin 0 -> 14440 bytes spec/factories.rb | 33 ++ spec/models/table_names_spec.rb | 6 +- spec/policies/romaneio_policy_spec.rb | 21 ++ spec/requests/admin/romaneios_spec.rb | 82 +++++ spec/services/pdf/romaneio_pdf_spec.rb | 107 ++++++ .../romaneios/enriquecimento_gade_spec.rb | 47 +++ spec/services/romaneios/importador_spec.rb | 115 ++++++ .../services/romaneios/plano_planilha_spec.rb | 102 ++++++ spec/support/tabela_operacao.rb | 84 +++++ whatsapp/Dockerfile | 9 +- 51 files changed, 3450 insertions(+), 46 deletions(-) create mode 100644 app/controllers/admin/romaneios_controller.rb create mode 100644 app/javascript/controllers/romaneio_controller.js create mode 100644 app/models/romaneio.rb create mode 100644 app/models/romaneio_linha.rb create mode 100644 app/models/romaneio_veiculo.rb create mode 100644 app/policies/romaneio_policy.rb create mode 100644 app/services/pdf/fonte_utf8.rb create mode 100644 app/services/pdf/romaneio_pdf.rb create mode 100644 app/services/romaneios/enriquecimento_gade.rb create mode 100644 app/services/romaneios/importador.rb create mode 100644 app/services/romaneios/normalizador.rb create mode 100644 app/services/romaneios/plano_do_dia.rb create mode 100644 app/services/romaneios/plano_planilha.rb create mode 100644 app/views/admin/romaneios/_avisos.html.erb create mode 100644 app/views/admin/romaneios/_celula.html.erb create mode 100644 app/views/admin/romaneios/_controles.html.erb create mode 100644 app/views/admin/romaneios/_marcador.html.erb create mode 100644 app/views/admin/romaneios/_removidas.html.erb create mode 100644 app/views/admin/romaneios/_tabela.html.erb create mode 100644 app/views/admin/romaneios/index.html.erb create mode 100644 app/views/admin/romaneios/show.html.erb create mode 100755 bin/docker-boot create mode 100755 bin/sondar_plano_do_dia create mode 100644 db/migrate/20260827000001_create_romaneios.rb create mode 100644 db/migrate/20260827000002_create_romaneio_veiculos.rb create mode 100644 db/migrate/20260827000003_create_romaneio_linhas.rb create mode 100644 db/migrate/20260827000004_add_permissao_romaneio_aos_perfis.rb create mode 100755 deploy/ntp-seguro.sh create mode 100644 public/logo-gade.png create mode 100644 spec/policies/romaneio_policy_spec.rb create mode 100644 spec/requests/admin/romaneios_spec.rb create mode 100644 spec/services/pdf/romaneio_pdf_spec.rb create mode 100644 spec/services/romaneios/enriquecimento_gade_spec.rb create mode 100644 spec/services/romaneios/importador_spec.rb create mode 100644 spec/services/romaneios/plano_planilha_spec.rb create mode 100644 spec/support/tabela_operacao.rb diff --git a/.env.example b/.env.example index 5106d4c..49b6b94 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/Dockerfile b/Dockerfile index f970493..f97f83d 100644 --- a/Dockerfile +++ b/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"] diff --git a/Gemfile b/Gemfile index a8683a4..d69d0a2 100644 --- a/Gemfile +++ b/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 diff --git a/Gemfile.lock b/Gemfile.lock index cdfc2a2..ffa2121 100644 --- a/Gemfile.lock +++ b/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 diff --git a/README.md b/README.md index eccea02..9036554 100644 --- a/README.md +++ b/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. + +--- + +
+🕐 Relógio, restart automático e migrations no boot + +## 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" +``` + +
diff --git a/app/controllers/admin/romaneios_controller.rb b/app/controllers/admin/romaneios_controller.rb new file mode 100644 index 0000000..cd0dfbb --- /dev/null +++ b/app/controllers/admin/romaneios_controller.rb @@ -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 diff --git a/app/javascript/controllers/romaneio_controller.js b/app/javascript/controllers/romaneio_controller.js new file mode 100644 index 0000000..d8b723f --- /dev/null +++ b/app/javascript/controllers/romaneio_controller.js @@ -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 = + `` + } + + 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 + } +} diff --git a/app/models/permissao.rb b/app/models/permissao.rb index ca9833b..4571c8a 100644 --- a/app/models/permissao.rb +++ b/app/models/permissao.rb @@ -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 diff --git a/app/models/romaneio.rb b/app/models/romaneio.rb new file mode 100644 index 0000000..db46b81 --- /dev/null +++ b/app/models/romaneio.rb @@ -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 diff --git a/app/models/romaneio_linha.rb b/app/models/romaneio_linha.rb new file mode 100644 index 0000000..153207b --- /dev/null +++ b/app/models/romaneio_linha.rb @@ -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 diff --git a/app/models/romaneio_veiculo.rb b/app/models/romaneio_veiculo.rb new file mode 100644 index 0000000..e674ea1 --- /dev/null +++ b/app/models/romaneio_veiculo.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index be5d55c..967d352 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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] diff --git a/app/policies/romaneio_policy.rb b/app/policies/romaneio_policy.rb new file mode 100644 index 0000000..59faf07 --- /dev/null +++ b/app/policies/romaneio_policy.rb @@ -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 diff --git a/app/services/pdf/base_pdf.rb b/app/services/pdf/base_pdf.rb index d576cfc..c5ec729 100644 --- a/app/services/pdf/base_pdf.rb +++ b/app/services/pdf/base_pdf.rb @@ -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 diff --git a/app/services/pdf/fonte_utf8.rb b/app/services/pdf/fonte_utf8.rb new file mode 100644 index 0000000..2d87050 --- /dev/null +++ b/app/services/pdf/fonte_utf8.rb @@ -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 diff --git a/app/services/pdf/romaneio_pdf.rb b/app/services/pdf/romaneio_pdf.rb new file mode 100644 index 0000000..7fb43e4 --- /dev/null +++ b/app/services/pdf/romaneio_pdf.rb @@ -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 diff --git a/app/services/romaneios/enriquecimento_gade.rb b/app/services/romaneios/enriquecimento_gade.rb new file mode 100644 index 0000000..aefe627 --- /dev/null +++ b/app/services/romaneios/enriquecimento_gade.rb @@ -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 diff --git a/app/services/romaneios/importador.rb b/app/services/romaneios/importador.rb new file mode 100644 index 0000000..7dac8d8 --- /dev/null +++ b/app/services/romaneios/importador.rb @@ -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 diff --git a/app/services/romaneios/normalizador.rb b/app/services/romaneios/normalizador.rb new file mode 100644 index 0000000..a331063 --- /dev/null +++ b/app/services/romaneios/normalizador.rb @@ -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 diff --git a/app/services/romaneios/plano_do_dia.rb b/app/services/romaneios/plano_do_dia.rb new file mode 100644 index 0000000..4189bf9 --- /dev/null +++ b/app/services/romaneios/plano_do_dia.rb @@ -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 diff --git a/app/services/romaneios/plano_planilha.rb b/app/services/romaneios/plano_planilha.rb new file mode 100644 index 0000000..fef63f4 --- /dev/null +++ b/app/services/romaneios/plano_planilha.rb @@ -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 diff --git a/app/services/simpli_route/client.rb b/app/services/simpli_route/client.rb index 5f59e78..e769f06 100644 --- a/app/services/simpli_route/client.rb +++ b/app/services/simpli_route/client.rb @@ -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. diff --git a/app/views/admin/romaneios/_avisos.html.erb b/app/views/admin/romaneios/_avisos.html.erb new file mode 100644 index 0000000..9f10cdc --- /dev/null +++ b/app/views/admin/romaneios/_avisos.html.erb @@ -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? %> +
+ ⚠️ +

+ A operação <%= @romaneio.operacao_label %> não tem a coluna + STATUS, então não dá para saber quem é paciente novo — a coluna + APARELHO 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. +

+
+<% end %> + +<% if @colunas_ausentes.include?('telefones') && @romaneio.operacao_tabela.present? %> +
+ ☎️ +

+ A operação <%= @romaneio.operacao_label %> não tem a coluna + TELEFONES. O que aparece na tabela veio do “TEL:” das anotações do + plano, que nem sempre traz todos os números. +

+
+<% end %> + +<% if @romaneio.operacao_tabela.blank? %> +
+ ℹ️ +

+ Este romaneio foi importado sem operação vinculada: a coluna APARELHO + não é preenchida sozinha e o telefone vem só das anotações do plano. +

+
+<% end %> + +<% if multifolha.any? %> +
+ 🖨️ +

+ <% multifolha.each do |veiculo, total| %> + <%= veiculo %> 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. +

+
+<% end %> diff --git a/app/views/admin/romaneios/_celula.html.erb b/app/views/admin/romaneios/_celula.html.erb new file mode 100644 index 0000000..ad83ff8 --- /dev/null +++ b/app/views/admin/romaneios/_celula.html.erb @@ -0,0 +1,13 @@ +<%# Uma célula editável + o marcador de "editado". %> +
+ + <%= render 'marcador', linha: linha, campo: campo %> +
diff --git a/app/views/admin/romaneios/_controles.html.erb b/app/views/admin/romaneios/_controles.html.erb new file mode 100644 index 0000000..1186724 --- /dev/null +++ b/app/views/admin/romaneios/_controles.html.erb @@ -0,0 +1,55 @@ +<%# Seletor de veículo + os dois campos de cabeçalho da folha. %> +
+
+ +
+ <%# ‹ › 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. %> + + + +
+
+ +
+
+ + +
+
+ + <% atual = @veiculos.find { |v| v.veiculo == @veiculo } %> + +
+
+ +
+ + +
+
diff --git a/app/views/admin/romaneios/_marcador.html.erb b/app/views/admin/romaneios/_marcador.html.erb new file mode 100644 index 0000000..c737be7 --- /dev/null +++ b/app/views/admin/romaneios/_marcador.html.erb @@ -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. %> + + <% if linha.editado?(campo) %> + + <% end %> + diff --git a/app/views/admin/romaneios/_removidas.html.erb b/app/views/admin/romaneios/_removidas.html.erb new file mode 100644 index 0000000..c1234b9 --- /dev/null +++ b/app/views/admin/romaneios/_removidas.html.erb @@ -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? %> +
+

+ <%= linhas.size %> parada(s) saíram do plano +

+

+ 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. +

+ +
+<% end %> diff --git a/app/views/admin/romaneios/_tabela.html.erb b/app/views/admin/romaneios/_tabela.html.erb new file mode 100644 index 0000000..815470d --- /dev/null +++ b/app/views/admin/romaneios/_tabela.html.erb @@ -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. + + por célula, e não contenteditable: colar do Excel num contenteditable + traz HTML junto. %> +
+
+

<%= linhas.size %> paradas

+ Alterações salvam sozinhas +
+ +
+ + + + + + + + + + + + + <% linhas.each_with_index do |linha, i| %> + + + <% %w[nota_fiscal nome endereco].each do |campo| %> + + <% 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. %> + + + + + <% end %> + +
#Nota fiscalNomeEndereçoAparelhoTelefones
<%= i + 1 %> + <%= render 'celula', linha: linha, campo: campo %> + + + <%= render 'marcador', linha: linha, campo: 'aparelho' %> + + <%= render 'celula', linha: linha, campo: 'telefones' %> +
+
+
diff --git a/app/views/admin/romaneios/index.html.erb b/app/views/admin/romaneios/index.html.erb new file mode 100644 index 0000000..5f1b918 --- /dev/null +++ b/app/views/admin/romaneios/index.html.erb @@ -0,0 +1,144 @@ +<%# app/views/admin/romaneios/index.html.erb %> +
+ +
+

Romaneio de entrega

+

+ Monta o "CONTROLE DE ENTREGA" — uma folha por veículo, assinada pelo motorista na retirada +

+
+ + <%= render 'shared/flash' %> + +
+ ℹ️ +

+ A coluna APARELHO é marcada sozinha para quem está como + NOVO na operação do mês, e o telefone vem da própria + operação — não é preciso subir uma segunda planilha. Tudo continua editável na tela + antes de imprimir. +

+
+ + <%# Importação %> +
+

Importar plano

+ + <%= form_with url: admin_romaneios_path, method: :post, multipart: true, + data: { turbo: false }, class: 'space-y-4' do %> +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + + <%# Reserva: o plano do dia pode ainda não estar publicado. %> +
+ + Ou enviar a planilha do plano (.xlsx) + +
+ + +
+

+ 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. +

+
+
+ <% end %> +
+ + <%# Romaneios já importados %> +
+ <% if @romaneios.empty? %> +
+

Nenhum romaneio ainda.

+

Escolha a data do plano acima e clique em “Buscar no SimpliRoute”.

+
+ <% else %> + + + + + + + + + + + + + <% @romaneios.each do |r| %> + + + + + + + + + <% end %> + +
DataPlanoOperaçãoVeículosOrigem
<%= l r.planned_date, format: '%d/%m/%Y' %><%= r.rotulo_plano.presence || '—' %><%= r.operacao_label || '—' %><%= r.romaneio_veiculos.size %> + <%= r.origem %> + + <%= 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 , 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 %> +
+ <% end %> +
+
diff --git a/app/views/admin/romaneios/show.html.erb b/app/views/admin/romaneios/show.html.erb new file mode 100644 index 0000000..5ad792e --- /dev/null +++ b/app/views/admin/romaneios/show.html.erb @@ -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 + + + + <% end %> + diff --git a/app/views/layouts/_navbar.html.erb b/app/views/layouts/_navbar.html.erb index 31f641b..941839a 100644 --- a/app/views/layouts/_navbar.html.erb +++ b/app/views/layouts/_navbar.html.erb @@ -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) } %>

Administração

@@ -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 %> diff --git a/bin/docker-boot b/bin/docker-boot new file mode 100755 index 0000000..ebd8348 --- /dev/null +++ b/bin/docker-boot @@ -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 diff --git a/bin/sondar_plano_do_dia b/bin/sondar_plano_do_dia new file mode 100755 index 0000000..877589e --- /dev/null +++ b/bin/sondar_plano_do_dia @@ -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 ') 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.' diff --git a/config/routes.rb b/config/routes.rb index 42ca49a..aa3c97f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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. diff --git a/db/migrate/20260827000001_create_romaneios.rb b/db/migrate/20260827000001_create_romaneios.rb new file mode 100644 index 0000000..383d949 --- /dev/null +++ b/db/migrate/20260827000001_create_romaneios.rb @@ -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 diff --git a/db/migrate/20260827000002_create_romaneio_veiculos.rb b/db/migrate/20260827000002_create_romaneio_veiculos.rb new file mode 100644 index 0000000..c14501e --- /dev/null +++ b/db/migrate/20260827000002_create_romaneio_veiculos.rb @@ -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 diff --git a/db/migrate/20260827000003_create_romaneio_linhas.rb b/db/migrate/20260827000003_create_romaneio_linhas.rb new file mode 100644 index 0000000..721ba05 --- /dev/null +++ b/db/migrate/20260827000003_create_romaneio_linhas.rb @@ -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:". 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 diff --git a/db/migrate/20260827000004_add_permissao_romaneio_aos_perfis.rb b/db/migrate/20260827000004_add_permissao_romaneio_aos_perfis.rb new file mode 100644 index 0000000..d839259 --- /dev/null +++ b/db/migrate/20260827000004_add_permissao_romaneio_aos_perfis.rb @@ -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 diff --git a/deploy/ntp-seguro.sh b/deploy/ntp-seguro.sh new file mode 100755 index 0000000..b78c97c --- /dev/null +++ b/deploy/ntp-seguro.sh @@ -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 <<>> 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 <</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 diff --git a/docker-compose.yml b/docker-compose.yml index 3996098..67c6321 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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. diff --git a/public/logo-gade.png b/public/logo-gade.png new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..8594d6f0263ebf6523f1fa5cc3f9d1289275048ef8cea303674d223a4693222a GIT binary patch literal 14440 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 Ik|rVl183Mdng9R* literal 0 HcmV?d00001 diff --git a/spec/factories.rb b/spec/factories.rb index 0a4d694..87e8820 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -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 diff --git a/spec/models/table_names_spec.rb b/spec/models/table_names_spec.rb index fb4faa5..97fcb1f 100644 --- a/spec/models/table_names_spec.rb +++ b/spec/models/table_names_spec.rb @@ -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) diff --git a/spec/policies/romaneio_policy_spec.rb b/spec/policies/romaneio_policy_spec.rb new file mode 100644 index 0000000..af4f2f5 --- /dev/null +++ b/spec/policies/romaneio_policy_spec.rb @@ -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 diff --git a/spec/requests/admin/romaneios_spec.rb b/spec/requests/admin/romaneios_spec.rb new file mode 100644 index 0000000..38b043e --- /dev/null +++ b/spec/requests/admin/romaneios_spec.rb @@ -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 diff --git a/spec/services/pdf/romaneio_pdf_spec.rb b/spec/services/pdf/romaneio_pdf_spec.rb new file mode 100644 index 0000000..c99d6cb --- /dev/null +++ b/spec/services/pdf/romaneio_pdf_spec.rb @@ -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 diff --git a/spec/services/romaneios/enriquecimento_gade_spec.rb b/spec/services/romaneios/enriquecimento_gade_spec.rb new file mode 100644 index 0000000..76c4d5e --- /dev/null +++ b/spec/services/romaneios/enriquecimento_gade_spec.rb @@ -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 diff --git a/spec/services/romaneios/importador_spec.rb b/spec/services/romaneios/importador_spec.rb new file mode 100644 index 0000000..63ae6a0 --- /dev/null +++ b/spec/services/romaneios/importador_spec.rb @@ -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 diff --git a/spec/services/romaneios/plano_planilha_spec.rb b/spec/services/romaneios/plano_planilha_spec.rb new file mode 100644 index 0000000..a57a1f9 --- /dev/null +++ b/spec/services/romaneios/plano_planilha_spec.rb @@ -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 diff --git a/spec/support/tabela_operacao.rb b/spec/support/tabela_operacao.rb new file mode 100644 index 0000000..968d9f8 --- /dev/null +++ b/spec/support/tabela_operacao.rb @@ -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 diff --git a/whatsapp/Dockerfile b/whatsapp/Dockerfile index 2e4316f..f693397 100644 --- a/whatsapp/Dockerfile +++ b/whatsapp/Dockerfile @@ -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"]