Exportação CSV da tela admin de impostos no layout eNotas
TLDR:
/admin/taxesganha um botão que baixa um CSV de 50 colunas no layout de importação de vendas do eNotas, com as mesmas linhas e filtros da tela.
Contexto
A spec 20260811153509_admin_taxes_enotas_export já trocou a base da tela /admin/taxes de ProfessionalPaymentInvoice para Meeting e reescreveu TaxInvoiceSerializer com as colunas do eNotas. Mas ela deixou explicitamente fora de escopo “gerar o CSV de fato (export/download)” — a montagem do arquivo enviado ao eNotas seguiu sendo feita fora da plataforma.
Esta entrega fecha esse ciclo: a mesma listagem que já aparece na tela passa a ser baixável no formato exato que o eNotas importa.
O layout é um contrato externo do eNotas (Importador de Vendas): 50 colunas fixas, na ordem, separadas por ;. O export desta entrega cobre a perspectiva do terapeuta — dados do terapeuta e valor da taxa retida (10%), que é o que a tela já exibe.
Decisões tomadas no brainstorm
| Tema | Decisão |
|---|---|
ChaveUnica |
meeting.id puro, sem prefixo — mantém o CSV idêntico ao que a tela já mostra na coluna Sessão |
Venda_Data |
order.updated_at, como o serializer já faz — não meeting.start_at. O filtro de período também é por orders.updated_at, então CSV, tela e filtro batem entre si |
| Endereço | TaxInvoiceSerializer passa a expor os 7 campos separados que o CSV exige; cliente_endereco continua existindo como composição deles. A tela não muda visualmente |
| Paginação | FetchTaxSummary vira FetchTaxes, com paginação opcional — uma única porta de entrada para tela, API e export. A branch abandonada feat/export-taxes criou um FetchTaxExport separado e depois o unificou (commit d4739e30); vamos direto ao destino |
| Rota | Só GET /admin/taxes/export, dentro do authenticate :admin_user. Sem versão na API JSON — não há consumidor programático hoje |
| Truncamento | Feito no builder do CSV, não no serializer — os limites de tamanho são exigência do eNotas, e truncar no serializer cortaria também o que aparece na tela |
Revisões durante a entrega
| Tema | Decisão |
|---|---|
| Paginação nas telas | Tornar a paginação opcional no use case quebrou as duas telas que já existiam: Admin::TaxesController#index e o endpoint JSON não passavam page quando o usuário não navegava, e a view chama @taxes.current_page/total_pages (Kaminari), que não existem numa relation sem paginar. Regressão pega por 12 specs. Ambos os controllers passaram a mandar page: params[:page].presence || 1 — só o export chama o use case sem paginação |
| Colapso de espaços | sanitize no builder usa squish, não só a troca de ; por espaço: "Ana; Silva" virava "Ana Silva" (espaço duplo). Isso também resolve quebra de linha dentro de campo |
Objetivos
- Botão “Exportar CSV” em
/admin/taxesque baixa o arquivo respeitando os filtros aplicados na tela (start_period,end_period,professional_name) - Gerar o arquivo no layout eNotas: 50 colunas na ordem exata, separador
;, UTF-8, header obrigatório, todas as 50 posições presentes em cada linha mesmo quando vazias - Exportar todas as linhas do filtro, sem paginação — a tela pagina, o arquivo não
- Unificar
FetchTaxSummarye o export em um únicoFetchTaxescom paginação opcional - Versionar o layout eNotas como documentação de referência em
.project/docs/reference/
Fora de escopo
- Endpoint
GET /api/v1/admin/taxes/exportna API JSON — sem consumidor conhecido (YAGNI) - Export na perspectiva do paciente (valor cheio da sessão) — esta entrega só cobre a do terapeuta
- Integração via API com o eNotas (emissão automática de notas) — o arquivo continua sendo enviado manualmente pelo Importador de Vendas
- Mudança visual na tela
/admin/taxes— a tabela segue com as mesmas 8 colunas e o endereço concatenado - Geração assíncrona / via job — o volume atual (sessões de um período) é gerado na request
Mudanças
Serializer
app/serializers/tax_invoice_serializer.rb— os métodos privadoscidade_uf,cepepaise os campos crus do endereço viram atributos públicos:cliente_endereco_cidade(address.district),cliente_endereco_uf,cliente_endereco_cep,cliente_endereco_logradouro(address.street),cliente_endereco_numero(address.number),cliente_endereco_bairro(address.neighborhood) ecliente_endereco_pais.cliente_enderecocontinua existindo, agora montado a partir desses atributos ("logradouro, número, bairro, cidade - UF, CEP, país", pulando partes ausentes), para a tela e a API JSON seguirem funcionando sem mudança. A normalização já existente (remoção de;, UF viaSTATE_ABBREVIATIONScom remoção de acentos, CEP em 8 dígitos com"00000000"tratado como ausente, país"Brasil"quandocountry_codeéBRou ausente) é preservada, só muda de lugar
Use case
app/use_cases/professional_payment_invoices/fetch_tax_summary.rb→fetch_taxes.rb— renomeado paraProfessionalPaymentInvoices::FetchTaxes. Só chama#paginatequandocontext.pageoucontext.limitestiverem presentes; caso contrário devolve a coleção completa. Expõecontext.taxescomo antesapp/use_cases/professional_payment_invoices/build_enotas_csv.rb(novo,< UseCaseBase) — recebetaxes:(as sessões) e expõecontext.csv(string). GuardaCOLUMNS, a lista congelada dos 50 nomes de coluna na ordem do layout, eMAX_LENGTHS, o mapa de limites por coluna. Monta o header e uma linha por sessão viaTaxInvoiceSerializer, escrevendonilnas colunas não preenchidas. UsaCSV.generate(col_sep: ";"). Cada valor passa por uma sanitização final: remove;, colapsa quebras de linha e trunca no limite da coluna quando houverapp/use_cases/professional_payment_invoices/export_taxes_csv_flow.rb(novo,< UseCaseBase::Flow) — encadeiaFetchTaxeseBuildEnotasCsv, recebendo os filtros e expondocontext.csv
Mapeamento das 50 colunas
Preenchidas (14 de 50):
| # | Coluna | Origem | Máx. |
|---|---|---|---|
| 1 | ChaveUnica |
meeting.id |
1000 |
| 2 | Cliente_NomeRazaoSocial |
nome do terapeuta | 115 |
| 4 | Cliente_Documento |
CPF do terapeuta, só dígitos | 14 |
| 5 | Cliente_Email |
e-mail do terapeuta | 80 |
| 6 | Cliente_EnderecoCidade |
address.district |
— |
| 7 | Cliente_EnderecoUF |
UF normalizada (2 letras) | 2 |
| 8 | Cliente_EnderecoCEP |
CEP em 8 dígitos | 8 |
| 9 | Cliente_Endereco |
address.street |
125 |
| 10 | Cliente_EnderecoNumero |
address.number |
10 |
| 12 | Cliente_EnderecoBairro |
address.neighborhood |
30 |
| 13 | Cliente_EnderecoPais |
"Brasil" ou address.country |
— |
| 18 | Produto_Nome |
constante "ATENDIMENTO TERAPEUTICO" |
255 |
| 21 | Venda_ValorTotal |
taxa retida (CalculateFee, 10%), formato 0.00 |
— |
| 22 | Venda_Data |
order.updated_at em DD/MM/AAAA |
— |
As 36 colunas restantes (Cliente_NomeFantasia, Cliente_EnderecoComplemento, Cliente_Telefone, Cliente_TipoPessoa, Cliente_InscricaoMunicipal, Cliente_InscricaoEstadual, Produto_IDExterno, Produto_ValorTotal, Venda_MeioPagamento, Venda_DataVencimento e toda a família NFe_*) saem vazias, mas presentes — a linha precisa manter os 50 delimitadores nas posições corretas. NFe_CNAE e NFe_CodigoServicoMunicipio ficam vazios porque já estão configurados no cadastro da empresa no eNotas (Empresa > Dados municipais).
Controller, rota e view
config/routes.rb—get "/admin/taxes/export", to: "admin/taxes#export"dentro do blocoauthenticate :admin_user, antes/junto da rota de index já existenteapp/controllers/admin/taxes_controller.rb—#indexpassa a chamarFetchTaxes; nova action#exportchamaExportTaxesCsvFlowcomstart_period,end_periodeprofessional_name(sempage/limit) e respondesend_data csv, filename:, type: "text/csv; charset=utf-8". Obefore_action :authenticate_admin_user!/:require_developer!já cobre a nova action. Nome do arquivo:impostos-enotas-<start>-<end>.csvcom as barras do período trocadas por-(ex.:impostos-enotas-01-2026-12-2026.csv); sem filtro de período,impostos-enotas.csvapp/controllers/api/v1/admin/taxes_controller.rb— passa a chamarFetchTaxes(segue mandandopage/limit, então continua paginado)app/views/admin/taxes/index.html.erb— botão “Exportar CSV” notaxes-header, comolink_to admin_taxes_export_path(request.query_parameters), preservando os filtros ativos. Renderizado sempre, inclusive no estado vazioapp/assets/stylesheets/admin/taxes.css— estilo do botão de export, alinhado à direita no header, seguindo o padrão visual do botão “Filtrar”
Testes (TDD)
spec/use_cases/professional_payment_invoices/fetch_taxes_spec.rb— renomeado defetch_tax_summary_spec.rb: pagina quandopage/limitvêm preenchidos, devolve a coleção inteira quando ambos estão ausentes, e mantém os casos de filtro por período/nomespec/use_cases/professional_payment_invoices/build_enotas_csv_spec.rb(novo) — header com os 50 nomes na ordem exata; cada linha com 50 campos; colunas não preenchidas presentes e vazias; separador;; valor com ponto e 2 casas; data emDD/MM/AAAA;;no nome do terapeuta substituído; nome acima de 115 caracteres truncadospec/features/admin/taxes_index_spec.rb— botão “Exportar CSV” presente, e o link carrega os filtros ativos da telaspec/requests/admin/taxes_export_spec.rb(novo) —GET /admin/taxes/exportredireciona sem sessão, redireciona para o Avo quando o admin não édeveloper, e devolvetext/csvcom as linhas do período para um admindeveloper. Sem mocks, conforme a regra de request specsspec/requests/api/v1/admin/taxes_spec.rb— ajustado para o rename deFetchTaxSummaryparaFetchTaxes(comportamento inalterado)
Como verificar
make test test=spec/use_cases/professional_payment_invoices/fetch_taxes_spec.rbmake test test=spec/use_cases/professional_payment_invoices/build_enotas_csv_spec.rbmake test test=spec/requests/admin/taxes_export_spec.rbmake test test=spec/requests/api/v1/admin/taxes_spec.rbmake test test=spec/features/admin/taxes_index_spec.rbmake lint- Manual: acessar
/admin/taxes, filtrar um período, clicar em “Exportar CSV” e conferir que o arquivo baixado tem o mesmo conjunto de linhas da listagem (somando todas as páginas, não só a primeira) - Manual: abrir o CSV baixado e conferir contra o checklist do layout eNotas — header com 50 colunas, 49
;por linha, datasDD/MM/AAAA, valores com ponto, nenhum;dentro de campo - Manual: subir o arquivo no Importador de Vendas do eNotas em ambiente de teste e confirmar que as linhas são reconhecidas sem erro de layout
Documentação
.project/docs/reference/payments/enotas_csv_layout.md(novo) — layout do CSV de importação do eNotas e o mapeamento coluna a coluna com o nosso domínio, conforme detalhado acima.project/docs/README.md— entrada nova para o doc de referência- Nenhuma mudança em
rules/— não há regra de negócio nova: a taxa de 10% continua emProfessionalPaymentInvoices::CalculateFeee os critérios de elegibilidade da sessão já foram definidos na spec anterior. O fluxo de repasse segue descrito em invoice_payment_flow