diff --git a/Imagens para implantação /Entregas SUDESTE 07.2026_FINAL.xlsx b/Imagens para implantação /Entregas SUDESTE 07.2026_FINAL.xlsx
new file mode 100644
index 0000000..9cb2e71
Binary files /dev/null and b/Imagens para implantação /Entregas SUDESTE 07.2026_FINAL.xlsx differ
diff --git a/Imagens para implantação /Imagem colada (2).png b/Imagens para implantação /Imagem colada (2).png
new file mode 100644
index 0000000..db3345f
Binary files /dev/null and b/Imagens para implantação /Imagem colada (2).png differ
diff --git a/Imagens para implantação /Imagem colada (3).png b/Imagens para implantação /Imagem colada (3).png
new file mode 100644
index 0000000..6b20174
Binary files /dev/null and b/Imagens para implantação /Imagem colada (3).png differ
diff --git a/Imagens para implantação /Imagem colada (4).png b/Imagens para implantação /Imagem colada (4).png
new file mode 100644
index 0000000..69627c3
Binary files /dev/null and b/Imagens para implantação /Imagem colada (4).png differ
diff --git a/Imagens para implantação /Imagem colada (5).png b/Imagens para implantação /Imagem colada (5).png
new file mode 100644
index 0000000..1b3b70c
Binary files /dev/null and b/Imagens para implantação /Imagem colada (5).png differ
diff --git a/Imagens para implantação /Imagem colada (6).png b/Imagens para implantação /Imagem colada (6).png
new file mode 100644
index 0000000..0fdef96
Binary files /dev/null and b/Imagens para implantação /Imagem colada (6).png differ
diff --git a/README.md b/README.md
index 4c511e6..b84f4c8 100644
--- a/README.md
+++ b/README.md
@@ -1366,3 +1366,185 @@ docker compose up -d --build
+---
+
+
+🔭 Roadmap — Integrações futuras com a API SimpliRoute (08/07/2026)
+
+Mapeamento completo da API do SimpliRoute (documentation.simpliroute.com) cruzado com o fluxo
+do sistema. **Nada aqui está implementado** — são os próximos passos priorizados. A base
+técnica já existe (`SimpliRoute::Client`, `SimpliRoute::PlanilhaCarga`, token via ENV):
+cada item vira uma fase própria quando for priorizado.
+
+### 1. 🚀 Carga direta no SimpliRoute (substitui o baixar/importar planilha)
+`POST /v1/routes/visits/` aceita criação **em lote**: um botão "Subir para o SimpliRoute"
+criaria as visitas da operação vigente direto pela API, com as mesmas regras da planilha
+(título `NF {nf} - {nome}`, janelas 08:00–18:00, lat/long do mês anterior, etc.).
+> ⚠️ **Pré-requisito que segura esta fase: consolidar os ENDEREÇOS antes da subida.**
+- Anti-duplicação: conferir `reference` + `planned_date` antes de criar (reexecutar não duplica).
+- Desfazer: `POST /v1/bulk/delete/visits/` permite implementar um "remover carga".
+
+### 2. 🧾 Relatório de comprovantes de entrega (POD)
+`GET /v1/plans/visits/{visit_id}/detail/` traz **foto, assinatura, hora e GPS** de cada
+entrega → gerar PDF por operação/mês (reuso do padrão Prawn em `app/services/pdf/`).
+Valor: faturamento com STS/prefeitura e defesa em disputas ("não recebi").
+
+### 3. ⚡ Webhooks — painel em tempo real
+`POST /v1/addons/webhooks/` com eventos `visit_checkout`, `route_started/finished`,
+`on_its_way` → endpoint público autenticado no app grava o evento e o painel atualiza
+**na hora**, sem esperar a sync de 30 min. Exige atenção à segurança (assinatura,
+idempotência) e URL pública estável.
+
+### 4. 📱 Aviso ao paciente via WhatsApp
+Evento `on_its_way` + ETA da API + Twilio já existente (`NotificacaoService`):
+"seu medicamento saiu para entrega". Reduz insucesso por **RESPONSÁVEL AUSENTE**
+(motivo real da lista de observations).
+
+### 5. 📺 Monitor de rotas ao vivo
+`GET /v1/plans/{date}/vehicles/` + visitas por rota → tela "Operação de hoje" com cada
+veículo, % concluído e atrasos (complementa o dashboard, que olha o passado).
+
+### 6. 👷 Sincronização de motoristas/veículos
+`GET /v1/accounts/drivers/` e `GET /v1/routes/vehicles/` ↔ usuários motoristas do sistema
+(hoje o vínculo é o nome digitado — sujeito a divergência).
+
+### 7. 📊 Datamart e 🏷️ tags/skills
+Export paginado de analytics para enriquecer dashboards; tags/skills para classificar
+visitas por tipo de material.
+
+
+
+---
+
+
+🆕 Atualização 13/07/2026 — Planilha da Operação (página + Excel do cliente), dashboard e correções
+
+### 📋 Planilha da Operação — página própria (`/dashboard/operacoes/planilha`)
+Tabela **espelho da planilha da operação** (uma linha por NF) para pesquisar e acompanhar
+ocorrências/status, acessada pelo botão **"📋 Planilha da operação"** no Dashboard de Operações
+(o botão leva junto o contexto atual: operação/modo Global, período e cross-filters ativos).
+- Colunas: Data, NF, Destinatário (endereço no tooltip), Unidade STS, Motorista, Veículo,
+ **Resultado** (badge Entregue/Falha/Pendente), **Ocorrência** (motivo do insucesso) e status
+ gade (RECORRENTE/NOVO). No modo Global aparece também a coluna Operação.
+- **Busca textual server-side** (param `q`, qualquer campo) + **paginação server-side**
+ (param `pg`, 15/página, janela `« 1 … 7 8 9 … 42 »`) — aguenta as milhares de linhas do Global.
+- Alternância 🏥 Operação / 🌐 Global, seletor de operação (auto-submit) e chips removíveis dos
+ filtros herdados. Reusa as linhas do `Analytics::OperacaoMetricas` (novo método `#buscar`) —
+ **nenhuma query nova**.
+- Arquivos: rota `operacoes_planilha`, action `planilha` + `montar_tabela_espelho`
+ (`OperacoesDashboardController`), views `planilha.html.erb` + `_espelho.html.erb`.
+
+### ⬇️ Download do Excel do cliente — planilha Entregas preenchida
+Botão **"⬇️ Baixar Excel preenchido"** na página da planilha: gera o `.xlsx` no formato do modelo
+**"Entregas SUDESTE MM.AAAA_FINAL"** com as **3 abas, 1:1** — automatiza o preenchimento que era
+feito manualmente para entregar ao cliente.
+- **RESUMO** — mês, Total Previsto/Realizado/Performance e os quadros **EMAD** e **UBS** por
+ coordenadoria (CRS fixas do modelo) com **motivos de "Não Entregue"** (Óbito, Responsável
+ Ausente, Endereço não localizado, Recusa, Paciente não reside, Outros) classificados a partir
+ da `observation` do rastreio; linhas Total e Performance %.
+- **ENTREGAS** — colunas A–V da própria tabela `gade_entregas_*` (ordem física de importação =
+ ordem da planilha original) + **W–Z preenchidas pelo rastreio**: STATUS, ENTREGA (Sim/Não),
+ DATA OCORRÊNCIA (data real do checkout) e OCORRÊNCIA — último status de cada NF (mesma CTE
+ `ROW_NUMBER` dos painéis).
+- **SimpliRoute** — dump cru do rastreio com as **46 colunas exatas** do export original
+ (todas as visitas das NFs da operação, incluindo repetidas).
+- **Estilo idêntico ao modelo** (cores extraídas do `styles.xml` do próprio arquivo): cabeçalho
+ ENTREGAS azul `1155CC` (A–T) + vermelho `C00000` (U–Z), quadros do RESUMO em
+ `002060`/`0070C0`/`C00000`/`A5A5A5`, status verde/laranja/azul, títulos 16pt, performance em
+ itálico %, cabeçalhos mesclados. Validado offline com caxlsx + LibreOffice.
+- Arquivos: `Analytics::PlanilhaEntregas` (dados) + `Analytics::PlanilhaEntregasXlsx` (binário,
+ caxlsx), rota `operacoes_planilha_baixar`, action `baixar_planilha`.
+- ⚠️ **Ressalvas:** nomes de coluna das tabelas `gade_entregas_*`/rastreio foram deduzidos com
+ fallback seguro (coluna inexistente sai **vazia**, sem quebrar o layout) — conferir o primeiro
+ download real e ajustar `COLUNAS_GADE`/`RASTREIO_COLUNAS` se algo sair em branco. Os 2
+ **gráficos embutidos** do RESUMO não são replicados. "Conferência Documentos" é controle
+ manual (sai "A iniciar").
+
+### 📊 Dashboard financeiro
+- **Paginação nas tabelas de pagamentos** ("realizados" e "pendentes"): 10 linhas/página com
+ abas e contador ("1–10 de 47"), client-side (função genérica `paginarTabela`); some com ≤10 itens.
+- **Novo gráfico "Pagamentos por dia"** abaixo da "Evolução do custo": barras verdes com o valor
+ pago por data de pagamento (`pago_em`) + linha tracejada do **acumulado** — o fluxo de caixa,
+ que não existia em nenhuma tela (`build_grafico_pagamentos`). Quando não há pagamento no
+ período mostra estado vazio (💸 + valor aguardando pagamento) em vez de gráfico zerado.
+ > Iterações descartadas no caminho: "Entregas por dia" (redundante — valor = qtd × preço fixo)
+ > e "Resultado por dia" (já existe na tela de Operações).
+- O card dos gráficos virou `flex-col` e preenche toda a altura ao lado do ranking de motoristas
+ (sumiu o vazio embaixo da linha).
+
+### 🔴 Correções
+- **"Rodapé quebrado" no celular:** era uma **barra de rolagem horizontal** (laranja) colada no
+ rodapé — a legenda dos gráficos não quebrava linha e estourava ~37px a largura em telas
+ <~430px. ✅ Cabeçalhos/legendas dos gráficos com `flex-wrap` + **`overflow-x-hidden` no
+ ``** (rede de segurança; tabelas largas continuam rolando nos próprios contêineres
+ `overflow-x-auto`).
+
+### 📌 Padrões (reforçados)
+- Tabelas externas sempre read-only, nome de tabela via whitelist + `quote_table_name`, colunas
+ em whitelist fixa com fallback NULL (padrão `OperacaoMetricas`/`PlanilhaCarga`).
+- Excel com `caxlsx` (mesmo padrão da planilha de carga SimpliRoute): serviço de linhas +
+ serviço de binário + `send_data` no controller.
+
+> **Sem migration.** Deploy normal; em produção reiniciar o Puma após o deploy.
+
+
+
+---
+
+
+🆕 Atualização 15/07/2026 — Entrega de Termo Especial (novo card de preço + lançamento com dois tipos)
+
+O negócio passou a diferenciar **termo normal** e **termo especial**, cada um com seu preço.
+O modal de lançamento agora permite informar **quantos termos de cada tipo** o motorista
+entregou, num único envio.
+
+### 🆕 Funcionalidades
+- **Novo card de preço "📋 Entrega de Termo Especial"** em **Admin → Configurações**
+ (chave `preco_termo_especial`, moeda). Criado com **R$ 0,00** — definir o valor real no
+ card antes de usar. O card aparece automático (a view itera todas as configs).
+- **Novo pilar `tipo: termo_especial`** (enum 6, cor **ciano**, label "Entrega de Termo
+ Especial") — mesmo comportamento do termo normal: lote numa linha (`quantidade` × preço),
+ `manual: true`, `tracking_id` sintético `TERMO-`.
+- **Modal "📄 Entrega de termo" com dois steppers** lado a lado — "📄 Termo Normal"
+ (inicia em 1) e "📋 Termo Especial" (inicia em 0), cada um exibindo seu preço unitário.
+ Um clique em "Adicionar termos ✓" lança os dois lotes de uma vez (exige ao menos 1 termo
+ no total; mínimo 0 em cada stepper).
+- **Exibição em todas as telas**: linha "Termo Especial" no painel 📊 Resumo do Passo 2,
+ card próprio no Revisar, badge ciano "Entrega de Termo Especial ×N" nos apontamentos
+ manuais (show) e nos PDFs (extrato, relatório do motorista e financeiro — automático,
+ iteram `TIPO_CORES`).
+
+### ⚙️ Como funciona (técnico)
+- **Migration** (rodar `db:migrate`): `20260715000001_add_preco_termo_especial_configuracao`
+ — cria a config `preco_termo_especial` (idempotente) + seed correspondente.
+- `Configuracao`: chave nova em `CHAVES`/`CHAVES_MOEDA`/`LABELS`/`ICONES`/`mapa_de_precos`
+ + helper `preco_termo_especial`.
+- `Consolidacao#adicionar_termos!` ganhou o parâmetro **`tipo:`** (default `'termo'`,
+ validado em `termo`/`termo_especial`); o preço vem de
+ `ConsolidacaoEntrega.valor_para(tipo)`.
+- `ConsolidacaoEntregasController#apontar_termo` lê `quantidade` (normal) +
+ `quantidade_especial` e cria **um lote por tipo** dentro de uma transação, auditando as
+ duas quantidades. `contar_manuais` soma a `quantidade` dos **dois** tipos de termo; os
+ agregados `group(:tipo).sum(:quantidade)` já funcionavam sem mudança.
+- Stimulus: `apontamento_termo_controller.js` com targets `quantidade`/`quantidadeEspecial`
+ e ações `maisEspecial`/`menosEspecial`; `validacao_controller.js` com target opcional
+ `qtdTermoEspecial`.
+
+### 📂 Arquivos
+```
+db/migrate/20260715000001_add_preco_termo_especial_configuracao.rb (nova)
+app/models/configuracao.rb · consolidacao.rb · consolidacao_entrega.rb
+app/controllers/consolidacao_entregas_controller.rb
+app/javascript/controllers/apontamento_termo_controller.js · validacao_controller.js
+app/views/consolidacao_entregas/validar.html.erb · revisar.html.erb
+app/views/consolidacoes/show.html.erb
+app/services/pdf/relatorio_motorista_pdf.rb
+db/seeds.rb · spec/models/configuracao_spec.rb
+```
+
+```bash
+docker-compose exec app bundle exec rails db:migrate
+```
+
+
+
diff --git a/app/controllers/consolidacao_entregas_controller.rb b/app/controllers/consolidacao_entregas_controller.rb
index a0092d0..df04cc8 100644
--- a/app/controllers/consolidacao_entregas_controller.rb
+++ b/app/controllers/consolidacao_entregas_controller.rb
@@ -64,7 +64,8 @@ class ConsolidacaoEntregasController < ApplicationController
bonus: Configuracao.preco_bonus,
desconto: Configuracao.preco_desconto,
extraordinaria: Configuracao.preco_extraordinaria,
- termo: Configuracao.preco_termo
+ termo: Configuracao.preco_termo,
+ termo_especial: Configuracao.preco_termo_especial
}
end
@@ -267,25 +268,38 @@ class ConsolidacaoEntregasController < ApplicationController
end
# POST /consolidacoes/:consolidacao_id/consolidacao_entregas/apontar_termo
- # ENTREGA DE TERMO — lança um lote de N termos para o motorista. Não há NF nem
- # código de rastreio: só a quantidade, a um preço fixo configurável.
+ # ENTREGA DE TERMO — lança lotes de termos para o motorista. Não há NF nem
+ # código de rastreio: só as quantidades (normal e especial), cada tipo a um
+ # preço fixo configurável.
def apontar_termo
authorize @consolidacao, :update?
- motorista = @consolidacao.adicionar_termos!(
- motorista: params[:motorista].to_s.strip,
- quantidade: params[:quantidade],
- vehicle: params[:vehicle], # veículo escolhido no lançamento (Q3)
- user: current_user
- )
+ qtd_normal = params[:quantidade].to_i
+ qtd_especial = params[:quantidade_especial].to_i
+ raise ArgumentError, 'Informe ao menos um termo (normal ou especial).' if qtd_normal < 1 && qtd_especial < 1
+
+ motorista = nil
+ ActiveRecord::Base.transaction do
+ { 'termo' => qtd_normal, 'termo_especial' => qtd_especial }.each do |tipo, qtd|
+ next if qtd < 1
+
+ motorista = @consolidacao.adicionar_termos!(
+ motorista: params[:motorista].to_s.strip,
+ quantidade: qtd,
+ tipo: tipo,
+ vehicle: params[:vehicle], # veículo escolhido no lançamento (Q3)
+ user: current_user
+ )
+ end
+ end
auditar!(:editar, @consolidacao,
- dados_novos: { entrega_termo: params[:quantidade].to_i, motorista: motorista })
+ dados_novos: { entrega_termo: qtd_normal, entrega_termo_especial: qtd_especial, motorista: motorista })
respond_to do |format|
format.json { render json: resumo_json(motorista).merge(motorista: motorista) }
format.html do
redirect_to validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: motorista),
- notice: "#{params[:quantidade].to_i} entrega(s) de termo adicionada(s) para #{motorista}."
+ notice: "#{qtd_normal + qtd_especial} entrega(s) de termo adicionada(s) para #{motorista}."
end
end
rescue ArgumentError => e
@@ -534,8 +548,9 @@ class ConsolidacaoEntregasController < ApplicationController
# Conta lançamentos manuais como ENTREGAS: cada termo vale `quantidade` (lote
# numa só linha); os demais apontamentos manuais valem 1 por nota (tracking).
def contar_manuais(rel_manual)
- rel_manual.termo.sum(:quantidade) +
- rel_manual.where.not(tipo: ConsolidacaoEntrega.tipos[:termo]).distinct.count(:tracking_id)
+ tipos_termo = ConsolidacaoEntrega.tipos.values_at(:termo, :termo_especial)
+ rel_manual.where(tipo: tipos_termo).sum(:quantidade) +
+ rel_manual.where.not(tipo: tipos_termo).distinct.count(:tracking_id)
end
# `vehicles` (opcional, string ou array): quando presente, TODO o resumo
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index 78bf621..e1f349c 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -165,6 +165,9 @@ class DashboardController < ApplicationController
@pagamentos_feitos = pagos.includes(:consolidacao, :pagador).order(pago_em: :desc)
@pagamentos_pendentes = pendentes.includes(:consolidacao).order(consolidacao_id: :desc)
+
+ # Fluxo de pagamentos por dia (valor pago + acumulado) para o gráfico
+ @grafico_pagamentos_dia = build_grafico_pagamentos(pagos)
end
# Visão financeira (custo) das consolidações finalizadas do período, já com o
@@ -241,6 +244,28 @@ class DashboardController < ApplicationController
{ labels: labels, valores: valores, qtds: qtds }
end
+ # Valor pago por dia (pela data do pagamento) + acumulado no período,
+ # para o gráfico de fluxo de pagamentos.
+ def build_grafico_pagamentos(pagos)
+ dias = (@periodo_inicio..@periodo_fim).to_a
+
+ if dias.size <= MAX_DIAS_GRAFICO
+ soma = pagos.group("DATE(pago_em)").sum(:valor_total).transform_keys(&:to_s)
+ labels = dias.map { |d| d.strftime('%d/%m') }
+ chaves = dias.map(&:to_s)
+ else
+ soma = pagos.group("TO_CHAR(pago_em, 'YYYY-MM')").sum(:valor_total)
+ meses = meses_no_periodo
+ labels = meses.map { |m| Date.strptime(m, '%Y-%m').strftime('%m/%Y') }
+ chaves = meses
+ end
+
+ valores = chaves.map { |k| soma[k].to_f.round(2) }
+ acumulado = 0
+ { labels: labels, valores: valores,
+ acumulado: valores.map { |v| (acumulado += v).round(2) } }
+ end
+
# Meses ("YYYY-MM") do início ao fim do período selecionado, inclusive.
def meses_no_periodo
meses = []
diff --git a/app/controllers/operacoes_dashboard_controller.rb b/app/controllers/operacoes_dashboard_controller.rb
index 930cb5c..6e79101 100644
--- a/app/controllers/operacoes_dashboard_controller.rb
+++ b/app/controllers/operacoes_dashboard_controller.rb
@@ -7,6 +7,11 @@
class OperacoesDashboardController < ApplicationController
MODOS = %w[operacao global comparar].freeze
+ # Linhas por página da tabela espelho (planilha da operação)
+ ESPELHO_POR_PAGINA = 15
+
+ XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'.freeze
+
def index
authorize :dashboard, :operacoes?
@@ -34,8 +39,67 @@ class OperacoesDashboardController < ApplicationController
end
end
+ # Planilha da operação (tabela espelho): página própria com busca e paginação.
+ # Aceita os mesmos parâmetros do index (operacao/modo global/período/cross-
+ # filters), então o botão no dashboard preserva o contexto atual.
+ def planilha
+ authorize :dashboard, :operacoes?
+
+ @periodo_inicio, @periodo_fim = periodo_selecionado
+ @modo = params[:modo] == 'global' ? 'global' : 'operacao'
+ @operacoes_agrupadas = Operacao.agrupadas_por_mes
+ @tabelas_validas = Operacao.nomes_validos
+ @filtros, @chips = cross_filtros
+ @data_filtro = parse_data(params[:f_data])
+
+ if @modo == 'global'
+ @metricas = montar(@tabelas_validas, inicio: @periodo_inicio, fim: @periodo_fim, filtros: @filtros, data: @data_filtro)
+ else
+ @operacao = Operacao.sanitizar([params[:operacao]]).first || @tabelas_validas.first
+ @metricas = montar([@operacao], filtros: @filtros, data: @data_filtro) if @operacao
+ end
+
+ montar_tabela_espelho if @metricas
+ end
+
+ # GET /dashboard/operacoes/planilha/baixar?operacao=gade_entregas_...
+ # Baixa a planilha Entregas (.xlsx) da operação já preenchida com o resultado
+ # do rastreio — o modelo que é entregue ao cliente.
+ def baixar_planilha
+ authorize :dashboard, :operacoes?
+
+ tabela = params[:operacao].to_s
+ unless Operacao.valida?(tabela)
+ return redirect_to(operacoes_planilha_path, alert: 'Selecione uma operação válida.')
+ end
+
+ planilha = Analytics::PlanilhaEntregas.new(tabela)
+ if planilha.linhas.empty?
+ return redirect_to(operacoes_planilha_path(operacao: tabela),
+ alert: "A operação #{planilha.label} não tem registros.")
+ end
+
+ send_data Analytics::PlanilhaEntregasXlsx.new(planilha).gerar,
+ filename: "Entregas #{planilha.label} #{Date.current.strftime('%m.%Y')}.xlsx",
+ type: XLSX_MIME,
+ disposition: 'attachment'
+ end
+
private
+ # Tabela espelho da planilha da operação: busca textual (q) + paginação (pg)
+ # sobre as linhas já cross-filtradas. Ordena pelas mais recentes (checkout;
+ # sem checkout vai para o fim).
+ def montar_tabela_espelho
+ @busca = params[:q].to_s.strip
+ linhas = @metricas.buscar(@busca).sort_by { |r| r['checkout'].to_s }.reverse
+
+ @espelho_total = linhas.size
+ @espelho_paginas = [(@espelho_total / ESPELHO_POR_PAGINA.to_f).ceil, 1].max
+ @espelho_pagina = params[:pg].to_i.clamp(1, @espelho_paginas)
+ @espelho_linhas = linhas[(@espelho_pagina - 1) * ESPELHO_POR_PAGINA, ESPELHO_POR_PAGINA] || []
+ end
+
def montar(tabelas, inicio: nil, fim: nil, filtros: {}, data: nil)
Analytics::OperacaoMetricas.new(tabelas: tabelas, inicio: inicio, fim: fim, filtros: filtros, data: data)
end
diff --git a/app/javascript/controllers/apontamento_termo_controller.js b/app/javascript/controllers/apontamento_termo_controller.js
index 260bd55..5068d0e 100644
--- a/app/javascript/controllers/apontamento_termo_controller.js
+++ b/app/javascript/controllers/apontamento_termo_controller.js
@@ -1,17 +1,18 @@
// app/javascript/controllers/apontamento_termo_controller.js
-// Stimulus — Entrega de termo. Lança um lote de N termos para o motorista da tela.
-// Entrega sem NF nem código de rastreio: só a quantidade (stepper − N +), a um
-// preço fixo configurável.
+// Stimulus — Entrega de termo. Lança lotes de termos (normal e/ou especial) para
+// o motorista da tela. Entrega sem NF nem código de rastreio: só as quantidades
+// (um stepper − N + por tipo), cada tipo a um preço fixo configurável.
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
- static targets = ["modal", "quantidade", "erro", "veiculo"]
+ static targets = ["modal", "quantidade", "quantidadeEspecial", "erro", "veiculo"]
static values = { apontarTermoUrl: String, motorista: String }
abrir() {
this.erroTarget.classList.add("hidden")
this.quantidadeTarget.value = "1"
+ this.quantidadeEspecialTarget.value = "0"
this.modalTarget.classList.remove("hidden")
}
@@ -24,26 +25,39 @@ export default class extends Controller {
if (event.target === this.modalTarget) this.fechar()
}
- // Stepper
+ // Steppers (mínimo 0 em ambos; o envio exige ao menos 1 no total)
mais() {
- this.quantidadeTarget.value = String(this.qtdAtual() + 1)
+ this.quantidadeTarget.value = String(this.qtdAtual(this.quantidadeTarget) + 1)
}
menos() {
- this.quantidadeTarget.value = String(Math.max(1, this.qtdAtual() - 1))
+ this.quantidadeTarget.value = String(Math.max(0, this.qtdAtual(this.quantidadeTarget) - 1))
}
- qtdAtual() {
- const n = parseInt(this.quantidadeTarget.value, 10)
- return Number.isFinite(n) && n > 0 ? n : 1
+ maisEspecial() {
+ this.quantidadeEspecialTarget.value = String(this.qtdAtual(this.quantidadeEspecialTarget) + 1)
+ }
+
+ menosEspecial() {
+ this.quantidadeEspecialTarget.value = String(Math.max(0, this.qtdAtual(this.quantidadeEspecialTarget) - 1))
+ }
+
+ qtdAtual(alvo) {
+ const n = parseInt(alvo.value, 10)
+ return Number.isFinite(n) && n > 0 ? n : 0
}
async adicionar(event) {
event?.preventDefault()
- const quantidade = this.qtdAtual()
- const motorista = this.motoristaValue.trim()
+ const quantidade = this.qtdAtual(this.quantidadeTarget)
+ const quantidadeEspecial = this.qtdAtual(this.quantidadeEspecialTarget)
+ const motorista = this.motoristaValue.trim()
if (!motorista) { this.mostrarErro("Motorista não identificado."); return }
+ if (quantidade + quantidadeEspecial < 1) {
+ this.mostrarErro("Informe ao menos um termo (normal ou especial).")
+ return
+ }
const btn = event.currentTarget
btn.disabled = true
@@ -56,9 +70,10 @@ export default class extends Controller {
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({
- motorista: motorista,
- quantidade: quantidade,
- vehicle: this.hasVeiculoTarget ? this.veiculoTarget.value : ""
+ motorista: motorista,
+ quantidade: quantidade,
+ quantidade_especial: quantidadeEspecial,
+ vehicle: this.hasVeiculoTarget ? this.veiculoTarget.value : ""
})
})
const data = await resp.json()
diff --git a/app/javascript/controllers/validacao_controller.js b/app/javascript/controllers/validacao_controller.js
index a8c3c79..e75c130 100644
--- a/app/javascript/controllers/validacao_controller.js
+++ b/app/javascript/controllers/validacao_controller.js
@@ -12,7 +12,7 @@ export default class extends Controller {
"consolidarBtn", "consolidarHint",
"contadorClassificadas", "contadorTotal", "contadorManuais", "manuaisInfo",
"qtdNormal", "qtdRetirada", "qtdBonus", "qtdDesconto", "qtdExtraordinaria",
- "qtdTermo", "qtdManuais", "valorTotal"
+ "qtdTermo", "qtdTermoEspecial", "qtdManuais", "valorTotal"
]
static values = {
@@ -187,6 +187,8 @@ export default class extends Controller {
this.qtdExtraordinariaTarget.textContent = porTipo["extraordinaria"] || 0
if (this.hasQtdTermoTarget)
this.qtdTermoTarget.textContent = porTipo["termo"] || 0
+ if (this.hasQtdTermoEspecialTarget)
+ this.qtdTermoEspecialTarget.textContent = porTipo["termo_especial"] || 0
const valor = dados.valor_total || 0
this.valorTotalTarget.textContent =
diff --git a/app/models/configuracao.rb b/app/models/configuracao.rb
index 56ae369..d42932a 100644
--- a/app/models/configuracao.rb
+++ b/app/models/configuracao.rb
@@ -10,6 +10,7 @@ class Configuracao < ApplicationRecord
preco_desconto
preco_extraordinaria
preco_termo
+ preco_termo_especial
notificacao_whatsapp
notificacao_email
empresa_nome
@@ -23,6 +24,7 @@ class Configuracao < ApplicationRecord
preco_desconto
preco_extraordinaria
preco_termo
+ preco_termo_especial
].freeze
validates :chave, presence: true, inclusion: { in: CHAVES }, uniqueness: true
@@ -58,6 +60,10 @@ class Configuracao < ApplicationRecord
valor('preco_termo').to_f
end
+ def self.preco_termo_especial
+ valor('preco_termo_especial').to_f
+ end
+
# Mapa usado pelo DashboardController (Fase 3)
def self.mapa_de_precos
{
@@ -66,7 +72,8 @@ class Configuracao < ApplicationRecord
bonus: preco_bonus,
desconto: preco_desconto,
extraordinaria: preco_extraordinaria,
- termo: preco_termo
+ termo: preco_termo,
+ termo_especial: preco_termo_especial
}
end
@@ -77,6 +84,7 @@ class Configuracao < ApplicationRecord
'preco_desconto' => 'Desconto',
'preco_extraordinaria' => 'Entrega Extraordinária',
'preco_termo' => 'Entrega de Termo',
+ 'preco_termo_especial' => 'Entrega de Termo Especial',
'notificacao_whatsapp' => 'Notificação WhatsApp',
'notificacao_email' => 'Notificação E-mail',
'empresa_nome' => 'Nome da Empresa'
@@ -89,6 +97,7 @@ class Configuracao < ApplicationRecord
'preco_desconto' => '⚠️',
'preco_extraordinaria' => '✨',
'preco_termo' => '📄',
+ 'preco_termo_especial' => '📋',
'notificacao_whatsapp' => '💬',
'notificacao_email' => '✉️',
'empresa_nome' => '🏢'
diff --git a/app/models/consolidacao.rb b/app/models/consolidacao.rb
index 8bb25d9..7d14d9e 100644
--- a/app/models/consolidacao.rb
+++ b/app/models/consolidacao.rb
@@ -339,10 +339,15 @@ class Consolidacao < ApplicationRecord
# para atrelar (só motorista + quantidade, a um preço fixo configurável). Fica
# numa única linha (manual: true): quantidade guarda o N e valor_aplicado guarda
# o total do lote, então recalcular_motorista! e demais somas seguem inalteradas.
- def adicionar_termos!(motorista:, quantidade:, user:, vehicle: nil)
+ # `tipo` distingue termo normal ('termo') de termo especial ('termo_especial'),
+ # cada um com seu preço configurável.
+ def adicionar_termos!(motorista:, quantidade:, user:, vehicle: nil, tipo: 'termo')
nome = motorista.to_s.strip
raise ArgumentError, 'Informe o motorista do termo.' if nome.blank?
+ tipo = tipo.to_s
+ raise ArgumentError, 'Tipo de termo inválido.' unless %w[termo termo_especial].include?(tipo)
+
qtd = quantidade.to_i
raise ArgumentError, 'Informe uma quantidade válida de termos.' if qtd < 1
@@ -351,10 +356,10 @@ class Consolidacao < ApplicationRecord
consolidacao_entregas.create!(
tracking_id: "TERMO-#{SecureRandom.uuid}", # sintético: não há Entrega/NF
- tipo: 'termo',
+ tipo: tipo,
motorista_nome: nome,
quantidade: qtd,
- valor_aplicado: qtd * ConsolidacaoEntrega.valor_para('termo'), # total do lote
+ valor_aplicado: qtd * ConsolidacaoEntrega.valor_para(tipo), # total do lote
manual: true,
vehicle: vehicle.presence, # veículo escolhido no lançamento (Q3)
created_by: user&.id
diff --git a/app/models/consolidacao_entrega.rb b/app/models/consolidacao_entrega.rb
index df93357..2ce0894 100644
--- a/app/models/consolidacao_entrega.rb
+++ b/app/models/consolidacao_entrega.rb
@@ -15,7 +15,8 @@ class ConsolidacaoEntrega < ApplicationRecord
bonus: 2,
desconto: 3,
extraordinaria: 4,
- termo: 5
+ termo: 5,
+ termo_especial: 6
}
TIPO_CORES = {
@@ -24,7 +25,8 @@ class ConsolidacaoEntrega < ApplicationRecord
'bonus' => { bg: 'bg-white border border-orange-500', text: 'text-black', label: 'Bônus' },
'desconto' => { bg: 'bg-gray-900', text: 'text-white', label: 'Desconto' },
'extraordinaria' => { bg: 'bg-purple-600', text: 'text-white', label: 'Entrega Extraordinária' },
- 'termo' => { bg: 'bg-blue-600', text: 'text-white', label: 'Entrega de Termo' }
+ 'termo' => { bg: 'bg-blue-600', text: 'text-white', label: 'Entrega de Termo' },
+ 'termo_especial' => { bg: 'bg-cyan-600', text: 'text-white', label: 'Entrega de Termo Especial' }
}.freeze
# Uma entrega pode ter vários pilares (Normal + Bônus + Retirada…),
@@ -54,6 +56,7 @@ class ConsolidacaoEntrega < ApplicationRecord
when 'desconto' then Configuracao.preco_desconto
when 'extraordinaria' then Configuracao.preco_extraordinaria
when 'termo' then Configuracao.preco_termo
+ when 'termo_especial' then Configuracao.preco_termo_especial
else 0
end
end
diff --git a/app/services/analytics/operacao_metricas.rb b/app/services/analytics/operacao_metricas.rb
index 97c2834..e05bf31 100644
--- a/app/services/analytics/operacao_metricas.rb
+++ b/app/services/analytics/operacao_metricas.rb
@@ -45,6 +45,18 @@ module Analytics
end
end
+ # Campos pesquisáveis da tabela espelho (planilha da operação).
+ BUSCA_CAMPOS = %w[reference_id driver vehicle status observation contact_name
+ address nome_completo endereco_completo status_gade operacao].freeze
+
+ # Busca textual da tabela espelho: filtra as linhas (já pós cross-filter)
+ # por qualquer campo exibido na planilha.
+ def buscar(texto)
+ termo = texto.to_s.strip.downcase
+ return linhas if termo.empty?
+ linhas.select { |r| BUSCA_CAMPOS.any? { |c| r[c].to_s.downcase.include?(termo) } }
+ end
+
# Taxas (úteis no comparativo).
def taxa_sucesso
pct(sucesso, total)
diff --git a/app/services/analytics/planilha_entregas.rb b/app/services/analytics/planilha_entregas.rb
new file mode 100644
index 0000000..45f25cc
--- /dev/null
+++ b/app/services/analytics/planilha_entregas.rb
@@ -0,0 +1,242 @@
+# app/services/analytics/planilha_entregas.rb
+#
+# Monta os dados da "planilha Entregas" entregue ao cliente (modelo
+# "Entregas SUDESTE MM.AAAA_FINAL.xlsx"), com as 3 abas do modelo:
+#
+# RESUMO — totais/performance e quadro por coordenadoria (EMAD e UBS),
+# com os motivos de "Não Entregue" agregados do rastreio.
+# ENTREGAS — planilha do cliente (A..V da tabela gade_entregas_*) com as
+# colunas de resultado W..Z preenchidas pelo rastreio (o
+# preenchimento que hoje é feito manualmente).
+# SimpliRoute — dump cru do rastreio (todas as visitas das NFs da operação).
+#
+# SEGURANÇA: tabela passa pela whitelist (Operacao.sanitizar) + quote_table_name;
+# colunas são whitelist fixa e as ausentes viram NULL (mesmo padrão do
+# OperacaoMetricas). Bases SOMENTE LEITURA.
+module Analytics
+ class PlanilhaEntregas
+ # Colunas da tabela gade (snake_case) -> cabeçalho exato do modelo (A..V).
+ # Coluna que não existir em alguma operação sai vazia, preservando o layout.
+ COLUNAS_GADE = {
+ 'ordem' => 'Ordem',
+ 'operacao' => 'Operação',
+ 'coordenadoria' => 'Coordenadoria',
+ 'supervisao' => 'Supervisão',
+ 'unidade' => 'Unidade',
+ 'data_cadastro' => 'Data Cadastro',
+ 'cartao_sus' => 'Cartão SUS',
+ 'nome_completo' => 'Nome Completo',
+ 'endereco_completo' => 'Endereço Completo',
+ 'nome_responsavel' => 'Nome Responsável',
+ 'telefones' => 'Telefones',
+ 'area_de_risco' => 'Área de Risco',
+ 'numero_ativo' => 'Numero Ativo',
+ 'num_serie_base' => 'Num Serie Base',
+ 'num_serie_pulverizador' => 'Num Serie Pulverizador',
+ 'observacoes' => 'Observações',
+ 'endereco_sem_complemento' => 'Endereço Sem Complemento',
+ 'complemento' => 'Complemento',
+ 'latitude' => 'Latitude',
+ 'longitude' => 'Longitude',
+ 'status' => 'STATUS',
+ 'nota_fiscal' => 'NOTA FISCAL'
+ }.freeze
+
+ # Cabeçalho completo A..Z da aba ENTREGAS (gade + resultado do rastreio).
+ CABECALHO = (COLUNAS_GADE.values + ['STATUS', 'ENTREGA', 'DATA OCORRÊNCIA', 'OCORRÊNCIA']).freeze
+
+ # Aba SimpliRoute: cabeçalho exato do modelo -> colunas candidatas na tabela
+ # de rastreio (a primeira que existir é usada; nenhuma existente = vazia).
+ RASTREIO_COLUNAS = {
+ 'Tracking ID' => %w[tracking_id],
+ 'Referência ID' => %w[reference_id],
+ 'Data prevista' => %w[planned_date],
+ 'Motorista' => %w[driver],
+ 'Co-pilotos' => %w[copilots co_pilotos copilotos],
+ 'Veículo' => %w[vehicle],
+ 'Título' => %w[title titulo],
+ 'Endereço' => %w[address],
+ 'ETA' => %w[eta],
+ 'ETD' => %w[etd],
+ 'Checkin' => %w[checkin],
+ 'Checkout' => %w[checkout],
+ 'Responsável' => %w[receiver responsavel responsible],
+ 'Tempo de serviço estimado' => %w[estimated_service_time tempo_de_servico_estimado duration],
+ 'Tempo de serviço realme' => %w[real_service_time tempo_de_servico_real],
+ 'Antecipação' => %w[early antecipacao anticipation],
+ 'Atraso' => %w[delay atraso],
+ 'Latitude' => %w[latitude],
+ 'Longitude' => %w[longitude],
+ 'Checkout latitude' => %w[checkout_latitude],
+ 'Checkout longitude' => %w[checkout_longitude],
+ 'Load' => %w[load load_1 carga],
+ 'Load 2' => %w[load_2],
+ 'Load 3' => %w[load_3],
+ 'Load 4' => %w[load_4],
+ 'Estado' => %w[status],
+ 'Comentários' => %w[checkout_comment comments comentarios comment],
+ 'Observações' => %w[observation observacoes],
+ 'Janela de horário Inicial 1' => %w[window_start window_start_1 janela_inicial_1],
+ 'Janela de horário Final 1' => %w[window_end window_end_1 janela_final_1],
+ 'Janela de horário Inicial 2' => %w[window_start_2 janela_inicial_2],
+ 'Janela de horário Final 2' => %w[window_end_2 janela_final_2],
+ 'Habilidades necessárias' => %w[skills_required habilidades_necessarias],
+ 'Habilidades adicionais' => %w[skills_optional habilidades_adicionais],
+ 'Notas' => %w[notes notas],
+ 'Nome de contato' => %w[contact_name],
+ 'Telefone de contato' => %w[contact_phone telefone_de_contato],
+ 'Correio eletrônico de contato' => %w[contact_email correio_eletronico_de_contato],
+ 'ID da rota' => %w[route_id id_da_rota],
+ 'ID da conta' => %w[account_id],
+ 'Nome da conta' => %w[account_name nome_da_conta],
+ 'Nota Fiscal' => %w[nota_fiscal],
+ 'Termo de Recebimento' => %w[termo_de_recebimento],
+ 'Foto da Fachada' => %w[foto_da_fachada],
+ 'Relatório de visita' => %w[relatorio_de_visita visit_report],
+ 'Número de Série da Base (Aparelho)' => %w[numero_de_serie_da_base_aparelho num_serie_base numero_serie_base]
+ }.freeze
+
+ # Quadro do RESUMO — linhas fixas do modelo.
+ COORDENADORIAS = ['CRS Sudeste', 'CRS Sul', 'CRS Leste', 'CRS Norte', 'CRS Oeste', 'CRS Centro'].freeze
+ MOTIVOS = ['Óbito', 'Responsável Ausente', 'Endereço não localizado', 'Recusa',
+ 'Paciente não reside no local', 'Outros'].freeze
+ GRUPOS = %w[EMAD UBS].freeze
+
+ MESES_PT = %w[JANEIRO FEVEREIRO MARÇO ABRIL MAIO JUNHO JULHO AGOSTO SETEMBRO OUTUBRO NOVEMBRO DEZEMBRO].freeze
+
+ attr_reader :tabela
+
+ def initialize(tabela)
+ @tabela = Operacao.sanitizar([tabela]).first
+ raise ArgumentError, "Operação inválida: #{tabela}" unless @tabela
+ end
+
+ def label
+ Operacao.label(@tabela)
+ end
+
+ # ── Aba ENTREGAS ─────────────────────────────────────────────
+ # Array de hashes: chaves de COLUNAS_GADE + rastreio_status/checkout/observation.
+ # ORDER BY g.ctid preserva a ordem física de importação — a mesma ordem da
+ # planilha original do cliente (tabelas gade são read-only após o import).
+ def linhas
+ @linhas ||= begin
+ gade = conn.quote_table_name(@tabela)
+ sql = <<~SQL
+ WITH ultimo AS (
+ SELECT *,
+ ROW_NUMBER() OVER (PARTITION BY reference_id ORDER BY checkout DESC NULLS LAST) AS rn
+ FROM #{conn.quote_table_name(Entrega.table_name)}
+ WHERE reference_id IS NOT NULL
+ )
+ SELECT #{selects_gade},
+ r.status AS rastreio_status,
+ r.checkout AS rastreio_checkout,
+ r.observation AS rastreio_observation
+ FROM #{gade} g
+ LEFT JOIN ultimo r ON r.rn = 1 AND r.reference_id::text = g.nota_fiscal
+ ORDER BY g.ctid
+ SQL
+ conn.select_all(sql).to_a
+ end
+ end
+
+ # ── Aba SimpliRoute ──────────────────────────────────────────
+ # Todas as visitas do rastreio para as NFs da operação (inclui repetidas,
+ # como no dump original do SimpliRoute).
+ def linhas_rastreio
+ gade = conn.quote_table_name(@tabela)
+ sql = <<~SQL
+ SELECT r.*
+ FROM #{conn.quote_table_name(Entrega.table_name)} r
+ WHERE r.reference_id::text IN (SELECT nota_fiscal FROM #{gade} WHERE nota_fiscal IS NOT NULL)
+ ORDER BY r.reference_id, r.checkout NULLS LAST
+ SQL
+ conn.select_all(sql).to_a
+ end
+
+ # { cabeçalho do modelo => coluna real do rastreio (ou nil) }.
+ def colunas_rastreio
+ existentes = conn.columns(Entrega.table_name).map(&:name)
+ RASTREIO_COLUNAS.transform_values { |cands| cands.find { |c| existentes.include?(c) } }
+ end
+
+ # ── Aba RESUMO ───────────────────────────────────────────────
+ # Agrega as linhas da ENTREGAS por grupo (EMAD/UBS) e coordenadoria:
+ # previsão/entregue/não entregue/pendente + motivos de "Não Entregue".
+ def resumo
+ grupos = GRUPOS.index_with { Hash.new { |h, k| h[k] = novo_bloco } }
+
+ linhas.each do |l|
+ bloco = grupos[grupo_de(l)][coordenadoria_de(l)]
+ bloco[:previsao] += 1
+ status = l['rastreio_status'].to_s
+ if status == 'completed'
+ bloco[:entregue] += 1
+ elsif Entrega::STATUS_FALHA.include?(status)
+ bloco[:nao_entregue] += 1
+ bloco[:motivos][motivo_de(l['rastreio_observation'])] += 1
+ else
+ bloco[:pendente] += 1
+ end
+ end
+
+ total_prev = linhas.size
+ total_real = grupos.values.sum { |coords| coords.values.sum { |b| b[:entregue] } }
+ {
+ mes: "#{MESES_PT[Date.current.month - 1]}/#{Date.current.year}",
+ total_previsto: total_prev,
+ total_realizado: total_real,
+ performance: (total_prev.zero? ? nil : total_real.to_f / total_prev),
+ grupos: grupos
+ }
+ end
+
+ # Bloco zerado do quadro RESUMO (também usado pelo gerador xlsx).
+ def novo_bloco
+ { previsao: 0, entregue: 0, nao_entregue: 0, pendente: 0, motivos: Hash.new(0) }
+ end
+
+ private
+
+ def conn
+ ActiveRecord::Base.connection
+ end
+
+ # SELECT das colunas whitelisted; ausentes viram NULL com o mesmo alias.
+ def selects_gade
+ existentes = conn.columns(@tabela).map(&:name)
+ COLUNAS_GADE.keys.map do |c|
+ existentes.include?(c) ? "g.#{c} AS #{c}" : "CAST(NULL AS text) AS #{c}"
+ end.join(', ')
+ end
+
+ # Grupo do quadro (EMAD/UBS): coluna "operacao" da linha; sem ela, deduz do
+ # nome/label da operação. Default UBS (grupo mais comum no modelo).
+ def grupo_de(linha)
+ texto = I18n.transliterate("#{linha['operacao']} #{@tabela} #{label}").upcase
+ texto.include?('EMAD') ? 'EMAD' : 'UBS'
+ end
+
+ # Casa a coordenadoria da linha com as fixas do modelo (sem acento/caixa);
+ # valores fora da lista entram como estão (viram linha extra no quadro).
+ def coordenadoria_de(linha)
+ bruto = linha['coordenadoria'].to_s.strip
+ return COORDENADORIAS.first if bruto.empty? # sem coluna: agrega na 1ª (Sudeste)
+
+ chave = I18n.transliterate(bruto).upcase
+ COORDENADORIAS.find { |c| I18n.transliterate(c).upcase == chave } || bruto
+ end
+
+ # Classifica a observação do insucesso nos motivos fixos do RESUMO.
+ def motivo_de(observacao)
+ o = I18n.transliterate(observacao.to_s).downcase
+ return 'Óbito' if o.include?('obito')
+ return 'Responsável Ausente' if o.include?('ausente')
+ return 'Endereço não localizado' if o.include?('localizado') || o.include?('endereco')
+ return 'Recusa' if o.include?('recus')
+ return 'Paciente não reside no local' if o.include?('reside')
+ 'Outros'
+ end
+ end
+end
diff --git a/app/services/analytics/planilha_entregas_xlsx.rb b/app/services/analytics/planilha_entregas_xlsx.rb
new file mode 100644
index 0000000..dba9da2
--- /dev/null
+++ b/app/services/analytics/planilha_entregas_xlsx.rb
@@ -0,0 +1,211 @@
+# app/services/analytics/planilha_entregas_xlsx.rb
+#
+# Gera o binário .xlsx da planilha Entregas do cliente com as 3 abas do modelo
+# "Entregas SUDESTE MM.AAAA_FINAL.xlsx": RESUMO, ENTREGAS e SimpliRoute.
+# Os dados vêm de Analytics::PlanilhaEntregas.
+#
+# As cores/estilos foram extraídos do próprio arquivo modelo (styles.xml):
+# ENTREGAS A..T azul 1155CC e U..Z vermelho C00000 (texto branco, negrito);
+# RESUMO com quadros em 002060/0070C0/C00000/A5A5A5; SimpliRoute sem estilo.
+# (Os gráficos embutidos do modelo não são replicados.)
+require 'caxlsx'
+
+module Analytics
+ class PlanilhaEntregasXlsx
+ def initialize(planilha)
+ @planilha = planilha
+ end
+
+ # String binária do .xlsx.
+ def gerar
+ pkg = Axlsx::Package.new
+ wb = pkg.workbook
+ definir_estilos(wb)
+
+ aba_resumo(wb)
+ aba_entregas(wb)
+ aba_simpliroute(wb)
+
+ pkg.to_stream.read
+ end
+
+ private
+
+ def definir_estilos(wb)
+ st = wb.styles
+ borda = { style: :thin, color: 'FF808080', edges: %i[left right top bottom] }
+ centro = { horizontal: :center, vertical: :center }
+
+ # Cabeçalhos/civis do RESUMO
+ @titulo_mes = st.add_style(b: true)
+ @titulo_sec = st.add_style(b: true, sz: 16, u: true)
+ @rotulo_kpi = st.add_style(b: true, sz: 10)
+ @valor_kpi = st.add_style(b: true, sz: 10, border: { style: :thin, color: 'FF000000', edges: [:bottom] })
+ @valor_pct = st.add_style(b: true, sz: 10, format_code: '0.0%',
+ border: { style: :thin, color: 'FF000000', edges: [:bottom] })
+
+ @h_navy = st.add_style(bg_color: '002060', fg_color: 'FFFFFF', b: true, sz: 11, border: borda, alignment: centro)
+ @h_azul = st.add_style(bg_color: '0070C0', fg_color: 'FFFFFF', b: true, sz: 11, border: borda, alignment: centro)
+ @h_verm = st.add_style(bg_color: 'C00000', fg_color: 'FFFFFF', b: true, sz: 11, border: borda, alignment: centro)
+ @h_cinza = st.add_style(bg_color: 'A5A5A5', fg_color: 'FFFFFF', sz: 10, border: borda,
+ alignment: centro.merge(wrap_text: true))
+ @h_motivo = st.add_style(b: true, sz: 10, border: borda, alignment: centro.merge(wrap_text: true))
+
+ @celula = st.add_style(sz: 11, border: borda)
+ @cel_num = st.add_style(sz: 11, border: borda, alignment: centro)
+
+ # Status das Entregas / Conferência Documentos (verde "bom", laranja "em
+ # andamento", azul "a iniciar" — como no modelo)
+ @st_verde = st.add_style(bg_color: 'C6EFCE', fg_color: '006100', b: true, sz: 10, border: borda, alignment: centro)
+ @st_laranja = st.add_style(bg_color: 'FCE4D6', fg_color: 'ED7D31', sz: 10, border: borda, alignment: centro)
+ @st_iniciar = st.add_style(fg_color: '0070C0', sz: 10, border: borda, alignment: centro)
+
+ @perf_rotulo = st.add_style(i: true)
+ @perf_pct = st.add_style(i: true, format_code: '0.0%')
+
+ # Cabeçalho da ENTREGAS
+ @cab_azul = st.add_style(bg_color: '1155CC', fg_color: 'FFFFFF', b: true)
+ @cab_verm = st.add_style(bg_color: 'C00000', fg_color: 'FFFFFF', b: true)
+ end
+
+ # ── RESUMO ───────────────────────────────────────────────────
+ # Mesma disposição do modelo: mês, totais/performance e um quadro por grupo
+ # (EMAD e UBS) com as coordenadorias fixas + motivos de "Não Entregue".
+ # ("Cordenadoria" sem o primeiro "o" é verbatim do modelo.)
+ def aba_resumo(wb)
+ resumo = @planilha.resumo
+ wb.add_worksheet(name: 'RESUMO') do |s|
+ s.add_row []
+ s.add_row ['', resumo[:mes]], style: [nil, @titulo_mes]
+ s.add_row []
+ s.add_row ['', 'Total Previsto:', resumo[:total_previsto]], style: [nil, @rotulo_kpi, @valor_kpi]
+ s.add_row ['', 'Total Realizado:', resumo[:total_realizado]], style: [nil, @rotulo_kpi, @valor_kpi]
+ s.add_row ['', 'Performance:', resumo[:performance] || ''],
+ style: [nil, @rotulo_kpi, (resumo[:performance] ? @valor_pct : @valor_kpi)]
+
+ PlanilhaEntregas::GRUPOS.each { |grupo| quadro_grupo(s, grupo, resumo[:grupos][grupo]) }
+
+ s.column_widths 3, 18, 10, 10, 12, 14, 14, 3, 10, 12, 14, 10, 14, 10
+ end
+ end
+
+ def quadro_grupo(s, grupo, coords)
+ s.add_row []
+ s.add_row ['', grupo], style: [nil, @titulo_sec]
+
+ # "Informações" (F:G) e 'Motivos de "Não Entregue"' (I:N) mescladas
+ s.add_row ['', '', '', '', '', 'Informações', '', '', 'Motivos de "Não Entregue"', '', '', '', '', ''],
+ style: [nil, nil, nil, nil, nil, @h_motivo, @h_motivo, nil, *Array.new(6, @h_motivo)]
+ r = s.rows.size
+ s.merge_cells("F#{r}:G#{r}")
+ s.merge_cells("I#{r}:N#{r}")
+
+ s.add_row ['', 'Cordenadoria', 'Previsão', 'Entregue', 'Não Entregue',
+ 'Status das Entregas', 'Conferência Documentos', '',
+ *PlanilhaEntregas::MOTIVOS],
+ style: [nil, @h_navy, @h_azul, @h_navy, @h_verm, @h_cinza, @h_cinza, nil,
+ *Array.new(PlanilhaEntregas::MOTIVOS.size, @h_motivo)]
+
+ # Coordenadorias fixas do modelo + eventuais extras vindas dos dados.
+ nomes = PlanilhaEntregas::COORDENADORIAS + (coords.keys - PlanilhaEntregas::COORDENADORIAS)
+ total = @planilha.novo_bloco
+
+ nomes.each do |nome|
+ b = coords[nome] || @planilha.novo_bloco
+ total[:previsao] += b[:previsao]
+ total[:entregue] += b[:entregue]
+ total[:nao_entregue] += b[:nao_entregue]
+ total[:pendente] += b[:pendente]
+ b[:motivos].each { |m, q| total[:motivos][m] += q }
+
+ s.add_row ['', nome, b[:previsao], b[:entregue], b[:nao_entregue],
+ (b[:pendente].zero? ? 'Concluído' : 'Em andamento'), 'A iniciar', '',
+ *PlanilhaEntregas::MOTIVOS.map { |m| b[:motivos][m] }],
+ style: [nil, @celula, @cel_num, @cel_num, @cel_num,
+ (b[:pendente].zero? ? @st_verde : @st_laranja), @st_iniciar, nil,
+ *Array.new(PlanilhaEntregas::MOTIVOS.size, @cel_num)]
+ end
+
+ s.add_row ['', 'Total', total[:previsao], total[:entregue], total[:nao_entregue], '', '', '',
+ *PlanilhaEntregas::MOTIVOS.map { |m| total[:motivos][m] }],
+ style: [nil, @h_navy, @h_azul, @h_navy, @h_verm, nil, nil, nil,
+ *Array.new(PlanilhaEntregas::MOTIVOS.size, @h_motivo)]
+
+ if total[:previsao].positive?
+ s.add_row ['', 'Performance %', 1,
+ total[:entregue].to_f / total[:previsao],
+ total[:nao_entregue].to_f / total[:previsao]],
+ style: [nil, @perf_rotulo, @perf_pct, @perf_pct, @perf_pct]
+ else
+ s.add_row ['', 'Performance %', '', '', ''], style: [nil, @perf_rotulo]
+ end
+ end
+
+ # ── ENTREGAS ─────────────────────────────────────────────────
+ # A..V direto da tabela gade (texto); W..Z do rastreio:
+ # W STATUS (completed/failed...), X ENTREGA (Sim/Não), Y DATA OCORRÊNCIA
+ # (data real do checkout), Z OCORRÊNCIA (motivo do insucesso).
+ # Cabeçalho como no modelo: A..T azul, U..Z (resultado) vermelho.
+ def aba_entregas(wb)
+ linhas = @planilha.linhas
+ azuis = PlanilhaEntregas::COLUNAS_GADE.size - 2 # A..T
+ wb.add_worksheet(name: 'ENTREGAS') do |sheet|
+ sheet.add_row(PlanilhaEntregas::CABECALHO,
+ style: Array.new(azuis, @cab_azul) +
+ Array.new(PlanilhaEntregas::CABECALHO.size - azuis, @cab_verm))
+ linhas.each do |l|
+ valores, tipos = linha_entrega(l)
+ sheet.add_row(valores, types: tipos)
+ end
+ sheet.auto_filter = "A1:Z#{linhas.size + 1}"
+ end
+ end
+
+ def linha_entrega(l)
+ status_r = l['rastreio_status'].to_s
+ entregue = if status_r == 'completed'
+ 'Sim'
+ elsif Entrega::STATUS_FALHA.include?(status_r)
+ 'Não'
+ else
+ ''
+ end
+ data_ocorrencia = data_de(l['rastreio_checkout'])
+
+ valores = PlanilhaEntregas::COLUNAS_GADE.keys.map { |c| l[c].to_s } +
+ [status_r, entregue, data_ocorrencia || '', l['rastreio_observation'].to_s]
+ tipos = Array.new(PlanilhaEntregas::COLUNAS_GADE.size + 2, :string) +
+ [(data_ocorrencia ? :date : :string), :string]
+ [valores, tipos]
+ end
+
+ # ── SimpliRoute ──────────────────────────────────────────────
+ # Dump cru do rastreio (todas as visitas das NFs da operação), com o
+ # cabeçalho exato do modelo — sem estilo, como no original; cabeçalho sem
+ # coluna correspondente sai vazio.
+ def aba_simpliroute(wb)
+ colunas = @planilha.colunas_rastreio
+ linhas = @planilha.linhas_rastreio
+ wb.add_worksheet(name: 'SimpliRoute') do |sheet|
+ sheet.add_row(colunas.keys)
+ linhas.each do |r|
+ sheet.add_row(colunas.values.map { |col| col ? texto(r[col]) : '' },
+ types: Array.new(colunas.size, :string))
+ end
+ sheet.auto_filter = "A1:#{Axlsx.col_ref(colunas.size - 1)}#{linhas.size + 1}"
+ end
+ end
+
+ def texto(valor)
+ valor.respond_to?(:strftime) ? valor.strftime('%d/%m/%Y %H:%M') : valor.to_s
+ end
+
+ def data_de(valor)
+ return nil if valor.nil?
+ return valor.to_date if valor.respond_to?(:to_date)
+ Date.parse(valor.to_s)
+ rescue ArgumentError, TypeError
+ nil
+ end
+ end
+end
diff --git a/app/services/pdf/relatorio_motorista_pdf.rb b/app/services/pdf/relatorio_motorista_pdf.rb
index afd5b71..bb1f08c 100644
--- a/app/services/pdf/relatorio_motorista_pdf.rb
+++ b/app/services/pdf/relatorio_motorista_pdf.rb
@@ -37,7 +37,7 @@ module Pdf
original&.vehicle.presence || (e.manual? ? 'manual' : '—'),
original ? "NF #{original.numero_nf}" : (e.nf_manual.present? ? "NF #{e.nf_manual}" : '—'),
original ? original.address.to_s : (e.obs_manual.presence || '—'),
- e.termo? && e.quantidade > 1 ? "#{e.tipo_cor[:label]} ×#{e.quantidade}" : e.tipo_cor[:label],
+ (e.termo? || e.termo_especial?) && e.quantidade > 1 ? "#{e.tipo_cor[:label]} ×#{e.quantidade}" : e.tipo_cor[:label],
(e.desconto? ? '-' : '') + moeda(e.valor_aplicado)
]
end
diff --git a/app/services/simpli_route/planilha_carga.rb b/app/services/simpli_route/planilha_carga.rb
index cfa3e16..579e60e 100644
--- a/app/services/simpli_route/planilha_carga.rb
+++ b/app/services/simpli_route/planilha_carga.rb
@@ -2,8 +2,8 @@
#
# Monta as linhas da planilha de carga do SimpliRoute a partir de UMA operação
# vigente (tabela gade_entregas_*). Preenche Latitude/Longitude reaproveitando a
-# geocodificação do MÊS ANTERIOR da mesma operação: casa nome + endereço +
-# supervisão e puxa a lat/long do rastreio (db_reem_simplerout_2026) pelo NF antigo.
+# geocodificação do MÊS ANTERIOR da mesma operação: casa nome + endereço
+# (normalizado) e puxa a lat/long do rastreio (db_reem_simplerout_2026) pelo NF antigo.
#
# Tabelas de operação e rastreio são SOMENTE LEITURA (apenas SELECT).
module SimpliRoute
@@ -14,8 +14,10 @@ module SimpliRoute
nota_fiscal nome_completo endereco_sem_complemento complemento supervisao telefones
].freeze
- # Colunas que formam a chave de match com o histórico.
- COLUNAS_CHAVE = %w[nome_completo endereco_sem_complemento supervisao].freeze
+ # Colunas que formam a chave de match com o histórico. Supervisão fica de
+ # fora: o cliente renomeia/mescla supervisões entre meses (ex.: "STS SANTA
+ # CECILIA" + "STS SE" → "STS SE_SANTA CECILIA") e a geo é do endereço.
+ COLUNAS_CHAVE = %w[nome_completo endereco_sem_complemento].freeze
# Mês (número) -> grafias aceitas nos nomes de tabela (abreviação e extenso).
MESES_GRAFIAS = {
@@ -122,18 +124,31 @@ module SimpliRoute
end
end
- # Chave de match: nome | endereço | supervisão, normalizados.
+ # Chave de match: nome | endereço, normalizados.
def chave(row)
- COLUNAS_CHAVE.map { |c| normaliza(row[c]) }.join('|')
+ [normaliza(row['nome_completo']),
+ normaliza_endereco(row['endereco_sem_complemento'])].join('|')
end
def normaliza(valor)
valor.to_s.strip.gsub(/\s+/, ' ').upcase
end
- # Coordenada como Float (com PONTO). O rastreio pode trazer texto com vírgula
- # decimal ("-23,62"); coordenadas não usam separador de milhar, então trocar
- # a vírgula por ponto é seguro e garante a saída numérica (ponto) no .xlsx.
+ # Rua/número/bairro (3 primeiros segmentos) + dígitos do CEP: imune às
+ # variações de sufixo que o cliente alterna entre meses
+ # ("São Paulo, São Paulo, 01511-010, Brazil" vs "São Paulo, SP, CEP: 01511-010").
+ # O CEP entra só pelos dígitos finais (7-8) para tolerar hífen fora do
+ # lugar (ex.: "028140-30").
+ def normaliza_endereco(valor)
+ segs = valor.to_s.split(',').map(&:strip)
+ base = normaliza(segs.first(3).join(', '))
+ cep = segs.drop(3).join.gsub(/\D/, '')[/\d{7,8}\z/]
+ [base, cep].compact.join('|')
+ end
+
+ # Coordenada como Float. O rastreio pode trazer texto com vírgula decimal
+ # ("-23,62"); coordenadas não usam separador de milhar, então trocar a
+ # vírgula por ponto é seguro — o Float#to_s do xlsx sai sempre com PONTO.
def to_coord(valor)
Float(valor.to_s.strip.tr(',', '.'))
rescue ArgumentError, TypeError
diff --git a/app/services/simpli_route/planilha_carga_xlsx.rb b/app/services/simpli_route/planilha_carga_xlsx.rb
index 79ead25..0c74d5d 100644
--- a/app/services/simpli_route/planilha_carga_xlsx.rb
+++ b/app/services/simpli_route/planilha_carga_xlsx.rb
@@ -59,8 +59,10 @@ module SimpliRoute
private
- # Monta [valores, tipos] de uma linha (A..Z). Lat/long numéricos quando há
- # match; D/E como texto ("08:00"/"18:00"); NF/ref como texto (igual ao exemplo).
+ # Monta [valores, tipos] de uma linha (A..Z). Lat/long como TEXTO com ponto
+ # decimal — se fossem numéricas, Excel/LibreOffice em pt-BR exibiria vírgula
+ # (locale) e o cliente subiria "-23,57" pro SimpliRoute; D/E como texto
+ # ("08:00"/"18:00"); NF/ref como texto (igual ao exemplo).
def linha(l)
lat = l[:latitude]
lng = l[:longitude]
@@ -72,8 +74,8 @@ module SimpliRoute
l[:janela_fim], # E
l[:tempo], # F
l[:anotacoes].to_s, # G
- (lat.nil? ? '' : lat), # H
- (lng.nil? ? '' : lng), # I
+ lat.to_s, # H
+ lng.to_s, # I
l[:referencia].to_s, # J
'', '', # K, L
l[:contato].to_s, # M
@@ -82,8 +84,8 @@ module SimpliRoute
]
tipos = [
:string, :string, :integer, :string, :string, :integer, :string,
- (lat.nil? ? :string : :float), # H
- (lng.nil? ? :string : :float), # I
+ :string, # H
+ :string, # I
:string, :string, :string, :string, :string,
*Array.new(VAZIAS_FINAIS.size, :string)
]
diff --git a/app/views/admin/planilhas_simpli_route/show.html.erb b/app/views/admin/planilhas_simpli_route/show.html.erb
index cd8492c..70a8eb3 100644
--- a/app/views/admin/planilhas_simpli_route/show.html.erb
+++ b/app/views/admin/planilhas_simpli_route/show.html.erb
@@ -14,7 +14,7 @@
ℹ️
A Latitude/Longitude é reaproveitada do mês anterior da mesma
- operação (casando nome + endereço + supervisão). Pacientes novos ou sem
+ operação (casando nome + endereço). Pacientes novos ou sem
correspondência saem sem lat/long — o SimpliRoute geocodifica na importação.
Se a operação não tiver mês anterior, a planilha sai inteira sem lat/long.
diff --git a/app/views/consolidacao_entregas/revisar.html.erb b/app/views/consolidacao_entregas/revisar.html.erb
index fd3161a..c5dc270 100644
--- a/app/views/consolidacao_entregas/revisar.html.erb
+++ b/app/views/consolidacao_entregas/revisar.html.erb
@@ -12,14 +12,15 @@
<%# Resumo por tipo %>
-
<% end %>
@@ -473,6 +511,60 @@
})();
+<%# Paginação client-side das tabelas de pagamentos (10 por página) %>
+
+
<%# Filtro de operação — submete o form ao marcar/desmarcar %>
+<%# Fluxo de pagamentos: barras (pago no dia) + linha (acumulado no período) %>
+
+
<%# Rosca de pagamentos (pago x a pagar, por valor) %>
-
+<%# overflow-x-hidden: conteúdo largo demais não pode criar scroll horizontal na
+ página (aparecia como uma barra laranja "quebrando" o rodapé no celular);
+ tabelas largas já rolam nos próprios contêineres overflow-x-auto. %>
+
<%# ── Animação de abertura (uma vez, logo após o login) ── %>
<% if @mostrar_splash %>
diff --git a/app/views/operacoes_dashboard/_espelho.html.erb b/app/views/operacoes_dashboard/_espelho.html.erb
new file mode 100644
index 0000000..d092db6
--- /dev/null
+++ b/app/views/operacoes_dashboard/_espelho.html.erb
@@ -0,0 +1,139 @@
+<%# app/views/operacoes_dashboard/_espelho.html.erb %>
+<%# Tabela espelho da planilha da operação: uma linha por NF com resultado,
+ ocorrência, motorista etc. Busca textual (param q) e paginação (param pg)
+ são server-side; o restante da URL (modo/operação/período/cross-filters)
+ é preservado nos links e no form. %>
+
+<% pg_path = ->(n) { operacoes_planilha_path(request.query_parameters.merge('pg' => n)) } %>
+<% resultado_badge = ->(status) {
+ if status == 'completed'
+ ['Entregue', 'bg-green-500/10 text-green-400 border-green-500/30']
+ elsif Entrega::STATUS_FALHA.include?(status)
+ ['Falha', 'bg-red-500/10 text-red-400 border-red-500/30']
+ else
+ ['Pendente', 'bg-yellow-500/10 text-yellow-400 border-yellow-500/30']
+ end
+ } %>
+
+
+
+
+
📋 Planilha da operação
+
Espelho por NF — pesquise por nota, motorista, unidade, ocorrência, veículo...
+
+
+
+
+
+ <% if @espelho_linhas.blank? %>
+
+ <%= @busca.present? ? %(Nenhuma entrega encontrada para "#{@busca}".) : 'Nenhuma entrega para exibir.' %>
+
diff --git a/app/views/operacoes_dashboard/index.html.erb b/app/views/operacoes_dashboard/index.html.erb
index 46f2f56..2a0ff56 100644
--- a/app/views/operacoes_dashboard/index.html.erb
+++ b/app/views/operacoes_dashboard/index.html.erb
@@ -88,16 +88,28 @@
<% end %>
- <%# ── Abas de modo ──────────────────────────────────────── %>
+ <%# ── Abas de modo + atalho para a planilha ─────────────── %>
<% modos = { 'operacao' => '🏥 Operação', 'global' => '🌐 Global', 'comparar' => '⚖️ Comparar' } %>
-
<% if @tabelas_validas.blank? %>
diff --git a/app/views/operacoes_dashboard/planilha.html.erb b/app/views/operacoes_dashboard/planilha.html.erb
new file mode 100644
index 0000000..d1bddcb
--- /dev/null
+++ b/app/views/operacoes_dashboard/planilha.html.erb
@@ -0,0 +1,108 @@
+<%# app/views/operacoes_dashboard/planilha.html.erb %>
+<%# Página da Planilha da Operação: tabela espelho (uma linha por NF) com busca
+ e paginação. Chega-se aqui pelo botão no Dashboard de Operações, que passa
+ o contexto (operação/modo global/período/cross-filters) na URL. %>
+
+<% content_for :head do %>
+
+<% end %>
+
+<% ini_iso = @periodo_inicio.strftime('%Y-%m-%d') %>
+<% fim_iso = @periodo_fim.strftime('%Y-%m-%d') %>
+
+
+ Nenhuma operação encontrada no banco (tabelas gade_entregas_*).
+
+ <% end %>
+
+
+<%# Troca de operação recarrega a planilha %>
+
diff --git a/config/routes.rb b/config/routes.rb
index 9f867cc..1042fb1 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -18,6 +18,10 @@ Rails.application.routes.draw do
# Dashboard de análise de entregas por operação (UBS Norte, EMAD, ...)
get '/dashboard/operacoes', to: 'operacoes_dashboard#index', as: :operacoes_dashboard
+ # Planilha da operação (tabela espelho pesquisável, página própria)
+ get '/dashboard/operacoes/planilha', to: 'operacoes_dashboard#planilha', as: :operacoes_planilha
+ # Download .xlsx da planilha Entregas preenchida (modelo entregue ao cliente)
+ get '/dashboard/operacoes/planilha/baixar', to: 'operacoes_dashboard#baixar_planilha', as: :operacoes_planilha_baixar
# Painel motorista
get '/motorista', to: 'motorista/dashboard#index', as: :motorista_dashboard
diff --git a/db/migrate/20260715000001_add_preco_termo_especial_configuracao.rb b/db/migrate/20260715000001_add_preco_termo_especial_configuracao.rb
new file mode 100644
index 0000000..3112576
--- /dev/null
+++ b/db/migrate/20260715000001_add_preco_termo_especial_configuracao.rb
@@ -0,0 +1,18 @@
+# Cria a configuração de preço "Entrega de Termo Especial" em bancos já
+# existentes, para que apareça na tela de Configurações. Idempotente — não
+# sobrescreve se o admin já tiver definido um valor.
+class AddPrecoTermoEspecialConfiguracao < ActiveRecord::Migration[7.1]
+ def up
+ return if Configuracao.exists?(chave: 'preco_termo_especial')
+
+ Configuracao.create!(
+ chave: 'preco_termo_especial',
+ valor: '0.00',
+ descricao: 'Valor pago por entrega de termo especial (R$)'
+ )
+ end
+
+ def down
+ Configuracao.where(chave: 'preco_termo_especial').delete_all
+ end
+end
diff --git a/db/seeds.rb b/db/seeds.rb
index d0d1e9a..0466e38 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -30,6 +30,7 @@ configs = [
{ chave: 'preco_desconto', valor: '15.00', descricao: 'Valor descontado por problema (R$)' },
{ chave: 'preco_extraordinaria', valor: '25.00', descricao: 'Valor coringa para entregas fora do planejamento (R$)' },
{ chave: 'preco_termo', valor: '15.00', descricao: 'Valor pago por entrega de termo (R$)' },
+ { chave: 'preco_termo_especial', valor: '0.00', descricao: 'Valor pago por entrega de termo especial (R$)' },
{ chave: 'notificacao_whatsapp', valor: 'false', descricao: 'Notificações via WhatsApp (true/false)' },
{ chave: 'notificacao_email', valor: 'false', descricao: 'Notificações via e-mail (true/false)' },
{ chave: 'empresa_nome', valor: 'Reem Transporte', descricao: 'Nome da empresa exibido no sistema' },
diff --git a/spec/models/configuracao_spec.rb b/spec/models/configuracao_spec.rb
index 2938f86..d6f874e 100644
--- a/spec/models/configuracao_spec.rb
+++ b/spec/models/configuracao_spec.rb
@@ -30,6 +30,11 @@ RSpec.describe Configuracao, type: :model do
it 'retorna 0.0 quando a chave não existe' do
expect(Configuracao.preco_bonus).to eq(0.0)
end
+
+ it 'aceita a chave preco_termo_especial e converte em float' do
+ create(:configuracao, chave: 'preco_termo_especial', valor: '25.00')
+ expect(Configuracao.preco_termo_especial).to eq(25.0)
+ end
end
describe '#valor_formatado' do