Table of Contents
BI Transporte — Processador XLS¶
O BI Transporte substitui um processo manual de planilhas da Vantroba por um serviço que recebe os arquivos Excel de transporte, valida e arquiva automaticamente o faturamento e o movimento da frota, e mantém um histórico rastreável de cada envio. Reenviar a mesma planilha não duplica nada, e os dados ficam disponíveis para consulta no aplicativo e nas telas web.
Na prática: o que antes dependia de alguém rodar um script à mão vira um envio autenticado que se processa sozinho, notifica o resultado e guarda a trilha do que entrou — pronto para auditoria e para os relatórios de quem opera o transporte.
→ Documentação Completa — o sistema inteiro, de cima a baixo.
O que já foi entregue:
- Recebimento e processamento automático das planilhas de transporte, com histórico rastreável de cada envio.
- Reprocessar um período sem gerar tráfego nem churn desnecessário para os aplicativos móveis.
- Arquivamento dos arquivos recebidos em storage dedicado, fora do banco de dados.
- Identificação de veículo e frota nos lançamentos, preservando correções manuais de cadastro.
- Reset seguro e auditável da replicação quando o estado replicado precisa ser zerado.
- Configurações compartilhadas entre servidor e aplicativo, ajustáveis sem novo deploy.
- Acesso às telas internas restrito a contas @xadm.com.br.
- Stack uma major atrás modernizada e código alinhado às práticas do framework, sem mudar comportamento observável.
Atalhos¶
- Etapas do Projeto — o desenho dos subsistemas e fluxos.
- Decisões — o porquê de cada escolha técnica (ADRs).
- Operação — runbooks: deploy, resetar replicação, login Google.
- Dev / API — contrato da API REST e referência técnica.
- Manual — passo a passo para o usuário/suporte.
- Público — manual e referência da API expostos (
docs/public/). - Glossário do projeto — vocabulário do domínio.
Projeto
Documentação Completa — BI Transporte¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-10
O sistema como ele é hoje, em ordem de raciocínio: o problema, o que entra, como os dados são guardados, como o processamento funciona e, no fim, os contratos que outros sistemas consomem. Quem só integra pode pular direto para o cap. 7. O histórico ("como chegamos aqui") está no apêndice; o passo a passo de cada feature, nas Etapas do Projeto.
1. Contexto e problema¶
A Vantroba extrai do X-Adm, periodicamente, planilhas Excel com o faturamento e o movimento de frota do seu transporte. Antes, transformar esses arquivos exportados em dados consultáveis dependia de alguém rodar um script Python à mão — frágil, sem histórico, sem rastreabilidade de "qual arquivo gerou qual dado".
O BI Transporte substitui esse processo por um serviço contínuo (Java/Micronaut):
recebe as planilhas por uma API autenticada — um .zip com 1..N .xlsx do ciclo —,
valida, processa de forma assíncrona e persiste os fatos no PostgreSQL, de onde os
clientes móveis leem (via PowerSync) e a equipe consulta (telas web e API). Cada
planilha fica registrada com checksum, status e métricas — auditável.
Fora do escopo: editar os dados pela web (a origem é sempre a planilha) e enviar a planilha por tela (o envio é só por API). Relatórios e dashboards são de quem consome os dados, não deste serviço.
2. Dados de entrada¶
A entrada é um .zip com 1..N arquivos .xlsx exportados do X-Adm — o zip
é o lote (decisão 0032). Cada .xlsx tem
três abas de nome fixo — "10", "20" e "30" — e traz o próprio período de
referência, na aba 10 (decisão
0016); o cliente não informa
período no envio. Cada arquivo é validado por magic bytes ZIP antes de qualquer
leitura; abas com outro nome são ignoradas. A leitura é FastExcel (StAX,
streaming) — arquivos grandes não estouram a memória; escolhida no lugar do POI
para compilar em native-image (decisão 0026). O layout coluna-a-coluna é contrato com o
X-Adm (decisão 0003), fixado
no parser e em fixtures golden.
📎 Arquivo de referência:
RESULTADO_TRANSPORTE_0100_072025.xlsx— um arquivo real, com as 3 abas no formato abaixo, emdocs/anexos/privado/no Forgejo (docs/anexos/privado/RESULTADO_TRANSPORTE_0100_072025.xlsx). Arquivo confidencial — acesso restrito à equipe (só com login no Forgejo).
Aba 10 — período de referência¶
Só a primeira linha importa: célula E1 (data inicial) e F1 (data
final), formato dd/MM/yyyy. É o período que delimita o diff-aware (decisão
0016).
| Célula | Conteúdo | Formato |
|---|---|---|
E1 |
data inicial do período | dd/MM/yyyy |
F1 |
data final do período | dd/MM/yyyy |
Aba 20 — faturamento (uma linha por lançamento)¶
Colunas por letra do Excel (índice 0-based entre parênteses). Linha descartada se
dt_frete ou valor vier vazia.
| Coluna | Campo | Formato / regra |
|---|---|---|
| B (1) | classif_ctb |
texto (3) |
| C (2) | ident_ger |
texto (2) |
| D (3) | nro_frete |
texto (6) |
| E (4) | dt_frete |
data dd/MM/yyyy — obrigatória |
| F (5) | nro_docto |
texto (6) |
| G (6) | serie |
texto (3) |
| H (7) | dt_em |
data dd/MM/yyyy |
| I (8) | db_cr |
texto (1) |
| J (9) | tanque |
texto (1) |
| K (10) | placa |
texto (10) |
| L (11) | placa_comp |
texto (10) |
| M (12) | segmento |
texto (3) |
| N (13) | desc_seg |
texto (30) |
| O (14) | seq_proj |
texto (6) |
| P (15) | cod_proj |
texto (10) |
| Q (16) | filial |
texto (2) |
| R (17) | valor |
decimal — obrigatório |
| S (18) | fator_fat |
decimal |
| T (19) | distancia |
decimal |
| U (20) | espec_veic |
texto (10) |
| V (21) | mun_orig |
texto (30) |
| W (22) | mun_dest |
texto (30) |
| X (23) | cod_mot |
texto (6) |
| Y (24) | nome_mot |
texto (30) |
| Z (25) | cpf_mot |
texto (11) |
| AA (26) | gestor |
texto (6) |
| AB (27) | nome_gestor |
texto (30) |
| AC (28) | desc_classif |
texto (70) |
| AH (33) | tipo_frota |
texto (20) |
| AI (34) | marca_veiculo |
texto (80) |
| AJ (35) | modelo_veiculo |
texto (80) |
| AK (36) | ano AAAA/AAAA |
→ ano_construcao / ano_modelo |
Aba 30 — movimento de frota (uma linha por lançamento)¶
Linha descartada se data_lcto ou valor vier vazia.
| Coluna | Campo | Formato / regra |
|---|---|---|
| B (1) | classif_ctb |
texto (3) |
| C (2) | ident_ger |
texto (2) |
| D (3) | ident_custo |
texto (1) |
| E (4) | atrib_custo |
texto (1) |
| F (5) | ident_rat |
texto (1) |
| G (6) | db_cr |
texto (1) |
| H (7) | tanque |
texto (1) |
| I (8) | placa |
texto (10) |
| J (9) | placa_comp |
texto (10) |
| K (10) | segmento |
texto (3) |
| L (11) | desc_seg |
texto (30) |
| M (12) | seq_proj |
texto (6) |
| N (13) | cod_proj |
texto (10) |
| O (14) | filial |
texto (2) |
| P (15) | valor |
decimal — obrigatório |
| Q (16) | data_lcto |
data dd/MM/yyyy — obrigatória |
| R (17) | espec_veic |
texto (10) |
| S (18) | cod_conta |
texto (5) |
| T (19) | classif_conta |
texto (18) |
| U (20) | desc_conta |
texto (70) |
| V (21) | desc_classif |
texto (70) |
| AA (26) | processo |
texto (6) |
| AB (27) | tipo_frota |
texto (20) |
| AC (28) | marca_veiculo |
texto (80) |
| AD (29) | modelo_veiculo |
texto (80) |
| AE (30) | ano AAAA/AAAA |
→ ano_construcao / ano_modelo |
Entrada veicular malformada (marca/modelo/ano/placa) não derruba o lote — vira métrica de defesa (etapa 04).
3. Modelo de dados¶
O schema abaixo é a fonte da verdade — toda migration que evolui o banco atualiza este modelo no mesmo PR.
O schema vive em PostgreSQL, versionado por Flyway (V1..VN, congeladas por
checksum — nunca editar uma aplicada, sempre uma nova). Duas famílias de tabela,
por prefixo, que é contrato com o PowerSync:
bi_*— fatos e configuração sincronizados para os clientes móveis via PowerSync (exigemGRANT SELECTaopowersync_role, decisão 0013).xls_*— controle e auditoria locais ao backend, nunca sincronizados.
Tabelas e relacionamentos¶
erDiagram
xls_processamento ||--o{ bi_faturamento : "mescla por período"
xls_processamento ||--o{ bi_movimento : "mescla por período"
xls_conjunto_lote ||--o{ xls_processamento : "agrupa o lote .zip"
xls_conjunto_lote {
varchar conjunto_id PK
}
bi_faturamento {
bigint id PK
}
bi_movimento {
bigint id PK
}
xls_processamento {
bigint id PK
varchar checksum_sha256 UK
}
bi_configuracao {
text id PK
}
xls_resetar_powersync {
bigint id PK
}
Sem FK físicas. A relação
xls_processamento → bi_*é por período (periodo_inicial/periodo_final, lidos da aba 10), não por chave estrangeira: um upload mescla os fatos do seu período (UPSERT diff-aware), não os "possui".placaliga fato e dados veiculares de forma desnormalizada — não há tabela mestre de frota. As tabelasxls_*são controle/auditoria;xls_processamento.conjunto_idaponta o lote emxls_conjunto_lote(V16), também sem FK física.
Fluxo de dados¶
flowchart TD
xadm[X-Adm] -->|exporta .xlsx → .zip| up["POST /api/xls/processar (.zip)"]
up -->|binário| store[("Garage / arquivo_bytes")]
up -->|registra upload| ctrl[("xls_processamento")]
up --> sax["ExcelProcessor (FastExcel/StAX)"]
sax -->|aba 20| fat[/faturamento/]
sax -->|aba 30| mov[/movimento/]
fat --> upsert["UPSERT diff-aware + DELETE seletivo"]
mov --> upsert
upsert --> bif[("bi_faturamento")]
upsert --> bim[("bi_movimento")]
upsert -->|métricas| ctrl
bif -->|PowerSync| cli[Clientes móveis]
bim -->|PowerSync| cli
cfg[("bi_configuracao")] -->|PowerSync| cli
Detalhe de cada tabela¶
Dicionário por tabela (coluna, tipo, chave, nulo?, desde, nota). A chave natural
composta, os índices e os CHECK vêm em bullets abaixo de cada tabela — é onde a
chave de várias colunas fica legível (o ER acima só dá a topologia). Desde vazio =
coluna original da tabela.
bi_faturamento — fatos de faturamento (aba 20) · sincronizada¶
| Coluna | Tipo | Chave | Nulo? | Desde | Nota |
|---|---|---|---|---|---|
id |
bigint | PK | NOT NULL | ||
classif_ctb |
varchar | ||||
ident_ger |
varchar | ||||
nro_frete |
varchar | parte da chave natural | |||
dt_frete |
date | parte da chave natural | |||
nro_docto |
varchar | parte da chave natural | |||
serie |
varchar | ||||
dt_em |
date | parte da chave natural | |||
db_cr |
varchar | ||||
tanque |
varchar | ||||
placa |
varchar | parte da chave natural | |||
placa_comp |
varchar | ||||
segmento |
varchar | parte da chave natural | |||
desc_seg |
varchar | ||||
seq_proj |
varchar | parte da chave natural | |||
cod_proj |
varchar | parte da chave natural | |||
filial |
varchar | ||||
valor |
decimal | ||||
fator_fat |
decimal | ||||
distancia |
decimal | ||||
espec_veic |
varchar | parte da chave natural | |||
mun_orig |
varchar | ||||
mun_dest |
varchar | ||||
cod_mot |
varchar | parte da chave natural | |||
nome_mot |
varchar | ||||
cpf_mot |
varchar | ||||
gestor |
varchar | ||||
nome_gestor |
varchar | ||||
desc_classif |
varchar | ||||
tipo_frota |
varchar | V3 | veicular | ||
marca_veiculo |
varchar | V3 | veicular | ||
modelo_veiculo |
varchar | V3 | veicular | ||
ano_construcao |
smallint | V3 | veicular | ||
ano_modelo |
smallint | V3 | veicular |
- Chave natural (UK)
uq_bi_faturamento_natural— 10 colunas (NULLS NOT DISTINCT, V6):nro_frete, dt_frete, nro_docto, dt_em, placa, segmento, seq_proj, cod_proj, espec_veic, cod_mot. É a chave do UPSERT diff-aware. - Índices (V2):
dt_frete,dt_em,placa,filial,cod_proj. - Veiculares (
tipo_frota…ano_modelo) adicionadas em V3; demais em V1.
bi_movimento — movimento de frota (aba 30) · sincronizada¶
| Coluna | Tipo | Chave | Nulo? | Desde | Nota |
|---|---|---|---|---|---|
id |
bigint | PK | NOT NULL | ||
classif_ctb |
varchar | parte da chave natural | |||
ident_ger |
varchar | parte da chave natural | |||
ident_custo |
varchar | parte da chave natural | |||
atrib_custo |
varchar | parte da chave natural | |||
ident_rat |
varchar | parte da chave natural | |||
db_cr |
varchar | parte da chave natural | |||
tanque |
varchar | ||||
placa |
varchar | parte da chave natural | |||
placa_comp |
varchar | ||||
segmento |
varchar | parte da chave natural | |||
desc_seg |
varchar | ||||
seq_proj |
varchar | parte da chave natural | |||
cod_proj |
varchar | parte da chave natural | |||
filial |
varchar | parte da chave natural | |||
valor |
decimal | parte da chave natural | |||
data_lcto |
date | parte da chave natural | |||
espec_veic |
varchar | parte da chave natural | |||
cod_conta |
varchar | parte da chave natural | |||
classif_conta |
varchar | parte da chave natural | |||
desc_conta |
varchar | ||||
desc_classif |
varchar | ||||
processo |
varchar | ||||
tipo_frota |
varchar | V3 | veicular | ||
marca_veiculo |
varchar | V3 | veicular | ||
modelo_veiculo |
varchar | V3 | veicular | ||
ano_construcao |
smallint | V3 | veicular | ||
ano_modelo |
smallint | V3 | veicular | ||
seq_dentro_grupo |
smallint | NOT NULL | V6 | desambiguador da chave natural |
- Chave natural (UK)
uq_bi_movimento_natural— 17 colunas (NULLS NOT DISTINCT, V6): as 16 contábeis (classif_ctb, ident_ger, ident_custo, atrib_custo, ident_rat, db_cr, placa, segmento, seq_proj, cod_proj, filial, data_lcto, espec_veic, cod_conta, classif_conta, valor) +seq_dentro_grupo(desambiguador — colisões nas 16 são lançamentos legítimos, não duplicatas). - Índices (V2/V6):
data_lcto,placa,filial,cod_proj,cod_conta.
bi_configuracao — KV global sincronizada · sincronizada¶
| Coluna | Tipo | Chave | Nulo? | Desde | Nota |
|---|---|---|---|---|---|
id |
text | PK | NOT NULL | a chave É o id (semântica) | |
valor |
text | NOT NULL | |||
tipo |
varchar | NOT NULL | CHECK (ver abaixo) | ||
descricao |
text | ||||
sistema |
boolean | NOT NULL | default FALSE |
||
atualizado_em |
timestamptz | NOT NULL | default NOW() |
- CHECK
chk_bi_configuracao_tipo:tipo ∈ {STRING, INT, BOOL, TIMESTAMP, JSON}. - Seed (V12):
ultimo_nuke(TIMESTAMP,sistema=TRUE— sinal de reset, contrato com o cliente Flutter) eslow_query_timeout_ms(INT,10000, editável). idé a própria chave semântica (não surrogate; renomear = delete+insert).
xls_processamento — controle do pipeline · local¶
| Coluna | Tipo | Chave | Nulo? | Desde | Nota |
|---|---|---|---|---|---|
id |
bigint | PK | NOT NULL | ||
checksum_sha256 |
varchar | UK | NOT NULL | SHA-256 do binário (dedup) | |
nome_arquivo |
varchar | ||||
recebido_em |
timestamp | default NOW() |
|||
data_referencia |
date | legado — era o período do upload; nulo desde o lote .zip (decisão 0016) |
|||
hora_referencia |
time | legado — idem data_referencia |
|||
periodo_inicial |
date | período real, lido da aba 10 | |||
periodo_final |
date | período real, lido da aba 10 | |||
status |
varchar | default PENDENTE |
|||
iniciado_em |
timestamp | ||||
concluido_em |
timestamp | ||||
tempo_ms |
bigint | ||||
erro_mensagem |
text | ||||
log_arquivo |
varchar | caminho relativo do histórico que a UI exibe, data/execucoes/processamento-{id}.log (a V17 reescreveu os de logs/execucoes) |
|||
registros_faturamento |
integer | ||||
registros_movimento |
integer | ||||
arquivo_bytes |
bytea | object storage (etapa 03) | |||
objeto_bucket |
varchar | V8 | object storage | ||
objeto_chave |
varchar | V8 | object storage | ||
objeto_tamanho_bytes |
bigint | V8 | object storage | ||
faturamento_* |
integer | V7 | ×4: inseridas/atualizadas/inalteradas/removidas | ||
movimento_* |
integer | V7 | ×4: idem | ||
defesa_*_qtd |
integer | V11 | ×5: defesa veicular (etapa 04) | ||
xls_legado |
boolean | V11 | |||
conjunto_id |
varchar(64) | V16 | lote .zip de origem → xls_conjunto_lote (sem FK); índice ix_xls_processamento_conjunto |
- UNIQUE
uq_xls_processamento_checksum (checksum_sha256)— dedup de upload. - Object storage (V8):
objeto_*+arquivo_bytes(etapa 03). - Métricas de idempotência (V7):
faturamento_*/movimento_*× {inseridas, atualizadas, inalteradas, removidas} (8 colunas, etapa 02). - Defesa veicular (V11): 5 colunas
defesa_*_qtd+xls_legado(etapa 04). - Lote
.zip(V16):conjunto_idagrupa as entradas de um mesmo envio (decisão 0032).
xls_conjunto_lote — lote de envio .zip · local¶
| Coluna | Tipo | Chave | Nulo? | Desde | Nota |
|---|---|---|---|---|---|
conjunto_id |
varchar(64) | PK | NOT NULL | V16 | gerado no servidor: zip- + 60 primeiros hex do SHA-256 do .zip |
total |
integer | NOT NULL | V16 | nº de entradas do .zip |
|
duplicados |
integer | NOT NULL | V16 | default 0 |
|
nao_processados |
integer | NOT NULL | V16 | default 0; entradas que não viraram processamento nem duplicata-sucesso (tipo inválido, já em andamento) — contam para o lote fechar |
|
criado_em |
timestamp | NOT NULL | V16 | default NOW() |
|
notificado_em |
timestamp | V16 | quando saiu o resumo único no Telegram |
- Um lote fecha quando todas as entradas terminam; aí sai um resumo no Telegram
(decisão 0032). Espelha o modelo do
bi-comercial-xls.
xls_resetar_powersync — auditoria do reset · local¶
| Coluna | Tipo | Chave | Nulo? | Desde | Nota |
|---|---|---|---|---|---|
id |
bigint | PK | NOT NULL | ||
iniciado_em |
timestamp | NOT NULL | default NOW() |
||
concluido_em |
timestamp | ||||
status |
varchar | NOT NULL | default PENDENTE; CHECK (ver abaixo) |
||
motivo |
text | ||||
iniciado_por |
varchar | ||||
tempo_ms |
bigint | ||||
erro_mensagem |
text | ||||
step_atual |
varchar | ||||
steps_completados |
jsonb | NOT NULL | default [] |
||
detalhes |
jsonb | ||||
atividade_atual |
text | V9 | escrita ao vivo p/ polling |
- CHECK
chk_resetar_powersync_status:status ∈ {PENDENTE, EM_ANDAMENTO, SUCESSO, ERRO}. - Unique parcial
uq_xls_resetar_powersync_em_andamentoWHERE status = 'EM_ANDAMENTO': no máximo um reset em andamento (2º disparo → 409). - Índice
iniciado_em DESC. Ex-xls_nuke_replication(renomeada V14).
4. Mapeamento entrada↔dados¶
Cada aba alimenta uma tabela do banco (o ExcelProcessor lê por índice de coluna):
| Aba da planilha | → Tabela do banco | Linha válida exige |
|---|---|---|
10 (E1/F1) |
xls_processamento.periodo_inicial / periodo_final |
— (1ª linha) |
20 faturamento |
bi_faturamento |
dt_frete (E) e valor (R) |
30 movimento |
bi_movimento |
data_lcto (Q) e valor (P) |
Cada coluna grava na coluna homônima da tabela (nomes na seção 2); não é mapeamento posicional no banco, é por nome. Transformações na leitura:
- datas
dd/MM/yyyy→DATE; valores →DECIMAL; texto truncado ao tamanho da coluna (ver Modelo de dados). - ano
AAAA/AAAA(coluna AK/AE) → divide emano_construcao(esquerda) eano_modelo(direita); formato inválido → ambos nulos. - o período da aba 10 não vira coluna de fato — delimita quais linhas o processamento mescla naquele upload (cap. 5).
A identidade de cada fato no banco não é a posição na planilha, e sim a chave natural (cap. 3) — é o que permite reprocessar o mesmo período sem duplicar.
5. Fluxos e processamento¶
Arquitetura interna¶
O código é package-by-feature (decisão
0022): cada funcionalidade —
processamento, powersync, configuracao, seguranca — carrega suas próprias
camadas Controller → Service → Repository. A fundação transversal — erro RFC
7807, health, Sentry, versão, motor de auth (Firebase/sessão/Bearer/CSRF), object
storage Garage, utilitários e infra de teste — vem das libs do xadm-commons
(xadm-seguranca, xadm-comum-web, -storage, -util, -teste; decisões
0023/0024/0025).
O pacote local comum guarda só o resto app-específico (config typed,
StorageIndisponivelException, dto, AnosVeiculoParser, notificação Telegram).
Dentro de uma feature o fluxo é
Controller → Service → Repository: o controller só fala com service, nunca
com repositório direto, e a entidade não cruza o controller (a borda HTTP fala
DTO/view-model). A leitura do Excel está em processamento.processing (parser
FastExcel/StAX); a persistência é Micronaut Data JDBC, sem Hibernate (decisão
0001). O ArchUnit guarda três
invariantes: sem ciclos entre as features de topo, nenhum subpacote de comum
depende de feature e processamento.processing não depende da camada HTTP
(a leitura do Excel não conhece HTTP).
Recepção e processamento assíncrono¶
O envio responde 202 Accepted na hora; o trabalho pesado roda num pool
dedicado (processamento, 4 threads). Cada .xlsx do lote vira um processamento
próprio; o lote (xls_conjunto_lote) fecha quando todos terminam e manda um resumo
único no Telegram (decisão 0032). O
status em xls_processamento é a máquina de estados de cada planilha:
stateDiagram-v2
[*] --> PENDENTE: receber (checksum, grava binário, insere linha)
PENDENTE --> PROCESSANDO: job inicia
PROCESSANDO --> SUCESSO: grava métricas
PROCESSANDO --> ERRO: catch → erro_mensagem
ERRO --> PENDENTE: reprocessar
SUCESSO --> [*]
Reenviar o mesmo arquivo (mesmo checksum) já concluído com sucesso responde
200 sem reprocessar; já em fila, 409. Jobs presos por um restart são retomados
no boot (StartupRecoveryService).
Persistência idempotente (diff-aware)¶
O núcleo é o UPSERT diff-aware (etapa 02,
decisão 0006): por período,
COPY → TEMP TABLE → INSERT ... ON CONFLICT (chave natural) DO UPDATE WHERE ... IS
DISTINCT FROM, seguido de DELETE seletivo do que sumiu no período. Só linhas
realmente alteradas trocam de xmin — as inalteradas não re-propagam pelo
PowerSync (sem churn de bateria/banda nos celulares). Cada upload grava 8 métricas
finas (inseridas/atualizadas/inalteradas/removidas, por fato) em xls_processamento.
Como os arquivos de um lote mesclam em paralelo, a mescla tem duas defesas contra
deadlock: o INSERT … SELECT ordena pela chave natural e a fase de banco é
serializada por um advisory lock (decisão
0031); o parse do Excel
segue paralelo.
O binário do .xlsx vai para object storage (Garage), não para o banco — ver a
configuração de storage no cap. 6.
6. Conceitos transversais e configuração¶
Idempotência por checksum + chave natural (caps. 3 e 5) é o conceito central:
identidade de upload (checksum_sha256) separada da identidade de fato (chave
natural). Preservação de cadastro veicular (decisão
0012): correção feita direto no
banco sobrevive ao reprocessamento (entrada vazia não sobrescreve). Sinal de
reset (ultimo_nuke em bi_configuracao) avisa os clientes a reconectar após um
reset da replicação (etapas 05/06).
Prefixos de tabela são contrato com o PowerSync: bi_* sincroniza (exige
GRANT SELECT ao powersync_role — decisão
0013), xls_* é local.
Configuração (variáveis do projeto):
| Tema | Variáveis principais |
|---|---|
| Banco | DATASOURCES_DEFAULT_URL, DB_USER, DB_PASSWORD |
| API | VANTROBA_XLS_API_TOKEN (Bearer do /api/**; antes API_BEARER_TOKEN) |
| Object storage | S3_FILE_STORAGE (PSQL/PSQL_GARAGE/GARAGE), GARAGE_ENDPOINT, GARAGE_ACCESS_KEY, GARAGE_SECRET_KEY, GARAGE_BUCKET (antigas S3_* via fallback) |
| Auth das views | AUTH_FIREBASE_*, AUTH_SESSION_SECRET (login Google @xadm.com.br) |
| Reset / PowerSync | POWERSYNC_MONGODB_URI, POWERSYNC_NUKE_CONFIRMATION, COOLIFY_API_TOKEN |
| Notificação / erros | TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, SENTRY_DSN |
Defaults, semântica de cada modo de storage e o fluxo de auth/reset estão nas
etapas 03, 05 e
07; o runbook exaustivo de envs em
operacao/deploy. O deploy é procedimento — fica no
runbook, não neste livro.
7. Contratos públicos / API¶
A conclusão de tudo acima: as assinaturas que outros sistemas consomem.
| Método / rota | Auth | Resultado |
|---|---|---|
POST /api/xls/processar |
Bearer (ROLE_API) |
202 lote recebido (conjuntoId/total/itens[], status por arquivo) · 400 zip inválido/vazio/sem .xlsx |
GET /api/transporte/processamentos |
anônimo | lista paginada (page/size/status) |
GET /api/transporte/processamentos/{id} |
anônimo | detalhe (status, métricas) |
GET …/{id}/logs · …/{id}/arquivo |
anônimo | log (text/plain) · XLS original |
POST /api/transporte/admin/resetar-powersync |
Bearer + X-Confirm-Nuke |
202 (reset da replicação) |
GET /api/transporte/admin/resetar-powersync |
Bearer | histórico de resets: o Page do micronaut-data (page/size) |
Auth em duas camadas (decisão 0011):
/api/** por Bearer estático; as telas server-rendered por login Google. O
contrato narrado (exemplos curl, formato do arquivo, respostas) está em
dev/api-rest; a referência interativa gerada do código
(OpenAPI/Swagger) em
docs.xadm.biz/.../public/api/.
8. Apêndice — Histórico (changelog)¶
Como o sistema chegou ao estado atual — as etapas entregues e as decisões que as fundamentaram. Cada etapa descreve o seu delta; este livro é o consolidado.
Etapas:
- Recebimento e processamento automático das planilhas de transporte, com histórico rastreável de cada envio.
- Reprocessar um período sem gerar tráfego nem churn desnecessário para os aplicativos móveis.
- Arquivamento dos arquivos recebidos em storage dedicado, fora do banco de dados.
- Identificação de veículo e frota nos lançamentos, preservando correções manuais de cadastro.
- Reset seguro e auditável da replicação quando o estado replicado precisa ser zerado.
- Configurações compartilhadas entre servidor e aplicativo, ajustáveis sem novo deploy.
- Acesso às telas internas restrito a contas @xadm.com.br.
- Stack uma major atrás modernizada e código alinhado às práticas do framework, sem mudar comportamento observável.
Decisões:
- Micronaut Data JDBC, sem Hibernate
- Processar XLS (POI)
- Contrato canônico JSON para paridade Python↔Java
- Período de referência vem do upload (data/hora multipart)
- BIGSERIAL como PK nas tabelas bi_*
- Chaves naturais + UPSERT diff-aware
- COPY + TEMP TABLE no upsert em lote
- Object storage Garage com 3 modos (PSQL / PSQL_GARAGE / GARAGE)
- Resetar Powersync
- Sinal de reset do Powersync para clientes via bi_configuracao
- Autenticação em duas camadas (API Bearer + views Google)
- Preservação de cadastro de veículo no UPSERT
- GRANT automático ao powersync_role em tabelas novas
- Documentação no portal central (adoção da constituição X-Adm)
- GETs de leitura da API são anônimos (sem Bearer)
- Período do diff-aware vem da aba-10; data/hora do upload são metadado
- Contrato de erro único {code, message} via ErrorResponseProcessor
- Auth das views no micronaut-security nativo (aposenta o AuthFilter)
- Bump major para Java 25 + Micronaut 5 e seus efeitos colaterais
- Serialização via serde (build-time) e HTTP declarativo (@Client)
- Erro de API em RFC 7807 (ProblemDetail), alinhado ao default da casa
- Organização package-by-feature, alinhada ao padrão Micronaut da casa
- Adotar a lib xadm-seguranca (motor de auth compartilhado)
- Adotar a lib xadm-comum-web (infra web compartilhada)
- Adotar as libs xadm-comum-storage / -util / -teste (dedup do commons)
- Migrar leitura de XLSX de POI (SAX) para FastExcel — viabiliza native-image
- Bump das libs xadm-comum + migração do prefixo de storage s3. → garage.
- Views server-render em JTE (gg.jte) no lugar de Thymeleaf — native-safe
- 0029 — Adota o smoke de produção pós-deploy — ponteiro do ADR central 0031
- 0030 — Adota o CI 100% GitHub Actions e o deploy pelo control-plane — ponteiro dos ADRs centrais 0022, 0024, 0026 e 0027
- 0031 — Mescla concorrente: ORDER BY pela chave natural + advisory lock (anti-deadlock 40P01)
- 0032 — Envio em lote: um .zip com 1..N .xlsx por chamada
- 0033 — Adota a tela de login da xadm-seguranca e renomeia o token de API
- Adota a constituição 2.0.0: só native, libs na corrente e mavenLocal filtrado
Operação
Deploy (pipeline GitHub → Coolify)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-17
O que é¶
Como o bi-transporte-xls chega em produção (excel.vantroba.xadm.biz). Todo o
CI/CD roda no GitHub Actions, num workflow único —
.github/workflows/pipeline.yml (norma da casa:
ADR central 0027). O
Forgejo (fonte.xadm.biz) é só o git de origem e o registry de imagens — não
roda CI.
A imagem é buildada fora do host, no runner do GitHub: o binário native
(GraalVM, do Dockerfile.native —
ADR central 0033;
adoção na decisão 0034). O
Dockerfile JVM fica no repo como fallback, fora do build.targets. O deploy é
disparado pelo control-plane do central
(ADR central 0026):
o pipeline faz POST https://central-backend.xadm.biz/api/ci/deploy e o central
resolve qual recurso do Coolify atualizar. O Coolify só puxa a imagem pronta
e troca o container com rolling update guiado pelo HEALTHCHECK — não builda nem
reage a push (auto-deploy desligado).
| Job | O que faz |
|---|---|
decide |
Liga as flags tests / docs / native conforme o evento (ver Quando usar), lê o bloco smoke e a versão do Java (toolchain.java) do docs/app.json. |
gate |
Guardas da casa (nav-drift, toolchain × Dockerfile, placeholder aninhado, POI em native, locale pt-BR, paridade dos Dockerfiles, libs no piso…) + ./gradlew check + contrato REST + aviso de modelagem. |
release-check |
Só em tag: confere SemVer × CHANGELOG × tag. |
docs |
Javadoc + OpenAPI, mkdocs build --strict (também em PR) e publicação no Garage (tag → snapshot da versão; master → dev/). |
build_native |
Em paralelo ao gate. Publica fonte.xadm.biz/xadm/vantroba-xls:native-amd64 (tag móvel) e :<sha>-native (tag imutável). É o job mais longo (~15 min). |
deploy |
Só com gate verde e, em tag, release-check verde. Anota o commit que está no ar (alvo de rollback) e faz o POST do alvo native. |
smoke |
Smoke de produção pós-deploy; reprovou → rollback automático (ver Reversão). |
O recurso vantroba-xls-native-pull (imagem vantroba-xls:native-amd64) é o dono
do domínio principal excel.vantroba.xadm.biz e responde também no próprio
excel-native.vantroba.xadm.biz. O recurso jar do Coolify fica parado. O
control-plane acha o recurso pelo slug da imagem, não por uuid.
Topologia¶
flowchart TB
gh["GitHub Actions<br/>pipeline.yml"]
reg[("Registry fonte.xadm.biz<br/>xadm/vantroba-xls")]
cp["control-plane<br/>central-backend.xadm.biz"]
coolify["Coolify"]
traefik["Traefik<br/>excel.vantroba.xadm.biz"]
native["vantroba-xls-native-pull<br/>excel-native.vantroba.xadm.biz"]
pg[("PostgreSQL<br/>vantroba_transporte")]
garage[("Garage<br/>arquivo-api.xadm.biz")]
ps["PowerSync<br/>ps.vantroba"]
gh -->|"push :native-amd64"| reg
gh -->|"POST no control-plane"| cp
cp -->|"atualiza o recurso"| coolify
coolify -->|"puxa a imagem"| reg
coolify --> native
traefik --> native
native --> pg
native --> garage
pg -->|"replicação lógica (bi_*)"| ps
- Traefik termina o TLS na borda e encaminha HTTP para a porta interna 8080 — o app vê HTTP, não HTTPS.
- O host do JDBC não é
localhost: é o nome do serviço Docker do Postgres na rede interna do Coolify (ex.:postgresql18), não o host da máquina. - O Garage (
arquivo-api.xadm.biz) guarda o binário dos.xlsx; o PowerSync (ps.vantroba) replica as tabelasbi_*do Postgres para os clientes móveis. - Runtime: binário native sobre
ubuntu:24.04(glibc 2.39; o builder-ol8compila na 2.28). PostgreSQL 18+. - Health check
/healthcom intervalo de 15 s (timeout 5 s, 15 tentativas) — o rolling update espera o container novo ficar healthy.
Quando usar¶
- Release nova — sempre pela skill
/xadm-release(nunca bump nem tag à mão): ela bumpa ogradle.properties, escreve o CHANGELOG, cria a tag anotadavX.Y.Ze faz o push. A tagv*roda o release: testes + snapshot da doc sempre, e build + deploy dos alvos do trailerDeploy:da tag anotada — sem trailer, cai nodocs/app.json→build.targets(aqui,native). - Redeploy sem bump (republicar o mesmo código, ex. depois de falha de
infra) —
workflow_dispatch(passo a passo abaixo). - Provisionar um ambiente novo — ver Provisionar ambiente novo no Coolify.
Push em master (ou PR) não deploya: roda só gate e/ou docs, conforme
os caminhos alterados (código → gate; docs/ ou mkdocs.yml → docs). O commit
chore(release) em master é pulado — quem roda o release é a tag.
Pré-requisitos¶
- Claude Code com a skill
/xadm-releasee permissão de push emfonte.xadm.biz/xadm/vantroba-bi-xls(o repo do GitHub é o espelho que o CI lê). - Toda lib da casa declarada no
build.gradle.ktspublicada no registro: a versão que só existe nomavenLocalnão chega ao runner, e a/xadm-releaserecusa. - Acesso ao GitHub Actions do repo, para acompanhar o run e disparar o
workflow_dispatch. -
Secrets do repo no GitHub — confira se um job falhar por falta deles:
Secret Usado por FORGEJO_USER/FORGEJO_TOKENbuild_native— push da imagem no registryfonte.xadm.bizCENTRAL_DEPLOY_TOKENdeployesmoke(rollback) —POSTno control-planeGLITCHTIP_API_TOKENsmoke, camada 3 — token read-only do GlitchTip. Ausente = smoke reprovaSMOKE_M2M_TOKENsmoke, rotam2m— o mesmo valor doVANTROBA_XLS_API_TOKENdo recursoSMOKE_TOKENsmoke, rotasession— o mesmo valor da envSMOKE_TOKENdo recursoDOCS_S3_ENDPOINT/DOCS_S3_ACCESS_KEY/DOCS_S3_SECRET_KEYdocs— publicação no Garage
Variáveis de ambiente (runtime, no Coolify)¶
Ao contrário de um app Flutter, aqui a configuração é de runtime: vive nas
variáveis de ambiente do recurso no Coolify. O único valor de build é o
XADM_COMMIT, que o pipeline carimba na imagem (vira o commit do /health);
não há Build Argument a configurar no Coolify, porque ele não builda.
Core (obrigatórias em produção)¶
| Variável | Padrão | Descrição |
|---|---|---|
VANTROBA_XLS_API_TOKEN |
— | Bearer do /api/** (POST /api/xls/processar e o reset REST). Nome pelo destino, padrão da casa (seguranca); renomeada de API_BEARER_TOKEN — ver a nota abaixo |
DATASOURCES_DEFAULT_URL |
jdbc:postgresql://localhost:5432/vantroba_transporte |
URL JDBC |
DATASOURCES_DEFAULT_USERNAME |
vantroba_transporte |
usuário do banco |
DATASOURCES_DEFAULT_PASSWORD |
— | senha do banco |
PORT |
8080 |
porta HTTP |
APP_BASE_URL |
https://excel.vantroba.xadm.biz |
base dos links em notificações |
API_BEARER_TOKEN→VANTROBA_XLS_API_TOKEN(renomeada na próxima release). Cadastre o nome novo no recurso do Coolify antes do deploy dessa release, com o mesmo valor. OBearerTokenEnvresolve em código a primeira env não-vazia:VANTROBA_XLS_API_TOKEN, depoisAPI_BEARER_TOKEN. Sem ambas, declaraapp.api-tokenvazia para axadm-segurancarecusar o boot em produção. Quem chama a API não é afetado. Apague a antiga somente quando todos os deploys usarem o nome novo e o smoke estiver verde; mantenha-a se ainda precisar reverter para a versão antiga.
Telegram (opcional — sem token, notificação off)¶
TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID.
Object storage Garage (binário do XLS)¶
| Variável | Padrão | Descrição |
|---|---|---|
S3_FILE_STORAGE |
PSQL_GARAGE |
PSQL | PSQL_GARAGE | GARAGE (MAIÚSCULAS) |
GARAGE_ENDPOINT |
http://localhost:3900 |
em prod: Garage do arquivo-api.xadm.biz |
GARAGE_REGION |
garage |
bate com garage.toml |
GARAGE_ACCESS_KEY_ID / GARAGE_SECRET_ACCESS_KEY |
— | credencial do bucket, com os nomes que o broker da Central injeta (obrigatórias em prod) |
GARAGE_ACCESS_KEY / GARAGE_SECRET_KEY |
(dev key) | par antigo: lido só sem os nomes do broker, com WARN no boot |
GARAGE_BUCKET |
vantroba |
bucket dedicado |
GARAGE_CRIAR_BUCKET |
false |
true cria o bucket no startup (dev/teste); prod = false |
Envs
S3_*antigas seguem valendo. Oapplication.ymllê asS3_*como default das propriedadesgarage.*, e asGARAGE_*sobrescrevem por prioridade de fonte (env vence o YAML) — precedênciaGARAGE_*>S3_*> default, sem placeholder aninhado. A credencial segue o dual-read daxadm-comum-storage0.3.0: os nomes do broker primeiro, o par antigo como fallback (decisão 0034). Com o app no ar lendo os nomes do broker (sem oWARN), apague do recurso asS3_*e o parGARAGE_ACCESS_KEY/GARAGE_SECRET_KEY.S3_FILE_STORAGE(o modo,app.storage.modo) não muda.
Desenho e modos: etapa 03.
Error tracking (GlitchTip)¶
| Variável | Padrão | Descrição |
|---|---|---|
SENTRY_DSN |
— | DSN do projeto bi-transporte-xls no bug.xadm.biz — o mesmo do docs/app.json → features.glitchtip.dsn. Ausente = desligado. Confira pela chave: DSN de projeto inexistente perde todo erro em silêncio |
SENTRY_ENVIRONMENT |
production |
ambiente reportado |
SENTRY_TRACES_SAMPLE_RATE |
0.0 |
amostragem de tracing |
O SentryInitializer (da xadm-comum-web) roda antes do contexto Micronaut;
falha de init é engolida (nunca impede o boot). O nome antigo GLITCHTIP_DSN não é
lido: recurso que só tem ele sobe com o error tracking desligado, e o boot loga
WARN pedindo o rename.
Não setar SENTRY_RELEASE no Coolify. O release sai sozinho do XADM_COMMIT
carimbado na imagem (o sha da entrega), e o smoke pós-deploy usa
firstRelease == <sha> para achar exceção nova. SENTRY_RELEASE é override do
operador e ganha da lib: um vX.Y.Z ali descasa o release do sha e cega a camada 3 do
smoke (decisão 0029).
Reset da replicação PowerSync (POWERSYNC_* / COOLIFY_*)¶
Tabela completa (o runbook resetar-powersync aponta pra cá):
| Variável | Padrão | Descrição |
|---|---|---|
POWERSYNC_MONGODB_URI |
— | Connection string do Mongo do PowerSync. Vazia → o reset só simula o drop. O nome do DB é lido do path da URI |
POWERSYNC_MONGODB_DATABASE |
(do path da URI) | Override do nome do DB Mongo (raro) |
POWERSYNC_NUKE_CONFIRMATION |
— | Habilita o endpoint REST (a tela /admin não depende). Contrato: nome permanece "nuke" |
POWERSYNC_SLOT |
(auto-discover) | Vazio → descobre o slot do Mongo (sync_rules com state='ACTIVE'). Setar explícito só pra forçar/recovery em PG compartilhado |
POWERSYNC_PLUGIN |
pgoutput |
Plugin de replicação lógica do slot |
POWERSYNC_BOOTSTRAP_WAIT |
60 |
Segundos de espera extra pós-restart, antes da amostra final, pro bootstrap do PowerSync assentar |
COOLIFY_API_TOKEN |
— | Token Coolify (escrita/deploy). Setá-lo liga o restart automático (estratégia coolify); sem ele o restart é manual |
POWERSYNC_COOLIFY_NAME |
— | Nome exato do recurso PowerSync no Coolify (ex.: ps.vantroba). NAME ou UUID |
POWERSYNC_COOLIFY_UUID |
— | Alternativa ao _NAME: UUID do recurso |
COOLIFY_API_URL |
https://coolify.xadm.biz |
Em prod no mesmo daemon Docker do Coolify: http://coolify:8080 (HTTP interno — evita PKIX path building failed do hairpin NAT) |
POWERSYNC_HEALTH_URL |
— | URL de health-check do PowerSync conferida pós-restart |
POWERSYNC_RESTART_TIMEOUT |
120 |
Timeout (s) do polling do deployment Coolify |
Postgres: a role do app precisa do atributo REPLICATION (o reset dropa/cria o slot). Uma vez, como superuser: ALTER ROLE <role_do_app> WITH REPLICATION;.
Login das views (AUTH_*)¶
Runbook próprio com a tabela: google-auth.
Smoke de produção (SMOKE_TOKEN)¶
| Variável | Padrão | Descrição |
|---|---|---|
SMOKE_TOKEN |
— | Liga o seam POST /smoke/session da xadm-seguranca, que troca o token (cabeçalho X-Smoke-Token) por sessão de um principal smoke só-leitura. Vazia = seam desligado, e a rota session do smoke reprova. Mesmo valor do secret SMOKE_TOKEN do GitHub |
Passos¶
Release¶
- Rode
/xadm-releaseno Claude Code do repo. - Acompanhe o run do workflow
pipelineno GitHub Actions, disparado pela tagvX.Y.Z:gate,release-checkebuild_nativeem paralelo →deploy→smoke. OPOSTdo deploy é assíncrono (responde antes de o container novo servir) — quem confirma a entrega no ar é osmoke. - Smoke verde = produção confirmada. Vermelho: veja Reversão.
Redeploy sem bump¶
GitHub Actions → workflow pipeline → Run workflow, escolhendo como ref a
tag vigente (não master, que pode ter código ainda sem release) e marcando
native. Deixe tests marcado (default): sem ele o build roda, mas o deploy sai
pulado — deploy só com gate verde.
Provisionar ambiente novo no Coolify¶
- Banco (no Postgres compartilhado, como superuser) — criar antes do app:
Se for usar reset da replicação, garantir o atributo
CREATE DATABASE vantroba_transporte; CREATE USER vantroba_transporte WITH PASSWORD '<senha-forte>'; GRANT ALL PRIVILEGES ON DATABASE vantroba_transporte TO vantroba_transporte; \c vantroba_transporte GRANT ALL ON SCHEMA public TO vantroba_transporte;REPLICATIONna role e a existência dopowersync_role(ver decisão 0013). - Recurso Docker Image (pull-only, auto-deploy desligado), com o nome
canônico
<slug>-<target>-pull:vantroba-xls-native-pullna imagemfonte.xadm.biz/xadm/vantroba-xls:native-amd64. Não há webhook nem secret por recurso: o control-plane acha o recurso pelo slug da imagem (reconcile do central — ver deploy na infraestrutura). - Variáveis — todas as acima.
- Volume: mapear
/app/data/execucoes(WORKDIR /app+app.logs.dir: data/execucoes): é o histórico por processamento que a UI exibe, dado do app — nunca em/app/logs. - Health check: caminho
/health, porta8080(obrigatório — o rolling update espera ficar healthy). - Domínio:
excel.vantroba.xadm.biz(principal) eexcel-native.vantroba.xadm.bizno mesmo recurso. Procedimento e armadilhas na infraestrutura da casa.
Flyway: o histórico fica em
bi_transporte_flyway_schema_history. As migrations rodam no startup. Ao migrar de um schema antigo sem prefixo, rodarALTER TABLE bi_flyway_schema_history RENAME TO bi_transporte_flyway_schema_history;antes do deploy, senão o Flyway recria a tabela vazia e re-aplica tudo.Conformidade e retenção: os dados sensíveis do Excel ficam no PostgreSQL e no object storage; usar HTTPS sempre. Os logs de execução em disco (
app.logs.dir) não substituem auditoria fiscal — conservar as políticas de backup do banco e do volume de logs.
Verificação¶
- Job
smokeverde = produção confirmada. Ele roda depois dodeploye confere sozinho a identidade (ocommitdo/healthé o sha entregue), as rotas críticas do blocosmokedodocs/app.json(/health, a listagem do reset com o Bearer e/processamentoscom a sessão do seam) e nenhuma exceção nova no GlitchTip. Manifesto e porquê na decisão 0029; a norma, em smoke de produção. - Conferência manual (opcional):
curl https://excel.vantroba.xadm.biz/health→{"status":"UP","versao":"X.Y.Z","flavor":"native","commit":"<sha>"}. GET /processamentosabre (login em prod).- Logs do container no Coolify sem erro de Flyway/datasource e sem o
WARNde credencial antiga do Garage.
Reversão¶
Automática (smoke pós-deploy). Se o smoke reprova, o pipeline reverte sozinho
para a tag imutável fonte.xadm.biz/xadm/vantroba-xls:<sha anterior>-native (o
sha anterior é o commit que o /health mostrava antes do deploy), repola o
/health até confirmar a volta e deixa o run vermelho. Se o relatório disser
"rollback NÃO confirmado", produção pode estar quebrada: siga para a reversão
manual. Quando o smoke não reverte (outro deploy assumiu, falta de alvo, defeito
de configuração como GLITCHTIP_API_TOKEN ausente): ver a
norma do smoke.
Primeiro deploy após adotar o smoke: o
/healthno ar ainda não tinhacommit(lib < 0.9.0), então esse deploy não tem alvo de rollback — se reprovar, a reversão é manual. Dali em diante o rollback automático vale.Não pode a tag
<sha>-nativeque está no ar — é o alvo do rollback; apagá-la do registry transforma a reversão em "não confirmada" no pior momento.
Manual, quando preciso:
workflow_dispatchdopipelinetendo como ref a tag anterior boa, comnativemarcado — rebuilda e redeploya aquele código pelo caminho normal, com gate e smoke.- Break-glass (GitHub ou control-plane fora do ar): redeploy pelo painel do
Coolify, no recurso native, a partir de uma imagem
<sha>-nativeboa. É fora do fluxo — volte ao pipeline no deploy seguinte.
Fallback JVM (defeito que só o binário native tem): registre o motivo numa
decisão do app, ponha jar no build.targets, restaure do template do
pipeline.yml o build_jar e o step "Deploy jar", e religue o recurso
vantroba-xls-jar-pull (imagem :jar-amd64) com as mesmas variáveis.
A V17 (histórico em data/execucoes) aguenta a volta: a release anterior lê os caminhos novos pelo
volume, mas grava os logs que produzir em /app/logs, fora dele.
Migrations Flyway não revertem (nem no rollback do smoke) — se a versão nova introduziu migration incompatível, planejar a correção no banco antes de voltar.
Resetar Powersync¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-10
Operação destrutiva
Zera o estado replicado (slot do Postgres + base do PowerSync no MongoDB) e força todos os clientes móveis a um full re-sync. Leia até o fim antes de executar pela primeira vez.
O que é¶
Endpoint/tela admin que zera o estado replicado e força os clientes a refazer o
sync a partir do estado atual das tabelas bi_*. Usado para colapsar o histórico
acumulado no Mongo (que cresce sem parar). O desenho (9 steps, sinal para os
clientes) está em etapa 05.
Quando usar¶
- Storage do Mongo passou da faixa de tolerância (limpeza mensal/trimestral).
- Após mudança de schema em
bi_*que invalide os checkpoints. - Diagnóstico de divergência entre PG e Mongo (último recurso).
Quando não usar¶
- Em horário comercial — clientes online sofrem um freeze de alguns segundos; com muitos clientes, o re-bootstrap simultâneo gera carga.
- Sem janela de baixo tráfego confirmada com operação.
Pré-requisitos¶
Variáveis no app (ver deploy para a tabela completa):
POWERSYNC_MONGODB_URI— sem ela, o reset só simula o drop.POWERSYNC_NUKE_CONFIRMATION— habilita o endpoint REST (a tela/adminnão depende dela). Contrato: o nome do env permanece "nuke".- Restart automático (recomendado):
COOLIFY_API_TOKEN+POWERSYNC_COOLIFY_NAME(ou_UUID) → a estratégiacoolifyreinicia o recurso PowerSync sozinha ao fim do reset. Sem o token, o restart é manual. - Postgres: a role do app precisa do atributo
REPLICATION(o stepRESET_SLOTusapg_drop_replication_slot/pg_terminate_backend). Aplicar uma vez como superuser:ALTER ROLE <role_do_app> WITH REPLICATION;. Sem isso, o reset falha compermission denied to use replication slots. - PowerSync: o
powersync.yamlprecisa terbi_configuracaono streamrecent_data(priority 0). Após mudar osync_rules, redeploy do recurso PowerSync no Coolify — senão o sinalultimo_nukeé gravado no PG mas nunca chega no cliente.
Checklist: janela de baixo tráfego confirmada · Telegram de ops sendo
monitorado · (se for usar o endpoint REST) Bearer + POWERSYNC_NUKE_CONFIRMATION
em mãos.
Passos¶
Opção A — Tela /admin (recomendado)¶
- Abrir
/admin(login Google), clicar em Resetar Powersync. - Confirmar no modal anti-acidente (digitar
RESETAR). - Acompanhar os steps na tela de detalhe (polling automático).
- Com a estratégia
coolifyativa, o PowerSync reinicia sozinho; senão, reiniciar o recurso PowerSync no Coolify (1 clique) ao fim.
Opção B — Endpoint REST¶
Sem
COOLIFY_API_TOKEN(restart manual): parar o PowerSync ANTES. Sem isso, o slot fica ativo e oRESET_SLOTfalha (replication slot ... is active) — é a falha mais comum. Parar:docker stop powersyncoukubectl scale deploy/powersync --replicas=0. Subir de volta ao fim. Com a estratégiacoolifyativa, o PAUSE/RESUME é automático e este passo é dispensável.
BASE="https://excel.vantroba.xadm.biz"
curl -X POST "$BASE/api/transporte/admin/resetar-powersync" \
-H "Authorization: Bearer <VANTROBA_XLS_API_TOKEN>" \
-H "X-Confirm-Nuke: <POWERSYNC_NUKE_CONFIRMATION>" \
-H "X-Operator-Id: $USER" \
-H "Content-Type: application/json" \
-d '{"motivo": "limpeza trimestral 2026Q2"}'
# → 202 { "resetId": N, "status": "EM_ANDAMENTO", "linkStatus": "..." }
X-Operator-Idregistra quem disparou o reset (auditoria emxls_resetar_powersync).
Polling: GET $BASE/api/transporte/admin/resetar-powersync/{id} (Bearer) —
acompanhar status (EM_ANDAMENTO → SUCESSO). Tempo típico: < 60 s +
POWERSYNC_BOOTSTRAP_WAIT.
Histórico: GET $BASE/api/transporte/admin/resetar-powersync?page=0&size=20 (Bearer) —
devolve o Page do micronaut-data (content, totalSize e pageable com number/size),
mais recentes primeiro. size padrão 20, teto 200.
O header de confirmação permanece
X-Confirm-Nuke(contrato).
Verificação¶
- Telegram de ops:
✅ RESETAR POWERSYNC CONCLUÍDO em N ms. - Smoke em 1 cliente móvel: reabrir o app e confirmar o re-sync (download dos
buckets atuais; o bootstrap escala com o volume de
bi_*). - O sinal
ultimo_nukeembi_configuracaoé atualizado noFINALIZAR(faz o cliente Flutter dardisconnectAndClear). Contrato: a chave permaneceultimo_nuke.
Reversão / recovery por step¶
Sem rollback automático. Falha → status ERRO + Telegram. Resets órfãos em
EM_ANDAMENTO (container morreu no meio) são marcados ERRO automaticamente pela
recuperação de startup no próximo boot. Recovery manual por step:
| Step | Estado deixado pelo erro | Recovery |
|---|---|---|
VALIDAR/LOCK |
outro reset em andamento (409 já no POST inicial) | aguardar o concorrente terminar e re-chamar |
PAUSE |
PowerSync ainda rodando (sem estratégia coolify, é no-op) |
sem cleanup; re-chamar |
SNAPSHOT_PRE |
PG consultado, Mongo intacto | sem cleanup; re-chamar |
RESET_SLOT |
slot dropado mas não recriado | psql: se pg_replication_slots vazio, SELECT pg_create_logical_replication_slot('powersync','pgoutput'); e subir o PowerSync |
DROP_MONGO |
Mongo parcial/intacto (pior caso) | mongosh "$URI": use <db>; db.dropDatabase(); subir PowerSync (bootstrap fresh). O <db> vem do path da POWERSYNC_MONGODB_URI |
RESUME |
destrutivo já feito, PowerSync parado | reiniciar o recurso PowerSync no Coolify (fallback manual: docker start powersync / kubectl scale deploy/powersync --replicas=1) |
SAMPLE_POST/FINALIZAR |
reset de fato OK, só a finalização falhou | SQL de emergência abaixo |
SQL de emergência (finalização falhou — fecha o reset e grava o sinal pros clientes):
-- 1. marca o reset como concluído
UPDATE xls_resetar_powersync SET status = 'SUCESSO' WHERE id = N;
-- 2. grava o sinal pros clientes, se marcarUltimoNuke() não gravou.
-- 'ultimo_nuke' é contrato (lido pelo cliente Flutter) — não renomear.
INSERT INTO bi_configuracao (id, valor, tipo, sistema, atualizado_em)
VALUES ('ultimo_nuke', NOW()::TEXT, 'TIMESTAMP', TRUE, NOW())
ON CONFLICT (id) DO UPDATE SET valor = EXCLUDED.valor, atualizado_em = NOW();
Falha mais provável — RESET_SLOT por slot ativo: sintoma
replication slot ... is active; causa = PowerSync não parou antes. Diagnóstico:
SELECT slot_name, active, active_pid FROM pg_replication_slots;. Preferir parar
o PowerSync e re-chamar (a estratégia coolify faz PAUSE/RESUME sozinha; sem o
token, parar manualmente — ver Passos). Último recurso: kill -9 <active_pid>.
Sintomas pós-reset¶
| Sintoma | Causa provável | Ação |
|---|---|---|
| Cliente móvel não re-sincroniza ao reabrir | PowerSync não voltou a rodar | docker ps / kubectl get pods; subir o recurso |
| Bootstrap demorando (> 10 min) | volume grande em bi_* ou conexão fraca |
esperar; checar logs do PowerSync e o storage do Mongo crescendo |
| Erros de checkpoint nos clientes | slot novo à frente do esperado | esperar o bootstrap; se persistir, rodar o reset de novo |
Configurar login Google das views¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-08-04
O que é¶
Habilita o login Google (Firebase) nas views server-rendered
(/processamentos/**, /admin/**), restrito a emails @xadm.com.br. O
desenho está em etapa 07;
aqui é o procedimento de configuração. O /api/** (Bearer) não é afetado.
Quando usar¶
- Subir um ambiente de produção (as views precisam das envs
AUTH_*, senão ficam fail-closed em503). - Apontar o app para um novo domínio público (precisa autorizar o domínio no Firebase).
Pré-requisitos¶
Reusa o projeto Firebase xadm-6ab81 (compartilhado entre apps X-Adm).
- Authorized domains — Firebase Console → Authentication → Settings →
Authorized domains → adicionar o domínio do deploy (ex.:
excel.vantroba.xadm.biz). Sem isso o popup do Google falha no navegador. - Provider Google — já habilitado no
xadm-6ab81; nada a fazer. AUTH_SESSION_SECRET— segredo HS256 exclusivo deste deploy, ≥32 chars:openssl rand -hex 32. Nunca reusar de outro deploy.
Não é necessário o
firebase-admin-sdk.json: a validação do ID token usa só o JWKS público do Google (nimbus-jose-jwt).
Passos¶
Configurar as variáveis no Coolify:
| Variável | Obrigatória | Padrão | Descrição |
|---|---|---|---|
AUTH_FIREBASE_PROJECT_ID |
sim | — | xadm-6ab81 |
AUTH_FIREBASE_API_KEY |
sim | — | Web API key (valor público) |
AUTH_FIREBASE_AUTH_DOMAIN |
sim | — | xadm-6ab81.firebaseapp.com |
AUTH_FIREBASE_APP_ID |
sim | — | Web App ID (valor público) |
AUTH_SESSION_SECRET |
sim | — | segredo HS256 ≥32 chars, exclusivo |
AUTH_SESSION_TTL_SECONDS |
não | 28800 (8h) |
TTL do cookie |
AUTH_COOKIE_SECURE |
não | true |
Secure (true em prod HTTPS) |
AUTH_XADM_EMAIL_DOMAIN |
não | xadm.com.br |
domínio aceito |
AUTH_ISSUER |
não | bi-transporte-xls |
claim iss do xadm_session; o default fixo casa com as sessões existentes — só setar para forçar outro emissor |
Os 4 AUTH_FIREBASE_* são públicos (vão no HTML da página de login por design do
Firebase Web SDK). AUTH_SESSION_SECRET é o único segredo — nunca logado.
Verificação¶
- Abrir uma view (
/processamentos) → redireciona para/login→ "Entrar com Google" → após login com conta@xadm.com.br, volta para a view com o email na navbar. - Conta fora do domínio → "Acesso restrito a emails @xadm.com.br" (esperado).
Reversão / comportamento sem as envs¶
Não há "desligar" explícito — o comportamento depende das envs e do ambiente:
| Situação | Comportamento |
|---|---|
Envs AUTH_* completas |
filtro ativo (views exigem login) |
Envs ausentes e ambiente dev/test |
bypass (views públicas, WARN no log) |
| Envs ausentes e produção | fail-closed (503 em todas as views) |
Sessão e segurança (caveats)¶
- Sessão stateless (cookie
xadm_session, JWT HS256 auto-contido): não há revogação server-side. O logout só expira o cookie no navegador; um token vazado vale até expirar. O TTL de 8h (AUTH_SESSION_TTL_SECONDS) limita essa janela. - Fluxo: view sem sessão →
AuthFilterredireciona para/login?from=<path>→ "Entrar com Google" →POST /login/callback(valida CSRF + ID token + domínio) → emite o cookie → volta profromoriginal. - CSRF: os forms POST de view carregam
_csrfderivado da sessão; ausente/ divergente →403. O enforcement só vale com auth ativo — emdev/test(bypass) o CSRF não é exigido.
Troubleshooting¶
| Sintoma | Causa / ação |
|---|---|
Todas as views em 503 |
produção sem as 5 envs obrigatórias — configurar |
| Popup do Google falha (domínio) | domínio do deploy não está em Authorized domains |
Login OK mas volta sempre pro /login |
cookie não persistiu — checar AUTH_COOKIE_SECURE vs HTTPS |
| Bean falha no startup (secret curto) | AUTH_SESSION_SECRET < 32 chars — gerar novo |
| Login intermitente com erro do Google | JWKS do Google indisponível → o servidor responde 503 + Retry-After; tentar de novo |
Dev / API
Guia do código¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-18
Audiência: dev que abriu o repositório e quer passar os olhos e entender como o
código está organizado, sem ainda mergulhar no Javadoc classe a classe. Para o
porquê das decisões, veja decisoes/;
para rodar/testar, Como rodar; para o
contrato externo, API REST.
A tabela de classes no fim desta página é gerada no build a partir da 1ª frase do Javadoc de cada tipo — então acompanha o código. A narrativa abaixo é autorada.
Como o código é organizado¶
O código segue package-by-feature (decisão
0022): cada funcionalidade é um pacote de
topo sob br.com.vantroba.bi.transporte que carrega suas próprias camadas, em
vez de pastas globais controller/, service/, repository/. A fundação
transversal (erro RFC 7807, health, Sentry, motor de auth, object storage Garage,
utilitários e infra de teste) vem das libs do xadm-commons — não é código
local; ver decisões 0023/0024/0025.
O pacote local comum guarda só o resto app-específico (o que não coube numa
feature nem numa lib).
| Pacote de topo | É | Responsabilidade |
|---|---|---|
processamento |
feature | o pipeline XLS ponta a ponta — a feature central do app |
powersync |
feature | reset da replicação PowerSync |
configuracao |
feature | configuração global de runtime (admin) |
seguranca |
feature | as duas portas de auth (Bearer + login Google) — view-glue sobre o motor da lib |
comum |
resto | app-específico: config typed, exception (503 XLS), dto, util (AnosVeiculoParser), notificacao (Telegram) |
A fundação transversal (erros, health, Sentry, versão, auth, storage, util, teste) mora nas libs
xadm-seguranca/xadm-comum-web/-storage/-util/-testedoxadm-commons— um bump de lib corrige todos os apps da casa de uma vez.
As camadas dentro de uma feature¶
O fluxo de uma requisição é Controller → Service → Repository:
- Controller (
@Controller): só HTTP. Em API retorna DTO (record+@Serdeable) → JSON; em view server-rendered alimenta o template JTE com um view-model. Zero regra de negócio. - Service (
@Singleton): regra de negócio, orquestração,@Transactional, conversão entidade↔DTO. Injeção por construtor. - Repository (Micronaut Data JDBC, sem Hibernate — decisão 0001): só acesso a dado.
- A entidade (
@MappedEntity) nunca cruza o controller. A borda HTTP fala DTO (API) ou view-model (view); a entidade fica da camada de service para dentro.
Travas de arquitetura (ArchUnit)¶
O ArchitectureTest trava três invariantes do
package-by-feature — rodam no ./gradlew check:
- sem ciclos entre as features de topo;
- nenhum subpacote de
comumdepende de feature (inclusivecomum.notificacao: o Telegram é transporte puro, recebe texto pronto; a formatação que conhece o domínio vive no notificador de cada feature); processamento.processing(leitura do Excel) não depende da camada HTTP.
Referência completa¶
O Javadoc gerado (árvore de classes navegável, interna) fica em
dev/api/ — gerado pelo CI de docs a cada publish. Use-o quando precisar do
detalhe de uma classe; o inventário abaixo é o atalho para achá-la.
Inventário das classes¶
Processamento¶
Pipeline de processamento da planilha XLS — a feature central do app.
| Pacote | Classe | O que faz |
|---|---|---|
processamento |
ArmazenamentoArquivoService |
Coordena o armazenamento do binário XLS entre o Postgres (coluna arquivo_bytes) e o object storage Garage, conforme o ArmazenamentoModo configurado em app.storage.modo. |
processamento |
ArquivoBytesLegado |
Projeção de coluna única: o binário legado arquivo_bytes. |
processamento |
ArquivoXlsDownload |
Conteúdo de um arquivo XLS recuperado do storage para download. |
processamento |
BulkUpserter |
Caminho idempotente UPSERT diff-aware + DELETE seletivo (spec 004). |
processamento |
ColunasConteudo |
Fonte de verdade única das colunas relevantes ao caminho idempotente UPSERT diff-aware (spec 004 — § 4.2 e § 5.4). |
processamento |
ConjuntoLote |
Tabela xls_conjunto_lote: rastreia um lote de arquivos enviados juntos num .zip. |
processamento |
ConjuntoLoteRepository |
Persistência de ConjuntoLote (lote por zip). |
processamento |
ConjuntoLoteService |
Coordena o ciclo de vida de um lote (.zip). |
processamento |
CopyTextWriter |
Builders para o formato TEXT do COPY FROM STDIN do PostgreSQL. |
processamento |
Faturamento |
Linha de faturamento importada da aba 20 do Excel — tabela bi_faturamento. |
processamento |
FaturamentoRepository |
Repositório CRUD de bi_faturamento. |
processamento |
LoteRecebimentoBody |
Resposta do POST /api/xls/processar: id do lote, total e status por entrada. |
processamento |
MesclaResumo |
Agregado das métricas de uma execução do orquestrador (spec 004 § 5.5): contém o UpsertResultado de cada tabela alvo. |
processamento |
Movimento |
Linha de movimento contábil importada da aba 30 — tabela bi_movimento. |
processamento |
MovimentoRepository |
Repositório CRUD de bi_movimento. |
processamento |
ProcessamentoConsultaService |
Consultas de processamento: listagem paginada, detalhe, leitura do arquivo de log em disco e download do XLS original. |
processamento |
ProcessamentoDetalheResponse |
Detalhe de um processamento para API e tela de detalhe (metadados, contagens e métricas de idempotência da spec 004; sem bytes do XLS). |
processamento |
ProcessamentoListResponse |
Resposta paginada do endpoint de listagem de processamentos. |
processamento |
ProcessamentoNotificador |
Formata e dispara as notificações Telegram do pipeline de processamento. |
processamento |
ProcessamentoProcessador |
Execução assíncrona do processamento (fora do ProcessamentoService para o @Async ser aplicado via proxy). |
processamento |
ProcessamentoRepository |
Persistência JDBC de ProcessamentoXls e atualizações de status. |
processamento |
ProcessamentoResponse |
Resposta do recebimento síncrono (o controller HTTP mapeia httpStatus para o status da resposta). |
processamento |
ProcessamentoResumoDto |
Linha da lista de processamentos (JTE e GET /api/transporte/processamentos). |
processamento |
ProcessamentoService |
Orquestra recepção de arquivos, deduplicação por checksum e agendamento do processamento assíncrono. |
processamento |
ProcessamentoStatus |
Estados de um processamento de XLS (xls_processamento.status). |
processamento |
ProcessamentoTransactionalHelper |
Operações transacionais sobre os dados do BI por período (evita problemas de proxy com @Async). |
processamento |
ProcessamentoXls |
Entidade da tabela xls_processamento: metadados do envio, referência ao objeto XLS no MinIO e resultado do job. |
processamento |
StorageBackfillService |
Backfill dos blobs legados (arquivo_bytes) para o object storage Garage, dirigido pelo ArmazenamentoModo configurado em app.storage.modo: PSQL — não faz nada (tudo permanece no Postgres). |
processamento |
TransporteController |
API REST de transporte: envio de XLS (Bearer), listagem e consulta pública de processamentos. |
processamento |
UpsertResultado |
Métricas de uma operação UPSERT diff-aware + DELETE seletivo no caminho idempotente (spec 004 — § 4.7). |
processamento |
ViewController |
Rotas HTML (JTE) para histórico e detalhe de processamentos — acesso anônimo conforme configuração de segurança. |
processamento |
XlsController |
Porta única de envio de XLS — padrão da casa, idêntico entre todos os clientes. |
processamento |
ZipXlsExtrator |
Desempacota o .zip de lote em suas entradas .xlsx (o zip É o lote). |
processamento.processing |
ExcelProcessor |
Processa o byte[] do xlsx via FastExcel (StAX puro do JDK, sem XMLBeans/POI), em streaming. |
processamento.processing |
PeriodoRelatorio |
Período do relatório extraído da aba "10" (células E1/F1). |
processamento.processing |
ProcessamentoJsonExporter |
Serializa ResultadoProcessamento para JSON canônico conforme docs/dev/schema-processamento-xls.json. |
processamento.processing |
VarcharLimits |
Trunca strings ao tamanho máximo da coluna no PostgreSQL (evita erro no insert). |
processamento.processing |
VeiculoDadoFaltanteException |
Exception sintética usada pelo VeiculoDefesaCollector para envelopar um motivo+placa num evento Sentry (GlitchTip). |
processamento.processing |
VeiculoDefesaAvaliador |
Inspeciona uma linha do XLS mensal (faturamento ou movimento) e produz a lista de motivos de defesa que se aplicam ao cadastro veicular daquela linha. |
processamento.processing |
VeiculoDefesaCollector |
Coletor stateful per-processamento. |
PowerSync¶
Reset da replicação PowerSync — orquestra os steps que pausam a replicação, derrubam o estado no MongoDB do PowerSync, recriam o slot no PostgreSQL e sinalizam os clientes Dart a refazerem o bootstrap (disconnectAndClear).
| Pacote | Classe | O que faz |
|---|---|---|
powersync |
AdminController |
Endpoints administrativos: hoje, apenas o "reset" da replicação PowerSync. |
powersync |
AdminViewController |
Tela de administração JTE do reset da replicação PowerSync. |
powersync |
CoolifyClient |
Wrapper fino sobre HttpClient para a API REST do Coolify — apenas as chamadas que a estratégia coolify de PowerSyncProcessControl precisa. |
powersync |
MongoAdminService |
Operações administrativas no MongoDB do PowerSync. |
powersync |
MongoAdminServiceImpl |
Implementação real de MongoAdminService usando mongodb-driver-sync. |
powersync |
MongoAdminServiceStub |
Stub de MongoAdminService ativo quando powersync.mongodb.uri está vazio (default em dev). |
powersync |
PostgresReplicationAdmin |
Operações administrativas em slots de replicação lógica do PostgreSQL. |
powersync |
PowerSyncProcessControl |
Controla pause/resume do PowerSync Service durante o reset. |
powersync |
PowerSyncProcessControlCoolify |
Estratégia coolify de PowerSyncProcessControl: reinicia o recurso PowerSync pela API REST do Coolify ao final do reset. |
powersync |
PowerSyncProcessControlNone |
Implementação no-op de PowerSyncProcessControl: loga warning e reporta no progresso que o restart do PowerSync é manual. |
powersync |
ResetStatus |
Estados de uma execução de reset da replicação (xls_resetar_powersync.status). |
powersync |
ResetarPowersync |
Auditoria de execuções do "reset" da replicação PowerSync. |
powersync |
ResetarPowersyncAlreadyInProgressException |
Sinal do ResetarPowersyncRepository#iniciar de que já existe um reset EM_ANDAMENTO (violação do partial unique index). |
powersync |
ResetarPowersyncDetalheView |
View-model do reset para a tela de detalhe (admin/resetar-powersync-detalhe) — projeção dos campos que o template exibe. |
powersync |
ResetarPowersyncNotificador |
Formata e dispara as notificações Telegram do reset da replicação. |
powersync |
ResetarPowersyncRecovery |
Instruções de recuperação manual por step do reset — espelha a matriz de recovery do runbook (docs/resetar-powersync.md). |
powersync |
ResetarPowersyncRepository |
Persistência da auditoria de resets em xls_resetar_powersync. |
powersync |
ResetarPowersyncRequest |
Body opcional do POST /api/transporte/admin/resetar-powersync. |
powersync |
ResetarPowersyncResponse |
Resposta 202 do POST /api/transporte/admin/resetar-powersync. |
powersync |
ResetarPowersyncResumo |
View-model do reset para a listagem da tela admin — projeção dos campos que o template admin/powersync exibe. |
powersync |
ResetarPowersyncService |
Orquestra os 9 steps do reset da replicação PowerSync. |
powersync |
ResetarPowersyncStatusResponse |
Resposta 200 do GET /api/transporte/admin/resetar-powersync/{id}. |
powersync |
StartupRecoveryService |
Recuperação ao subir a aplicação: Processamentos órfãos em PROCESSANDO viram ERRO; Processamentos em PENDENTE são re-enfileirados; Resets órfãos em EM_ANDAMENTO (pool reset- morreu junto com a JVM) viram ERRO — desbloqueia o partial unique index pra novos resets. |
Configuração¶
Configuração global de runtime do app, editável pelo admin e persistida em banco — distinta da configuração estática de deploy (application.yml/env).
| Pacote | Classe | O que faz |
|---|---|---|
configuracao |
BiConfiguracao |
Configuração global do app em formato chave/valor. |
configuracao |
BiConfiguracaoRepository |
Persistência de configurações globais em bi_configuracao (V12). |
configuracao |
BiConfiguracaoView |
View-model de configuração para a tela admin/configuracoes — projeção dos campos que o template exibe. |
configuracao |
ConfigService |
Leitura de configurações globais (bi_configuracao) em runtime, com cache curto. |
configuracao |
ConfiguracaoAdminService |
Operações da tela admin de configurações (/admin/configuracoes). |
configuracao |
ConfiguracaoAdminViewController |
} (exige sessão; gate de email @xadm.com.br no login). |
Segurança¶
Camada de segurança específica do app (decisão 0011 / etapa 07): a policy de proteção das views e o fluxo de login Google.
| Pacote | Classe | O que faz |
|---|---|---|
seguranca |
AuthExceptionHandler |
Rede de segurança para AuthException que escape sem tratamento inline (o LoginController traduz as suas próprias no fluxo normal). |
seguranca |
ViewRejectionHandler |
Tradução das rejeições do micronaut-security (5.x lança AuthorizationException) preservando o comportamento do antigo AuthFilter: autenticado sem permissão (AuthorizationException#isForbidden()) → 403; view anônima, browser (Accept: text/html) → 302 /login?from=…; view anônima, script (JSON) → 401; view em fail-closed (prod sem envs de auth) → 503; path whitelisted (ex.: /api sem Bearer) → 401. |
seguranca |
ViewSecurityRule |
Regra de segurança das views, replicando o tri-estado do antigo AuthFilter: enabled (envs de auth completas): path de view exige Authentication — autenticado → ALLOWED, anônimo → REJECTED (o ViewRejectionHandler faz 302/401); bypass (dev/test sem envs): view liberada (ALLOWED) — views públicas; fail-closed (prod sem envs): view rejeitada (REJECTED) — o handler responde 503. |
seguranca |
ViewWhitelist |
Whitelist da CAMADA DE VIEWS — os paths que a ViewSecurityRule dispensa de sessão. |
Comum — base transversal¶
Base transversal — não é feature e, por trava de arquitetura (decisão 0022), não depende de nenhuma.
| Pacote | Classe | O que faz |
|---|---|---|
comum.config |
AppConfig |
Configuração de nível app.* compartilhada entre beans — prefixo app. |
comum.config |
CoolifyConfig |
Configuração do restart automático do PowerSync via API do Coolify — prefixo powersync.admin.coolify.*. |
comum.config |
LogsConfig |
Configuração do diretório de logs de execução — prefixo app.logs.*. |
comum.config |
PowersyncMongoConfig |
Configuração do MongoDB do PowerSync usada no reset da replicação — prefixo powersync.mongodb.*. |
comum.config |
PowersyncNukeConfig |
Configuração da confirmação e do bootstrap do reset destrutivo — prefixo powersync.nuke.* (contrato preservado do rename Nuke→ResetarPowersync; lido por integrações). |
comum.config |
PowersyncPostgresConfig |
Configuração da replicação Postgres do PowerSync usada no reset — prefixo powersync.postgres.*. |
comum.config |
TelegramConfig |
Configuração do bot Telegram — prefixo telegram.*. |
comum.exception |
StorageIndisponivelException |
Storage de arquivos (Garage) indisponível ao receber o XLS — HTTP 503. |
comum.notificacao |
TelegramApi |
Cliente HTTP declarativo da Telegram Bot API — só o sendMessage que o TelegramService usa. |
comum.notificacao |
TelegramService |
Transporte de notificações via Telegram Bot API: recebe um texto já pronto e o posta. |
comum.util |
AnosVeiculoParser |
Parseia o texto de ano do Excel no formato AAAA/AAAA (ex.: 2007/2007): à esquerda da barra = ano de construção; à direita = ano do modelo. |
Raiz¶
Raiz do bi-transporte-xls: serviço Micronaut que recebe a planilha XLS de transporte, processa as abas em registros bi_/xls_ no PostgreSQL e expõe o resultado por API REST e views server-rendered, sincronizando para clientes Dart via PowerSync.
| Pacote | Classe | O que faz |
|---|---|---|
transporte |
Application |
Ponto de entrada da aplicação Micronaut e definição global OpenAPI (Bearer). |
transporte |
BearerTokenEnv |
— |
Como rodar¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-15
Audiência: dev que quer subir o app na própria máquina. Build, testes e fixtures estão em Como buildar e testar; a organização do código, no Guia do código.
Requisitos¶
- JDK 25 no
JAVA_HOME(GraalVM 25; no WSL, via SDKMAN). O Gradle também roda em Java 25: o pluginio.micronaut.application5 exige. - Docker em execução: o Postgres de dev sobe num container.
Rodar em dev¶
./gradlew run
O run sobe o Postgres do docker-compose.dev.yml, espera ficar saudável, ativa o environment
dev e roda o app contra ele com os defaults de dev abaixo. Variável já definida no ambiente vence
o default.
| Variável | Default de dev | Efeito |
|---|---|---|
MICRONAUT_ENVIRONMENTS |
dev |
liga o bypass de auth e de CSRF das views |
DB_USER / DB_PASSWORD |
vantroba_transporte / dev123 |
casam com o docker-compose.dev.yml |
S3_FILE_STORAGE |
PSQL |
binário do .xlsx no Postgres — dispensa o Garage |
VANTROBA_XLS_API_TOKEN |
dev-token-apenas-local |
Bearer do /api/** |
TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID |
vazios | notificação desligada, sem rede |
APP_BASE_URL |
http://localhost:8080 |
base dos links nas notificações |
O app responde em http://localhost:8080: /health, /processamentos e /admin. O histórico por
processamento vai para ./data/execucoes, fora do git.
Variações¶
- Parar o banco (preserva os dados):
./gradlew pararInfraDev. Apagar de vez:docker compose -f docker-compose.dev.yml down. - Usar o seu próprio Postgres: defina
DATASOURCES_DEFAULT_URL(eDATASOURCES_DEFAULT_USERNAME/DATASOURCES_DEFAULT_PASSWORD); a subida do container é pulada. - Tudo em container (app incluso, sem
gradlew):docker compose -f docker-compose.dev.yml upsobe Postgres, Garage e o app peloDockerfile. - Binário native na máquina (GraalVM 25):
./gradlew nativeCompile -PnativeQuickbuilda com-Ob, mais rápido; o perfil de produção é o doDockerfile.native.
As envs de produção e a operação no Coolify estão no runbook de Deploy.
Como buildar e testar¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-15
Audiência: dev que precisa buildar e testar localmente. Subir o app em dev está em Como rodar.
Requisitos¶
- Java 25, preferencialmente GraalVM, disponível no
JAVA_HOME(toolchain pinada nobuild.gradle.kts:JavaLanguageVersion.of(25); instale via SDKMAN). - Docker em execução — os testes de integração (Testcontainers) sobem Postgres/Mongo/Garage reais.
- Build tool: Gradle (
./gradlew) — nunca Maven. - Lib da casa em versão ainda não publicada vem do
~/.m2: publique-a nomavenLocala partir doxadm-commonsantes de buildar (o bloco dobuild.gradle.ktssó aceitabr.com.xadmdali, depois do registro). Com o registro fora do ar,./gradlew --offlineresolve pelo cache e pelo~/.m2.
Recomendações de ambiente¶
- VS Code para editar (extensões de Java/Gradle ajudam).
- Claude Code — como plugin no VS Code ou a CLI no terminal; as regras
de IA do repo estão no
CLAUDE.md. - Remote do Forgejo (
fonte.xadm.biz/xadm/vantroba-bi-xls) configurado, com credencial — é o git de origem. O CI roda no espelho do GitHub (ver Deploy). - SDKMAN para instalar/alternar JDKs (a GraalVM 25 do projeto).
Buildar¶
./gradlew shadowJar # app.jar local (fallback JVM)
docker build -f Dockerfile.native -t vantroba-xls:native . # o mesmo build do job build_native do CI
./gradlew check # compila + checkstyle + todos os testes (integração inclusa — precisa de Docker)
O deploy não usa artefato buildado aqui nem builda no Coolify: a imagem native
é buildada no runner do GitHub Actions e o Coolify só puxa a imagem pronta — ver
Deploy. O Dockerfile JVM fica como fallback: o
build.targets do docs/app.json só tem native.
As 4 camadas de teste¶
| Camada | O que cobre | Como roda |
|---|---|---|
| Unitário | lógica pura (parsers, conversores, limites) | ./gradlew test |
| Conformidade golden (Java×Python) | paridade do parse Java com o resultado do fluxo Python legado | ./gradlew test — compara contra fixtures golden |
| Integração (Testcontainers) | repositórios, UPSERT diff-aware, storage Garage, reset — contra Postgres/Mongo/Garage reais | ./gradlew test (@IntegracaoDocker — precisa de Docker) |
| Arquitetura (ArchUnit) | sem ciclos entre as features de topo; comum não depende de feature; processamento.processing não depende da camada HTTP (0022) |
./gradlew test |
./gradlew test # tudo: unitário + conformidade + ArchUnit + integração
./gradlew check # o mesmo + checkstyle — é o gate do CI
Os testes de integração (@IntegracaoDocker da xadm-comum-teste, que marca a tag
docker e liga a DockerDisponivelCondition da lib) rodam por padrão; a flag
-PdockerTests não existe mais. Sem Docker, eles são pulados — com
DOCKER AUSENTE — teste de integração PULADO em ERROR no log e o teste marcado
skipped no relatório. Um BUILD SUCCESSFUL sem Docker não prova a
integração; o CI roda com Docker.
O Postgres de teste é o singleton da xadm-comum-teste (um container por JVM). O
AbstractRepositoryIntegrationTest limpa só as tabelas que os testes gravam,
porque a V10 semeia o cadastro de veículos, que a limpeza da lib apagaria.
Travas: a task test tem teto de 15 min, cada teste tem 2 min
(src/test/resources/junit-platform.properties, desligado sob debug) e o pool de
teste espera conexão por 5 s (application-test.yml). Travamento vira falha com
causa, não CI pendurado.
Fixtures de conformidade¶
Em src/test/resources/fixtures/transporte-YYYYMMDD/:
input.xlsx— a planilha do período (derivada de uma planilha real, anonimizada).resultado-python.json— o golden (saída do fluxo Python) a bater.
Uma fixture por período; o teste de conformidade roda a saída Java contra o golden.
Fixture nova nasce anonimizada
As planilhas de origem são reais (dado de cliente). Antes de commitar, rode
python scripts/anonimizar-fixtures.py src/test/resources/fixtures/transporte-YYYYMMDD:
ele troca nome de motorista/gestor, CPF e o CNPJ da empresa por fakes
determinísticos, em lockstep no input.xlsx e no resultado-python.json
(mesma substituição nos dois, preservando shape e resultado — a conformidade
segue verde). Permanecem reais (decisão explícita, REGRA Nº 3): placas,
municípios, segmentos e os valores numéricos — não são dado pessoal e são
computados pelo parser, então trocá-los exigiria regenerar o oracle Python.
A receita completa está no javadoc do ProcessamentoConformidadeTest.
Paridade não cobre os 5 campos de frota (ainda)
O teste de conformidade remove os 5 campos de cadastro de veículo/frota
(tipo de frota, marca, modelo, ano de construção, ano de modelo) antes de
comparar — o exportar-json.py do fluxo Python ainda não exporta esses
campos. Ou seja: a paridade Java×Python cobre tudo menos esses 5 campos,
até o script Python ser alinhado. Os campos em si são testados pelos testes
de cadastro de frota (ver etapa 04).
Fixture sintética de teste unitário sai do XlsxFixtureWriter (FastExcel-writer):
texto, número e data numérica com formato (data(linha, coluna, LocalDate)), que o
ExcelProcessor lê como data porque abre o workbook com ReadingOptions(true, false).
Contrato do arquivo¶
O layout das abas e o resultado canônico estão em
schema-processamento-xls.json.
API REST — contrato de integração¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-10
Referência da API (OpenAPI/Swagger) → · API Reference (Javadoc) →
Audiência: quem integra um sistema cliente (ERP, job agendado, script) com o processador. Não há tela de upload — o envio da planilha é só por esta API.
O contrato do arquivo (layout das 3 abas — colunas, índices, tipos) e do
resultado canônico do processamento está em
schema-processamento-xls.json (JSON Schema).
Como enviar a planilha¶
POST /api/xls/processar — multipart/form-data, autenticado por Bearer.
Campo único obrigatório: arquivo — um .zip contendo 1..N .xlsx do ciclo
(o zip é o lote). O cliente não envia mais data/hora: o período de cada
planilha é lido das próprias abas (decisão
0016, que superou a
0004). O servidor gera o id do
lote (zip- + os 60 primeiros hex do SHA-256 do .zip, idempotente), agrupa as
entradas e manda um resumo único no Telegram
quando todas terminam.
BASE="https://excel.vantroba.xadm.biz"
TOKEN="<mesmo valor de VANTROBA_XLS_API_TOKEN no servidor>"
# Monte o lote: zipe os .xlsx do ciclo num único .zip (o -j descarta caminhos).
zip -j ciclo.zip ./saida/*.xlsx
curl -sS -X POST "$BASE/api/xls/processar" \
-H "Authorization: Bearer $TOKEN" \
-F "arquivo=@./ciclo.zip;type=application/zip"
No Windows (curl.exe, PowerShell/cmd): mesma chamada, trocando a quebra de
linha \ por ^ (cmd) ou ` (PowerShell), e usando aspas duplas no campo
-F. Pra montar o zip: Compress-Archive -Path .\saida\*.xlsx -DestinationPath .\ciclo.zip.
Python (requests) — monta o .zip em memória e envia num passo só:
import glob, io, zipfile, requests
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
for caminho in glob.glob("saida/*.xlsx"):
z.write(caminho, arcname=caminho.split("/")[-1]) # só o nome, sem o path
buf.seek(0)
r = requests.post(
f"{BASE}/api/xls/processar",
headers={"Authorization": f"Bearer {TOKEN}"},
files={"arquivo": ("ciclo.zip", buf, "application/zip")},
timeout=120)
r.raise_for_status(); print(r.json()) # {conjuntoId, total, itens:[...]}
Respostas do POST¶
O 202 traz o recibo do lote — conjuntoId (zip-<60 hex do sha256>), total de
.xlsx e itens[] com o status por arquivo:
| HTTP (lote) | Situação | Corpo |
|---|---|---|
202 |
Lote recebido; processamento agendado por arquivo | conjuntoId, total, itens[] |
400 |
Zip inválido, vazio ou sem .xlsx |
erro RFC 7807 |
401 |
Bearer ausente ou inválido | — |
Cada entrada de itens[] tem nome, id (nulo quando não processado),
httpStatus e mensagem. O httpStatus por arquivo preserva a semântica de
idempotência que antes vinha no POST de um XLS avulso:
httpStatus do item |
Significado |
|---|---|
202 |
Novo processamento enfileirado (assíncrono) |
200 |
Mesmo arquivo já processado com sucesso (dedupe por checksum) |
409 |
Mesmo checksum já em fila / processando |
400 |
Entrada não aceita (não é .xlsx, sem uma das 3 abas 10/20/30, aba 10 sem período em E1/F1) |
Todo erro de nível de lote (400/401, e também falhas de validação e rotas
inexistentes) sai no formato RFC 7807 (application/problem+json):
{"type": "about:blank", "title": "...", "status": 400, "detail": "...",
"code": "BAD_REQUEST"} — status é o código HTTP, title o resumo, detail a
mensagem específica, e code (extensão) é o código estável legível por máquina.
O 2xx mantém o corpo de negócio (conjuntoId/itens[]), nunca o code.
O processamento é assíncrono: para cada id de item, faça polling em
GET …/processamentos/{id} até SUCESSO/ERRO, ou acompanhe pelo resumo do
Telegram. (Falhas de processamento depois do aceite viram status ERRO, não
400 — ver o detalhe e os logs do processamento.)
Leitura (GET, sem Bearer)¶
Os GETs de leitura são anônimos neste projeto:
GET /api/transporte/processamentos?page=0&size=20&status=SUCESSO— lista paginada (JSON)GET /api/transporte/processamentos/{id}— detalhe (JSON)GET /api/transporte/processamentos/{id}/logs— log (text/plain)GET /api/transporte/processamentos/{id}/arquivo— XLS original (em qualquer modo de storage; noPSQLlê dearquivo_bytes)
Interface humana: GET /processamentos (HTML, protegida por login — ver
etapa 07).
Limites e boas práticas¶
- Respeitar
micronaut.server.multipart.max-file-size(50 MB por padrão). - HTTPS sempre em produção.
- Em
409, aguardar o término do processamento em andamento. - Swagger UI interativo:
https://<host>/swagger-ui(botão Authorize para o Bearer).
Público
BI Transporte — documentação pública¶
Esta seção é a parte aberta da documentação do processador de planilhas de transporte da Vantroba: o manual de quem usa o sistema e o contrato da API para quem integra.
Manual do usuário¶
API¶
- Referência da API (OpenAPI) — contrato interativo, gerado do código.
Manual
Como acompanhar os processamentos de planilha¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-12
Use esta tela para ver o que aconteceu com cada planilha Excel enviada ao sistema: se foi processada com sucesso, quanto tempo levou e quantas linhas entraram nos dashboards.
Antes de começar¶
- Acesse o sistema com seu login
@xadm.com.br. - No topo da tela, clique em Processamentos.
Passo a passo¶
-
A tela mostra a lista dos envios mais recentes, do mais novo para o mais antigo. Cada linha é uma planilha enviada.

-
Leia a coluna Status de cada envio:
- SUCESSO (verde): a planilha foi processada e os dados já estão nos dashboards.
- ERRO (vermelho): algo impediu o processamento — veja Quando der erro abaixo.
-
Para filtrar, escolha um status na caixa Status e clique em Filtrar (por exemplo, mostrar só os que deram ERRO).
-
Para ver os detalhes de um envio, clique no número dele (coluna ID). Abre a tela com o período da planilha, o tempo de processamento, quantas linhas foram inseridas/atualizadas/removidas e o registro completo da execução.

Quando der erro, faça isso¶
Status ERRO: abra o detalhe (clique no número do envio) e leia o registro de execução no rodapé — ele aponta o que falhou. Em geral é uma linha da planilha com um campo obrigatório em branco. Corrija a planilha e envie de novo; as linhas que já estavam corretas não são duplicadas.
Não encontro um envio que fiz: confira o filtro de Status — se estiver marcado um status específico, volte para Todos. Use também a paginação no rodapé da lista.
O envio fica muito tempo sem mudar de status: anote o número do envio e abra um chamado para o suporte.
Como editar as configurações globais¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-12
Use esta tela para ajustar parâmetros que valem para todo o sistema — por exemplo, limites usados pelo aplicativo de BI. Os valores alterados aqui passam a valer em poucos segundos, sem precisar reinstalar nada.
Antes de começar¶
- Acesse o sistema com seu login
@xadm.com.br. - No topo da tela, clique em Admin e depois em Gerir configurações (ou acesse direto Admin → Configurações).
- Mudança em produção tem efeito imediato e não tem desfazer automático: para reverter, é só editar de novo com o valor anterior.
Passo a passo¶
-
A tela lista cada configuração com seu nome, tipo, valor atual e uma descrição do que ela faz.

-
Encontre a configuração que quer mudar e clique em Editar na linha dela.
-
No painel que abre, digite o novo Valor e clique em Salvar.

-
Para criar uma configuração nova, clique em + Nova chave no canto da lista e preencha os campos.
Quando der erro, faça isso¶
O botão Editar está apagado (desabilitado): essa linha tem a marca sistema — é uma configuração mantida automaticamente pelo próprio sistema e não deve ser alterada à mão.
Salvei mas o sistema não mudou de comportamento: aguarde até 30 segundos — é o tempo que o sistema leva para reler as configurações. Se depois disso ainda não refletir, confira se você salvou o valor certo reabrindo a edição.
Coloquei um valor errado: edite a mesma configuração de novo e volte para o valor anterior — não existe desfazer automático.
Como resetar a replicação pela tela de Admin¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-12
Esta tela dispara o reset da replicação: ela limpa e recria o canal que leva os dados até os aplicativos. Use só quando o suporte orientar — depois do reset, todos os celulares fazem uma sincronização completa na próxima vez que abrirem o app.
Operação destrutiva
O reset afeta todos os usuários de celular. Faça em horário de baixo movimento e só com orientação do suporte. Os detalhes técnicos, o que acontece em cada etapa e como recuperar estão no runbook Resetar Powersync.
Antes de começar¶
- Acesse o sistema com seu login
@xadm.com.br. - No topo da tela, clique em Admin.
- Combine o horário com o suporte — de preferência fora do horário de pico.
Passo a passo¶
-
No painel de Administração, localize o cartão Resetar replicação PowerSync e clique no botão de mesmo nome.

-
Abre uma janela de confirmação. Preencha:
- Motivo — por que está fazendo o reset (ex.: orientação do suporte).
- Operador — seu e-mail.
- O campo de confirmação — digite exatamente a palavra RESETAR.

-
Confira tudo e clique em Resetar agora. Acompanhe o andamento na tela de detalhe que abre em seguida — ela mostra cada etapa até concluir.
-
O histórico de resets aparece no próprio painel de Admin, no cartão Histórico recente.
Quando der erro, faça isso¶
O botão "Resetar agora" não habilita: confira se você digitou a palavra RESETAR exatamente (maiúsculas, sem espaços) e se preencheu Motivo e Operador.
O reset parou no meio (uma etapa ficou em vermelho): não tente de novo às cegas — chame o suporte com o horário e a etapa que falhou. O passo a passo de recuperação está no runbook Resetar Powersync.
Depois do reset, um celular não atualiza: peça ao usuário para abrir o app conectado à internet e aguardar — a primeira sincronização após o reset é completa e pode demorar mais que o normal.
Referência da API (OpenAPI)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-15
Referência interativa gerada do próprio código (anotações @Operation/@Schema
nos controllers, via micronaut-openapi). É a fonte-máquina do contrato; a
narrativa — audiência, exemplos curl, layout das abas — vive no
Contrato da API REST.
Como este spec chega aqui
O openapi.yaml é gerado pelo CI de docs (./gradlew classes) e costurado no
portal no build da documentação, não no deploy do app. Para pré-visualizar
localmente: ./gradlew classes e copie
build/classes/java/main/META-INF/swagger/*.yml para
docs/public/api/openapi.yaml.
Etapas do Projeto
Etapa 01 — Processador XLS do BI Transporte¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-10
Etapa fundacional: define a base (recepção, parsing, persistência, status) que as etapas seguintes estendem. Idempotência → etapa 02; object storage → etapa 03; cadastro veicular → etapa 04.
1. Contexto e escopo¶
Substitui um fluxo manual baseado em script Python por um serviço Java
contínuo que recebe planilhas Excel de transporte da Vantroba, valida e
persiste faturamento e movimento de frota no PostgreSQL (bi_*), com
rastreabilidade por checksum SHA-256 e histórico acessível por API e web.
Dentro do escopo: recepção do .xlsx por API autenticada; parsing SAX das 3
abas fixas; gravação idempotente em bi_faturamento/bi_movimento; máquina de
status do processamento; notificação Telegram; recuperação no startup; telas de
listagem/detalhe.
Fora do escopo: edição dos dados pela web (a origem é a planilha); upload por
tela (o envio é só por API — ver dev/api-rest).
Integrações: PostgreSQL do BI (escrita); PowerSync (leitura pelos clientes móveis — ver etapa 02); Telegram (notificação); Garage (object storage do binário — etapa 03).
2. Contexto do sistema¶
flowchart TB
equipe[Equipe X-Adm]
cliente[Sistema cliente<br/>ERP / job]
subgraph app[bi-transporte-xls]
http[Micronaut<br/>HTTP + Views]
job[Processamento<br/>assíncrono]
end
pg[(PostgreSQL bi_*)]
tg[Telegram Bot API]
equipe -->|GET /processamentos| http
cliente -->|POST /api/xls/processar .zip + Bearer| http
http -->|agenda| job
job -->|JDBC / Flyway| pg
job -->|notificação| tg
http -->|consultas| pg
3. Arquitetura — blocos e dependências¶
Código package-by-feature (decisão
0022): cada feature carrega suas camadas
Controller → Service → Repository; o transversal vai em comum. Dentro da feature,
o controller só fala com service e a entidade não cruza o controller. O ArchUnit
guarda três invariantes: sem ciclos entre features, comum não depende de feature,
processamento.processing não depende da camada HTTP.
| Pacote | Papel |
|---|---|
processamento |
a feature deste app: controllers REST + views, services (recebimento, async, Telegram, recovery, storage), repositórios JDBC, entidades bi_*/xls_*; inclui processamento.processing — leitura Excel SAX (POI streaming; superado pela decisão 0026 — hoje FastExcel) e mapeamento de colunas |
powersync |
reset do PowerSync e o sinal de reset (etapa 05) |
configuracao |
configuração global runtime (admin) |
seguranca |
Bearer estático (/api/**) + login das views (etapa 07) |
comum |
base transversal: comum.config (typed config), comum.exception (só StorageIndisponivelException), comum.util, comum.notificacao (Telegram). A infra web (RFC 7807, exceções HTTP, health, Sentry, versão) vem da lib xadm-comum-web (decisão 0024) |
4. Modelo de dados¶
A tabela de controle do pipeline é xls_processamento (prefixo xls_*,
não sincroniza via PowerSync). Os fatos bi_faturamento/bi_movimento são
criados aqui (colunas-base, aba 20/30); a chave natural/idempotência é tema da
etapa 02 e as colunas veiculares da
etapa 04.
erDiagram
xls_processamento ||--o{ bi_faturamento : "produz no período"
xls_processamento ||--o{ bi_movimento : "produz no período"
xls_processamento {
bigserial id PK
varchar checksum_sha256 UK "SHA-256 do binário; dedup de upload"
date data_referencia
time hora_referencia
date periodo_inicial
date periodo_final
varchar status "PENDENTE|PROCESSANDO|SUCESSO|ERRO"
timestamp recebido_em
timestamp iniciado_em
timestamp concluido_em
bigint tempo_ms
text erro_mensagem
}
xls_processamento — dicionário (grupos de colunas):
- Identidade/recepção:
id(BIGSERIAL PK);checksum_sha256(VARCHAR(64), NOT NULL, UNIQUEuq_xls_processamento_checksum— identidade do upload);nome_arquivo(VARCHAR(255));recebido_em(TIMESTAMP, default NOW());data_referencia(DATE),hora_referencia(TIME) — legado: eram o período informado no upload (decisão 0004), não mais populados desde o envio por lote.zipsemdata/hora(decisão 0016) — ficam nulos em uploads novos;periodo_inicial/periodo_final(DATE) — período real, lidos da aba 10. - Status/execução:
status(VARCHAR(20), default'PENDENTE');iniciado_em,concluido_em(TIMESTAMP);tempo_ms(BIGINT);erro_mensagem(TEXT, truncado a ~2000 chars);log_arquivo(VARCHAR(500)). - Contagens:
registros_faturamento,registros_movimento(INTEGER). - Object storage (etapa 03):
objeto_bucket(VARCHAR(63)),objeto_chave(VARCHAR(512)),objeto_tamanho_bytes(BIGINT);arquivo_bytes(BYTEA — binário no Postgres, modosPSQL/PSQL_GARAGE). - Métricas de idempotência (etapa 02):
8 colunas
faturamento_*/movimento_*(inseridas/atualizadas/inalteradas/removidas). - Defesa veicular (etapa 04): 5 colunas
defesa_*_qtd+xls_legado(BOOLEAN).
bi_faturamento (aba 20) e bi_movimento (aba 30) recebem as colunas-base em
V1__criar_tabelas.sql, em snake_case, espelhando o ERP (todas nullable, PK
id BIGSERIAL). Os índices de consulta (dt_frete, dt_em, placa, filial,
cod_proj, data_lcto, cod_conta) são criados em V2__criar_indices.sql. O
detalhe coluna-a-coluna e a chave natural ficam na etapa 02
(é lá que o schema vira contrato de UPSERT).
5. Contratos¶
5.1 API REST¶
A interface de entrada e leitura é a API REST — contrato completo
(request/response/status/exemplos) em dev/api-rest e
referência OpenAPI. Resumo:
| Método/rota | Auth | Resultado |
|---|---|---|
POST /api/xls/processar |
Bearer (ROLE_API) |
202 lote recebido (conjuntoId/total/itens[], status por arquivo) · 400 zip inválido/vazio/sem .xlsx |
GET /api/transporte/processamentos |
anônimo | lista paginada (page/size/status) |
GET /api/transporte/processamentos/{id} |
anônimo | detalhe |
GET …/{id}/logs · …/{id}/arquivo |
anônimo | log (text/plain) · XLS original |
5.2 Formato do arquivo (contrato com o ERP)¶
.xlsx validado por magic bytes ZIP (50 4B 03 04). Lidas só as abas
nomeadas exatamente "10", "20", "30" (leitura SAX por linha; índices de
coluna 0-based, A=0). Mapeamento mantido em processing/ExcelProcessor:
- Aba 10 (período/config): linha 0, coluna E (4) = data inicial, F (5)
= data final, formato
dd/MM/yyyy. Ausência → erro"Aba 10 ausente"; vazio →"Período incompleto nas células E1/F1". - Aba 20 (faturamento →
bi_faturamento): colunas 1–28 mapeiam os campos do ERP; linha descartada se col 4 (dt_frete) ou col 17 (valor) nulas. Colunas 33–36 (veicular) → etapa 04. - Aba 30 (movimento →
bi_movimento): colunas 1–26; linha descartada se col 16 (data_lcto) ou col 15 (valor) nulas. Colunas 27–30 (veicular) → etapa 04.
O mapeamento índice→coluna completo das abas 20/30 é o contrato de schema; vive versionado no
ExcelProcessore nas fixtures golden (decisão 0003). Mudança de layout pelo cliente = nova fixture + ajuste no parser, no mesmo PR.
6. Fluxos e estados¶
Pipeline assíncrono: o cliente recebe 202 Accepted na hora; o processamento
pesado roda no executor nomeado processamento (pool fixo, 4 threads).
sequenceDiagram
actor C as Cliente
participant TC as XlsController
participant PS as ProcessamentoService
participant PP as Processador (@Async)
participant EX as ExcelProcessor (SAX)
participant R as Repositories
participant TG as TelegramService
C->>TC: POST /api/xls/processar (.zip, multipart + Bearer)
TC->>PS: receberZip(bytes)
PS->>PS: descompacta o .zip · por .xlsx: magic bytes + checksum SHA-256 + dedup
PS->>R: grava cada binário (Garage/Postgres) + linha PENDENTE
PS-->>TC: itens[] (id/httpStatus por arquivo) + conjuntoId
TC-->>C: 202 Accepted (conjuntoId, total, itens[])
Note over PP: assíncrono (pool "processamento")
PP->>EX: lê abas 10 / 20 / 30 (SAX)
EX->>R: UPSERT diff-aware + DELETE seletivo (etapa 02)
PP->>R: SUCESSO + métricas (ou ERRO + mensagem)
PP->>TG: notifica
Máquina de estados do status (string em xls_processamento, 4 valores):
stateDiagram-v2
[*] --> PENDENTE: receber (insert)
PENDENTE --> PROCESSANDO: job inicia (grava iniciado_em)
PROCESSANDO --> SUCESSO: grava concluido_em, tempo_ms, métricas
PROCESSANDO --> ERRO: catch → erro_mensagem
ERRO --> PENDENTE: reprocessar (reset dos campos de execução)
SUCESSO --> [*]
Guard de dupla execução: o job só processa se o status for PENDENTE (senão
ignora). StartupRecoveryService (app.recovery.enabled, default true) retoma
no boot jobs presos em PENDENTE/PROCESSANDO após restart.
7. Configuração¶
| Env | Property | Default | Controla |
|---|---|---|---|
PORT |
micronaut.server.port |
8080 |
porta HTTP |
| — | micronaut.server.multipart.max-file-size |
52428800 (50 MiB) |
tamanho máx. do upload |
| — | micronaut.executors.processamento |
4 threads | pool do processamento async |
API_BEARER_TOKEN |
app.api-token |
(obrigatória) | Bearer do /api/** |
APP_BASE_URL |
app.base-url |
https://excel.vantroba.xadm.biz |
base de links |
| — | app.logs.dir |
logs/execucoes |
log por processamento |
| — | app.recovery.enabled |
true |
recovery no startup |
TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID |
telegram.* |
vazio → desabilita | notificação |
Datasource (DB_USER/DB_PASSWORD/DATASOURCES_DEFAULT_URL) e storage/envs
das demais etapas: ver etapa 03 (Garage),
etapa 05 (PowerSync) e
etapa 07 (auth). Runbook completo de envs:
operacao/deploy.
8. Decisões¶
| Nº | Decisão |
|---|---|
| 0001 | Micronaut Data JDBC, sem Hibernate |
| 0002 | Processar XLS (POI streaming/SAX) |
| 0003 | Contrato canônico Python↔Java |
| 0004 | Período de referência vem do upload |
9. Qualidade e observabilidade¶
- Log por processamento em
logs/execucoes/processamento-{id}.log(exposto emGET …/{id}/logs). - Telegram notifica sucesso/erro com link para o detalhe.
- Bugsink/Sentry (
SENTRY_DSN) captura exceções; defesa de parsing reporta sem derrubar o lote. - Métricas finas por upload em
xls_processamento(etapa 02).
10. Riscos¶
- Planilha fora do layout → parsing tolerante por linha; o erro vira
status=ERRO - log, nunca derruba o serviço.
- Divergência parse Java × Python legado → contrato canônico + fixtures golden (decisão 0003).
- Vazamento do
API_BEARER_TOKEN→ secrets no Coolify, HTTPS obrigatório. Rotação: gerar novo (openssl rand -hex 32), atualizar no Coolify + redeploy, avisar integradores. Não há janela de dois tokens — troca atômica.
Etapa 02 — Idempotência: UPSERT diff-aware¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-15
Estende a base da etapa 01: aqui o schema dos fatos vira contrato de UPSERT (chave natural + métricas). A preservação das colunas veiculares é a etapa 04.
1. Contexto e escopo¶
Os fatos (bi_faturamento, bi_movimento) sincronizam para clientes móveis via
PowerSync. Reprocessar um período (reupload do mesmo mês) não pode gerar churn
de checkpoint: cada linha tocada no Postgres re-propaga para os celulares,
gastando banda e bateria mesmo sem mudança real.
Dentro do escopo: chaves naturais por fato; pipeline diff-aware
(COPY → TEMP TABLE → UPSERT → DELETE seletivo); métricas finas por upload;
preservação de id/xmin nas linhas inalteradas.
Fora do escopo: preservação das colunas de cadastro veicular (regra especial) → etapa 04.
2. Modelo de dados¶
A identidade de cada fato é uma UNIQUE composta NULLS NOT DISTINCT sobre as
colunas do ERP que o definem — a chave natural (criadas em
V6__chaves_naturais.sql). As PKs são BIGSERIAL (decisão 0005); xmin (system
column do Postgres) é o que o PowerSync observa para propagar mudanças.
erDiagram
bi_faturamento {
bigserial id PK
date dt_frete "idx"
varchar placa "idx"
decimal valor
}
bi_movimento {
bigserial id PK
date data_lcto "idx"
decimal valor
smallint seq_dentro_grupo "desambiguador (V6)"
}
Chave natural — bi_faturamento (uq_bi_faturamento_natural, 10 colunas):
nro_frete, dt_frete, nro_docto, dt_em, placa, segmento, seq_proj, cod_proj,
espec_veic, cod_mot. A V6 fez dedup destrutivo prévio mantendo MIN(id) por
grupo (duplicatas no faturamento eram ruído).
Chave natural — bi_movimento (uq_bi_movimento_natural, 17 colunas): as 16
contábeis (classif_ctb, ident_ger, ident_custo, atrib_custo, ident_rat, db_cr,
placa, segmento, seq_proj, cod_proj, filial, data_lcto, espec_veic, cod_conta,
classif_conta, valor) + seq_dentro_grupo (SMALLINT, NOT NULL, backfill
ROW_NUMBER em V6). O desambiguador existe porque colisões nas 16 colunas são
lançamentos legítimos (mesmo custo repetido no período), não duplicatas — por
isso aqui não houve dedup destrutivo.
Métricas por upload (8 colunas em xls_processamento, V7): para cada fato,
*_inseridas (INSERT puro, xmax=0), *_atualizadas (UPDATE in-place, xmax≠0),
*_inalteradas (enviadas − inseridas − atualizadas), *_removidas (DELETE
seletivo). NULL = upload anterior à spec de métricas.
3. Fluxos principais¶
Pipeline diff-aware na ordem UPSERT → DELETE, por período, dentro de uma
transação (decisão 0007 — COPY + TEMP TABLE no BulkUpserter):
flowchart TD
A[Lote do upload] --> B[COPY → TEMP TABLE]
B --> C["INSERT ... ON CONFLICT (chave natural)<br/>DO UPDATE WHERE ... IS DISTINCT FROM"]
C --> D{Linha mudou?}
D -- sim --> E[UPDATE: novo xmin → PowerSync propaga]
D -- não --> F[no-op: id e xmin preservados]
C --> G["DELETE seletivo: linhas do período<br/>ausentes no upload (usa idx data)"]
- O
WHERE ... IS DISTINCT FROMgarante que só linhas realmente alteradas trocam dexmin— as inalteradas não re-propagam (zero churn no reupload). - O DELETE seletivo remove só o que sumiu dentro do período do upload (apoiado no índice por data), nunca o período inteiro nem dados de outros meses.
- O
ProcessamentoTransactionalHelper.mesclarDadosDoPeriodoorquestra UPSERT + DELETE e devolve oMesclaResumocom as 8 métricas, gravadas emxls_processamentono sucesso.
4. Decisões¶
| Nº | Decisão |
|---|---|
| 0005 | BIGSERIAL como PK nas bi_* |
| 0006 | Chaves naturais + UPSERT diff-aware |
| 0007 | COPY + TEMP TABLE no BulkUpserter |
| 0016 | Período do DELETE vem da aba 10 |
5. Qualidade e observabilidade¶
- Testes
BulkUpserterFaturamentoTest/BulkUpserterMovimentoTest: reupload sem mudança não bumpaxmin(regressão de churn). - As 8 métricas por upload (visíveis no detalhe e na API) tornam o efeito de cada processamento auditável: quantas linhas de fato mudaram.
6. Riscos¶
- Chave natural mal definida → colisão de linhas legítimas; mitigado pelo
seq_dentro_grupoembi_movimento. - UPSERT cego (sem
IS DISTINCT FROM) reintroduziria churn → coberto pelos testes de regressão acima. - GRANT faltando ao
powersync_rolenumabi_*nova → replicação para (42501); ver decisão 0013 e o gotcha no CLAUDE.md.
Etapa 03 — Object storage do XLS recebido (Garage)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-15
Estende a recepção da etapa 01: onde o binário do
.xlsxé gravado e lido.
1. Contexto e escopo¶
O binário do .xlsx recebido era guardado em BYTEA no Postgres, o que infla o
banco com o volume de uploads. O objetivo é mover o binário para object storage
S3-compatível (Garage) sem perder a rede de segurança durante a transição.
Dentro do escopo: seam de storage; 3 modos de operação; chave content-addressed; backfill no startup; tratamento de falha de I/O no upload.
Fora do escopo: CDN/cache de leitura; versionamento de objeto.
2. Componentes e modelo de dados¶
flowchart TB
rec[receber upload] --> coord[ArmazenamentoArquivoService]
coord -->|seam ArquivoXlsStorage| s3[S3ArquivoXlsStorage<br/>AWS SDK v2]
s3 --> garage[(Garage bucket: vantroba)]
coord -.->|modo PSQL / PSQL_GARAGE| bytea[(xls_processamento.arquivo_bytes)]
Único ponto que conhece o modo: service/ArmazenamentoArquivoService. As
referências do objeto vivem em xls_processamento (V8): objeto_bucket
(VARCHAR(63)), objeto_chave (VARCHAR(512)), objeto_tamanho_bytes (BIGINT); o
binário no Postgres fica em arquivo_bytes (BYTEA, V1). Chave content-addressed
{aaaa}/{mm}/{checksum_sha256}.xlsx, particionada pela data de referência do
upload (cliente AWS SDK v2 com forcePathStyle(true) — obrigatório para o Garage).
3. Fluxos e estados¶
app.storage.modo / env S3_FILE_STORAGE (sempre em MAIÚSCULAS; enum
ArmazenamentoModo):
| Modo | Grava | Lê (processamento) | Backfill no startup |
|---|---|---|---|
PSQL |
só Postgres (arquivo_bytes) |
Postgres | no-op |
PSQL_GARAGE (default) |
Postgres e Garage | Garage | copia legados p/ Garage, mantém arquivo_bytes |
GARAGE |
só Garage | Garage | migra legados e zera arquivo_bytes |
- Leitura com fallback cruzado: lê da fonte preferida do modo e recorre ao outro backend se vazia — cobre registros em transição de modo.
- Falha de I/O no upload →
StorageIndisponivelException→ HTTP 503 (sem linha persistida; o cliente reenvia). - Backfill (
StorageBackfillService,@Order(-100), roda antes do recovery): idempotente, isola falha por linha, re-enfileiraPENDENTEmigrado. - A coluna
arquivo_bytesnão é dropada — o "cleanup" do modoGARAGEé zerar em runtime (reversível: voltar paraPSQL_GARAGEre-popula no backfill).
4. Configuração¶
| Env | Property | Default |
|---|---|---|
S3_FILE_STORAGE |
app.storage.modo |
PSQL_GARAGE |
GARAGE_ENDPOINT |
garage.endpoint |
http://localhost:3900 |
GARAGE_REGION |
garage.region |
garage |
GARAGE_ACCESS_KEY / GARAGE_SECRET_KEY |
garage.access-key/garage.secret-key |
dev keys |
GARAGE_BUCKET |
garage.bucket |
vantroba |
GARAGE_CRIAR_BUCKET |
garage.criar-bucket |
false |
Prefixo migrado
s3.*→garage.*na adoção do xadm-comum-storage 0.2.0 (oS3Configda lib passou a@ConfigurationProperties("garage")). Envs antigasS3_*seguem via fallback${GARAGE_X:${S3_X:default}}.S3_FILE_STORAGE/app.storage.modoinalterado.
Em testes que não exercitam storage, fixar app.storage.modo=PSQL evita depender
do Garage. Setup do cluster Garage (init manual): operacao/deploy.
5. Decisões¶
| Nº | Decisão |
|---|---|
| 0008 | Object storage Garage com 3 modos |
6. Riscos¶
- Garage indisponível no modo
GARAGE→ uploads recusados (503) até voltar; o modoPSQL_GARAGEmitiga durante a transição. - Migração irreversível por engano → mitigado:
arquivo_bytesnão é dropada; o modoGARAGEsó zera em runtime.
Etapa 04 — Cadastro de veículo e frota¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-15
Estende o parsing da etapa 01 e o UPSERT diff-aware da etapa 02 com as colunas veiculares e uma regra de preservação específica.
1. Contexto e escopo¶
As planilhas RESULTADO_TRANSPORTE_* recentes trazem, além dos campos históricos,
frota (própria vs terceiros) e identificação do caminhão (marca, modelo,
anos). O processamento mapeia essas colunas e as persiste preservando correções
manuais de cadastro feitas direto no banco.
Dentro do escopo: mapeamento das colunas novas; parse do ano AAAA/AAAA;
backfill do cadastro veicular nas linhas existentes; defesa contra entrada
inválida; preservação no reprocessamento.
Fora do escopo: uma entidade de veículo normalizada — hoje os dados veiculares
vivem desnormalizados nas colunas dos próprios fatos
bi_faturamento/bi_movimento, chaveados por placa (não há tabela mestre de
frota persistente).
2. Modelo de dados¶
Cinco colunas adicionadas em ambos os fatos (V3__bi_veiculo_frota.sql):
erDiagram
bi_faturamento {
varchar tipo_frota "aba 20 col 33"
varchar marca_veiculo "col 34"
varchar modelo_veiculo "col 35"
smallint ano_construcao "col 36, esq. da barra"
smallint ano_modelo "col 36, dir. da barra"
}
bi_movimento {
varchar tipo_frota "aba 30 col 27"
varchar marca_veiculo "col 28"
varchar modelo_veiculo "col 29"
smallint ano_construcao "col 30, esq."
smallint ano_modelo "col 30, dir."
}
Tipos: tipo_frota VARCHAR(20), marca_veiculo/modelo_veiculo VARCHAR(80),
ano_construcao/ano_modelo SMALLINT (todas nullable). Mapeamento 0-based:
aba 20 → 33/34/35/36; aba 30 → 27/28/29/30. O ano é uma string
AAAA/AAAA (ex.: 2006/2007): AnosVeiculoParser grava os 4 dígitos à esquerda
em ano_construcao e os à direita em ano_modelo; formato inválido → ambos null.
O backfill do V10 gravava um relatório de não-matched numa tabela
xls_veiculo_backfill_relatorio— one-time, lida por nada em runtime, removida em V15. O backfill em si (UPDATE nosbi_*) permanece.
Defesa veicular (6 colunas em xls_processamento, V11): defesa_marca_ausente_qtd,
defesa_modelo_ausente_qtd, defesa_ano_invalido_qtd, defesa_tipo_frota_ausente_qtd,
defesa_placa_malformada_qtd (INTEGER) + xls_legado (BOOLEAN).
3. Fluxos principais¶
- Mapeamento + defesa: ao ler cada linha das abas 20/30, o
ExcelProcessorregistra as ocorrências de marca/modelo/ano/tipo_frota ausentes e placa malformada nas contagensdefesa_*_qtd— sem bloquear o lote (entrada ruim vira métrica + evento no Bugsink, não erro). - Backfill (
V10__backfill_veiculos.sql): a partir de um mestre veicular (~3100 placas, planilha20260525-veiculos-final.xlsxcarregada numa TEMP TABLEON COMMIT DROP), faz UPDATE-join idempotente preenchendo as colunas veiculares nas linhas existentes debi_faturamento/bi_movimento, sem sobrescrever valores já gravados não-vazios. (A migration está congelada; o gerador one-shot que a produziu foi removido — ver CHANGELOG.) - Preservação (decisão 0012): as cinco colunas estão em
ColunasConteudo.PRESERVAR_SE_VAZIO_NA_ENTRADA. No UPSERT, entrada "vazia" (NULL/''/0/ placeholders'SEM MARCA'/'SEM MODELO') não sobrescreve o valor gravado, viaCOALESCE(NULLIF(EXCLUDED.col, <sentinela>), <tabela>.col)— e também não dispara UPDATE (sem churn dexmin/PowerSync, consistente com a etapa 02). Correções de cadastro feitas direto no banco sobrevivem a reprocessamentos; em troca, esses campos só são limpos por script no banco, nunca por upload.
4. Decisões¶
| Nº | Decisão |
|---|---|
| 0012 | Preservação de cadastro de veículo no UPSERT |
Relacionado: o mecanismo de UPSERT diff-aware é o da etapa 02.
5. Riscos¶
- Cliente muda o layout das colunas (índices) → o parse pega célula errada; mitigado por testes com fixtures reais por período e pelas métricas de defesa.
- Upload com colunas vazias sobrescrevendo cadastro bom → resolvido pela preservação (decisão 0012).
Etapa 05 — Resetar Powersync¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-15
Esta página é o desenho (porquê e como funciona). O procedimento operacional passo a passo vive no runbook
operacao/resetar-powersync. O sinal que os clientes leem está na etapa 06.
1. Contexto e escopo¶
Mudanças estruturais ou estados corrompidos exigem, de vez em quando, zerar o estado replicado (slot de replicação no Postgres + base do PowerSync no MongoDB) e forçar um re-sync limpo nos clientes móveis. É destrutivo e arriscado à mão — encapsulado num serviço auditável, com confirmação anti-acidente e sinal para os clientes reconectarem.
Dentro do escopo: o serviço de reset (9 steps); os dois gatilhos (REST + tela);
restart opcional do PowerSync via Coolify; audit por step em xls_resetar_powersync.
Fora do escopo: o consumo do sinal pelos clientes (chave ultimo_nuke) →
etapa 06.
2. Componentes e modelo de dados¶
flowchart TD
rest[POST /api/transporte/admin/resetar-powersync<br/>Bearer + X-Confirm-Nuke]
tela[POST /admin/resetar-powersync<br/>modal RESETAR + login Google]
rest --> svc[ResetarPowersyncService]
tela --> svc
svc --> pg[(Postgres: RESET_SLOT)]
svc --> mongo[(MongoDB PowerSync: DROP_MONGO)]
svc --> coolify[Coolify API<br/>restart opcional]
svc --> audit[(xls_resetar_powersync<br/>audit por step)]
Audit em xls_resetar_powersync (xls_*, local, não sincroniza; ex-xls_nuke_replication,
renomeada em V14). Colunas-chave: id (PK), status (VARCHAR(20)), iniciado_em/
concluido_em, iniciado_por, motivo, step_atual, steps_completados (JSONB,
default []), atividade_atual (TEXT — polling de 2s pela tela), detalhes (JSONB),
erro_mensagem. Garantias de schema:
- CHECK
chk_resetar_powersync_status:status ∈ {PENDENTE, EM_ANDAMENTO, SUCESSO, ERRO}. - Índice unique parcial
uq_xls_resetar_powersync_em_andamentoWHERE status = 'EM_ANDAMENTO': no máximo um reset em andamento — um 2º disparo recebe409. - O audit nunca grava a URI do Mongo — só o nome do DB dropado.
3. Fluxos e estados¶
stateDiagram-v2
[*] --> EM_ANDAMENTO: criar (lança 409 se já houver)
EM_ANDAMENTO --> SUCESSO: FINALIZAR
EM_ANDAMENTO --> ERRO: falha em qualquer step
SUCESSO --> [*]
Os 9 steps idempotentes (executor dedicado reset-, single-thread):
flowchart TB
V[VALIDAR] --> L[LOCK] --> P[PAUSE] --> SP[SNAPSHOT_PRE]
SP --> RS[RESET_SLOT] --> DM[DROP_MONGO] --> R[RESUME]
R --> SA[SAMPLE_POST] --> F[FINALIZAR]
Por que o estado pode ficar parcial (e o recovery é por step): RESET_SLOT
roda antes de DROP_MONGO — falha no reset deixa o Mongo intacto. O drop do
slot roda com autoCommit ligado (o Postgres proíbe pg_drop_replication_slot
dentro de transação), então uma falha no meio deixa estado parcial — daí o
recovery ser passo a passo, não rollback transacional. Reset órfão (JVM morreu no
meio — deploy/OOM) é marcado ERRO no próximo boot, preservando o step onde parou.
No FINALIZAR, grava o sinal ultimo_nuke (etapa 06).
4. Contratos¶
4.1 Gatilhos REST (Bearer)¶
| Método/rota | Confirmação | Status |
|---|---|---|
POST /api/transporte/admin/resetar-powersync |
header X-Confirm-Nuke = token; X-Operator-Id opcional; query wait_secs; body {motivo} |
202 · 403 confirmação errada · 409 já em andamento · 503 desabilitado |
GET …/resetar-powersync/{id} |
— | status + steps |
GET …/resetar-powersync |
query limit/offset |
lista |
A tela /admin/resetar-powersync (modal anti-acidente, palavra RESETAR + CSRF,
protegida por login Google — etapa 07) chama o
mesmo serviço. Contrato completo: dev/api-rest.
4.2 Contratos preservados do rename Nuke→ResetarPowersync (NÃO mudar)¶
A feature se chama Resetar Powersync, mas estes identificadores mantêm o nome antigo "nuke" por serem contrato externo (lidos por clientes Dart/deploy):
| Contrato | Valor | Papel |
|---|---|---|
| Chave de dado | ultimo_nuke (em bi_configuracao, sistema=TRUE) |
sinal de reset (etapa 06) |
| Env | POWERSYNC_NUKE_CONFIRMATION → powersync.nuke.confirmation-token |
habilita o endpoint; vazio → 503 |
| Env | POWERSYNC_BOOTSTRAP_WAIT → powersync.nuke.bootstrap-wait-secs |
espera de bootstrap (default 60) |
| Header | X-Confirm-Nuke |
confirmação; errado → 403 |
| Config path | powersync.nuke.* |
mantido com o nome antigo |
5. Configuração¶
| Env | Property | Default | Papel |
|---|---|---|---|
POWERSYNC_MONGODB_URI |
powersync.mongodb.uri |
vazio → stub (simula o drop) | conexão Mongo do PowerSync |
POWERSYNC_MONGODB_DATABASE |
powersync.mongodb.database |
derivado da URI | DB a dropar |
POWERSYNC_SLOT |
powersync.slot |
auto-discover | slot de replicação |
POWERSYNC_PLUGIN |
powersync.plugin |
pgoutput |
plugin do slot |
COOLIFY_API_TOKEN |
— | vazio → restart manual | habilita restart automático no RESUME |
POWERSYNC_COOLIFY_* / COOLIFY_API_URL / POWERSYNC_HEALTH_URL / POWERSYNC_RESTART_TIMEOUT |
powersync.coolify.* |
— | alvo e health do restart |
Config de restart incompleta → PAUSE/RESUME viram no-op com WARNING (operador
reinicia o PowerSync na mão). Runbook: operacao/resetar-powersync.
6. Decisões¶
| Nº | Decisão |
|---|---|
| 0009 | Resetar Powersync em 9 steps |
| 0010 | Sinal de reset via bi_configuracao |
7. Riscos¶
- Reset disparado por engano → modal anti-acidente + header de confirmação + token configurável; audit por step permite recovery.
- PowerSync não sobe
sync_rulesACTIVE (ex.: GRANT faltando, decisão 0013) → o reset morre emSNAPSHOT_PRE; conferir privilégios antes.
Etapa 06 — Configuração global sincronizada¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-15
Fornece a tabela KV sincronizada que os clientes e o backend leem em runtime — e o canal do sinal de reset disparado pela etapa 05.
1. Contexto e escopo¶
Algumas configurações precisam ser compartilhadas entre o backend e os clientes
móveis em runtime — e o sinal de reset do Powersync precisa chegar aos celulares.
A solução é uma tabela KV (bi_configuracao) sincronizada via PowerSync, consumida
pelos dois lados.
Dentro do escopo: a tabela KV; o consumo no backend (cache + TTL); as telas de
administração; o sinal de reset (ultimo_nuke); a separação chaves de sistema vs
editáveis.
Fora do escopo: o serviço de reset em si → etapa 05.
2. Modelo de dados¶
bi_configuracao (V12__bi_configuracao.sql) é bi_* (sincroniza via PowerSync,
stream recent_data, priority 0; GRANT ao powersync_role garantido em V13). A
chave é o próprio id (TEXT) — não há surrogate.
erDiagram
bi_configuracao {
text id PK "a chave É o id semântico"
text valor "NOT NULL"
varchar tipo "CHECK: STRING|INT|BOOL|TIMESTAMP|JSON"
text descricao
boolean sistema "NOT NULL default FALSE; TRUE = read-only na UI"
timestamptz atualizado_em "NOT NULL default NOW()"
}
- CHECK
chk_bi_configuracao_tipo:tipo ∈ {STRING, INT, BOOL, TIMESTAMP, JSON}. - Seed (V12,
ON CONFLICT (id) DO NOTHING): ultimo_nuke— tipoTIMESTAMP,sistema=TRUE. Sinal monotônico de reset, consumido pelo cliente Flutter (contrato preservado da etapa 05).slow_query_timeout_ms— tipoINT, valor10000,sistema=FALSE(editável).
Renomear o id = delete + insert (é a chave semântica, não surrogate).
3. Fluxos principais¶
- Backend (
service/ConfigService): lê com cache in-memory + TTL 30 s (getString/getInt/getBool/getDuration(key, default)). Janela máxima de staleness após edição pela UI = 30 s. - Cliente Flutter: lê via PowerSync sync (stream
recent_data). - Telas admin (
ConfiguracaoAdminViewController, sob login Google — etapa 07): listam, editam e criam chaves. Rowssistema=TRUE(ex.:ultimo_nuke) aparecem read-only; POST manual via curl numa chave de sistema retorna 403. Chave nova pela UI nascesistema=FALSE; chave de sistema só nasce via migration ou repo. - Sinal de reset: o reset (etapa 05) faz UPSERT em
ultimo_nuke = NOW()::TEXTnoFINALIZAR; o cliente compara com o valor que guardou e disparadisconnectAndClearquando muda.
4. Decisões¶
| Nº | Decisão |
|---|---|
| 0010 | Sinal de reset via bi_configuracao |
5. Riscos¶
- Edição de chave de sistema corromper o sinal → bloqueado na UI (read-only) e no POST (403).
- Staleness de até 30 s no backend após edição → aceitável por design (TTL do cache); documentado.
bi_configuracaosem GRANT aopowersync_role→ não sincroniza (42501); consolidado em V13 (decisão 0013).
Etapa 07 — Autenticação Google nas views¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-07
O setup operacional (envs Firebase, cookies) vive no runbook
operacao/google-auth. Esta página é o desenho.Histórico: esta etapa descreve o gate via
AuthFilter(filtro caseiro). O mecanismo foi depois migrado para o micronaut-security nativo (AuthenticationFetcher+SecurityRule+ExceptionHandler) preservando o comportamento — ver etapa 08 e a decisão 0018. Os envs, o cookiexadm_sessione a UX continuam idênticos.
1. Contexto e escopo¶
As telas server-rendered (/, /processamentos, /admin, /admin/configuracoes)
são operadas por humanos da X-Adm e não podem ficar públicas. Diferente da API
(consumida por máquina, Bearer estático), elas precisam de login humano —
resolvido com Google/Firebase restrito ao domínio @xadm.com.br.
Dentro do escopo: o gate das views; a sessão; o CSRF; o comportamento sem envs (dev/test vs prod).
Fora do escopo: a auth da API (/api/**, Bearer estático — decisão 0011); não
mexer nela ao trabalhar nas views.
2. Componentes¶
flowchart TB
user[Usuário X-Adm] -->|login Google| fb[Firebase]
fb -->|ID token| login[LoginController]
login -->|cookie xadm_session<br/>JWT HS256| browser[Browser]
browser -->|cookie| filter[security/AuthFilter]
filter -->|email @xadm.com.br| views[Views /processamentos, /admin]
filter -.->|sem cookie / inválido| nega[302 /login ou 503]
3. Fluxos principais¶
3.1 Duas camadas independentes (decisão 0011)¶
/api/**→ Bearer estático (@Secured("ROLE_API"))./api/**é whitelisted noAuthFilter— o login Google não afeta a API.- Views → login Google + cookie de sessão JWT HS256 (
xadm_session), gate nosecurity/AuthFilter(@Filter("/**"), roda antes do SecurityFilter do micronaut-security), restrito a@xadm.com.br.
3.2 Login (CSRF double-submit)¶
GET /login→ renderiza Firebase JS SDK + grava cookie CSRFxadm_login_csrf.POST /login/callback(JSON{credential, from, csrfToken}) → valida CSRF + Firebase ID token + domínio; emitexadm_session. Status:200 {redirect};400csrf/token;401token inválido;403domain_not_allowed;503JWKS indisponível.POST /logout→ expiraxadm_session,303→/login.
3.3 Sem envs AUTH_* — bypass vs fail-closed¶
"Auth configurado" ≡ firebaseProjectId + firebaseApiKey + firebaseAuthDomain
+ firebaseAppId + sessionSecret todos preenchidos. Comportamento do AuthFilter:
- Configurado → filtro ativo (exige
xadm_session). - Ausente em
dev/test→ bypass (views públicas, para desenvolvimento). - Ausente fora de
dev/test→ fail-closed: views respondem503 {"error":"auth_not_configured"}— nunca abre por engano.
4. Acesso efetivo por endpoint¶
O intercept-url-map (micronaut-security) marca como isAnonymous(): /health,
/swagger/**, /swagger-ui/**, /public/**, /css/**, /images/** (estes dois
últimos são os assets do kit de UI — CSS/logo carregam na tela de login, antes da
sessão). A proteção real das views vem do AuthFilter (login Google) por cima.
| Grupo | Acesso efetivo |
|---|---|
POST /api/xls/processar, /api/transporte/admin/** |
Bearer (ROLE_API) |
Views /, /processamentos, /admin/** |
login Google (ativo); públicas em dev/test; 503 em prod sem envs |
GET /api/transporte/processamentos/** (JSON), /health, /swagger*, /public/**, /css/**, /images/**, /login, /logout |
anônimos |
Whitelist do AuthFilter (dispensam sessão): exatos /health, /favicon.ico,
/login, /logout; prefixos /health/, /swagger/, /swagger-ui, /login/,
/api/, /public/, /css/, /images/. As duas listas (o intercept-url-map do
YAML e a whitelist Java do AuthSupport) andam juntas — public novo entra nas duas.
5. Configuração¶
Envs AUTH_* → AuthSettings: AUTH_FIREBASE_PROJECT_ID, AUTH_FIREBASE_API_KEY,
AUTH_FIREBASE_AUTH_DOMAIN, AUTH_FIREBASE_APP_ID, AUTH_SESSION_SECRET,
AUTH_SESSION_TTL_SECONDS, AUTH_COOKIE_SECURE, AUTH_XADM_EMAIL_DOMAIN. Setup
detalhado: operacao/google-auth.
6. Decisões¶
| Nº | Decisão |
|---|---|
| 0011 | Autenticação em duas camadas |
7. Riscos¶
- Deploy de produção sem as envs
AUTH_*→ fail-closed (503), não exposição. - Vazamento do segredo da sessão (
AUTH_SESSION_SECRET) → rotação; secrets no Coolify.
Etapa 08 — Modernização: Java 25 + Micronaut 5 e auth nativa¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-17
Delta de modernização sobre a base Java 21 / Micronaut 4.7.6. Nada de comportamento observável mudou: a suíte completa (incl. docker/Testcontainers e o E2E de auth) passou sem alterar asserções — prova de paridade.
1. Contexto¶
O projeto estava uma major atrás (Micronaut 4.7.6, Java 21) e acumulava "código caseiro" onde o framework já oferecia o primitivo. Esta etapa moderniza a stack e realinha o código, em passos verificados contra a suíte.
2. Stack: Java 25 + Gradle 9.5.1 + Micronaut 5.0.2¶
- Toolchain JDK 25 (GraalVM 25.0.3 via SDKMAN); o Gradle 9.5.1 roda em
Java 25 — exigência do plugin
io.micronaut.application5.Dockerfilee CI passam atemurin:25/setup-java 25. - Os breaking changes reais do salto e como foram resolvidos estão na
decisão 0019 (serde default,
jackson explícito,
ViewModelProcessor<T,R>, checksum S3 do Garage, Testcontainers), incluindo a consolidação Jackson 2 → 3 (tools.jackson.*, removendo as classes duplicadas do uber-jar).
A serialização HTTP migrou para o serde (build-time, allowlist @Serdeable) e o
TelegramService virou @Client declarativo — detalhe na
decisão 0020.
Limpeza menor junto: bindings descartados viraram unnamed variables
_(JEP 456) em 5 pontos (catches e lambdas).
3. status stringly-typed → enums¶
ProcessamentoXls.status e ResetarPowersync.status deixam de ser String com
literais repetidos e viram enums (ProcessamentoStatus, ResetStatus),
mapeados como name() na fronteira (contrato JSON inalterado). Ganha checagem em
compile-time e elimina o "X".equals(getStatus()) sempre-falso silencioso.
4. Config espalhada → @ConfigurationProperties records¶
Os ~27 @Value soltos viram records tipados por prefixo coeso: S3Config,
TelegramConfig, CoolifyConfig, PowersyncMongoConfig/PowersyncPostgresConfig/
PowersyncNukeConfig, AppConfig, ArmazenamentoConfig, LogsConfig. Paths de
config preservados (nenhum env/contrato quebrado, incl. powersync.nuke.*).
Injeção 100% por construtor; sobra só o app.api-token como @Value single-use.
5. Contrato de erro único + Bean Validation¶
Todo corpo de erro da API passa a sair como {code, message} num ponto único
(ErrorResponseProcessor), substituindo os três formatos que coexistiam (Hateoas
default, envelope ad-hoc do login, status-no-corpo). Detalhe e porquê na
decisão 0017. Bean Validation foi
ativado nos pontos limpos (@Positive/@Size); a trilha de segurança do login
ficou manual de propósito (ver 0017).
6. Auth das views: caseiro → micronaut-security nativo¶
O AuthFilter (@Filter paralelo ao SecurityFilter) foi substituído por
primitivos nativos, mantendo UX, cookies e envs idênticos. Porquê e estratégia
de paridade na decisão 0018.
flowchart TB
user[Usuário X-Adm] -->|login Google| fb[Firebase]
fb -->|ID token| login[LoginController]
login -->|cookie xadm_session| browser[Browser]
browser -->|cookie| fetcher[SessionAuthenticationFetcher]
fetcher -->|Authentication| rule[ViewSecurityRule]
rule -->|ALLOWED| views[Views /processamentos, /admin]
rule -.->|REJECTED| handler[ViewRejectionHandler]
handler -.-> nega[302 /login · 401 JSON · 503 fail-closed]
O SessionTokenService (JWT da sessão) e o CsrfTokens (CSRF determinístico)
seguem intactos nesta etapa — a migração deles para o micronaut-security-jwt
e micronaut-security-csrf é trabalho subsequente.
Estado atual (2026-08-04): o motor de auth (validador Firebase,
SessionTokenService,CsrfTokens,SessionAuthenticationFetcher, Bearer estático, configauth.*) não é mais caseiro — vem da lib compartilhadabr.com.xadm.comum.seguranca(xadm-seguranca, decisão 0023). Local ficou só a policy de view (ViewSecurityRule/ViewRejectionHandler), oGlobalViewModel, oLoginControllere a whitelist base (ViewWhitelist). Oissdoxadm_sessionvirou config (auth.issuer, defaultbi-transporte-xls).
7. Decisões¶
| Nº | Decisão |
|---|---|
| 0017 | Contrato de erro único {code, message} |
| 0018 | Auth das views no micronaut-security nativo |
| 0019 | Bump major Java 25 + Micronaut 5 |
| 0020 | Serde (build-time) + HTTP declarativo (@Client) |
| 0023 | Adotar a lib xadm-seguranca (motor compartilhado) |
8. Riscos¶
- As mudanças de CI/Docker para Java 25 só se confirmam num run real de CI —
validar no primeiro PR antes do merge em
master. - A migração de auth preserva comportamento, mas é fronteira de segurança: o
AuthIntegrationTesté a rede que prova paridade — não enfraquecê-lo.
Decisões
0001 — Micronaut Data JDBC, sem Hibernate¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-11 · Decidido em: 2026-04-01
Contexto¶
O processador persiste fatos (bi_*) e metadados (xls_*) em PostgreSQL. A
camada de acesso a dados precisava ser escolhida junto com o framework. A stack
ficou em Java 21 + Micronaut 4 (preferido a Spring Boot pelo startup rápido
e footprint menor em container).
Decisão¶
Usar Micronaut Data JDBC — repositórios sobre entidades @MappedEntity —
sem Hibernate/JPA. O schema é versionado por Flyway (classpath:db/migration),
nunca por DDL automático de ORM.
Consequências¶
- Controle explícito do SQL — essencial para o UPSERT diff-aware e o bulk via COPY (ver decisão 0006 e decisão 0007).
- Sem cache de 1º nível, lazy-loading ou flush implícito surpreendendo o fluxo.
- Migrações revisáveis em PR; sem divergência entre o modelo Java e o banco.
Alternativas consideradas¶
- Hibernate/JPA: abstrai SQL demais; o SQL implícito atrapalha o controle fino que o pipeline diff-aware exige. Descartado.
- Spring Data / Spring Boot: startup e memória maiores num alvo de container; Micronaut atende o perfil. Descartado já na escolha do framework.
0002 — Processar XLS (POI)¶
Decisão obsoleta — superada pela decisão 0026
Status: Obsoleto · Responsável: Gustavo Madruga · Atualizado em: 2026-08-19 · Decidido em: 2026-04-01
SUBSTITUÍDA pela decisão 0026 (2026-08-19): a leitura migrou de POI/SAX para FastExcel (StAX puro) para viabilizar o build native-image — o XMLBeans do POI não compila em native. O streaming (memória ~constante) foi preservado. Esta decisão fica como registro histórico.
Contexto¶
As planilhas de transporte chegam grandes (dezenas de milhar de linhas,
arquivos de dezenas de MB — o limite de upload é 50 MB). O modo usermodel do Apache POI
(XSSFWorkbook) carrega a planilha inteira em memória — estoura o heap.
Correção 2026-06-12: o texto dizia "arquivos que passam de 100 MB"; o limite real de upload é 50 MB (
max-file-size). Fato incidental — a decisão (SAX) continua de pé (§1.4).
Decisão¶
Ler via SAX (streaming): ExcelProcessor + SheetSaxHandler +
ExcelCellValue, sempre as 3 abas fixas (10 = config/período, 20 =
faturamento, 30 = movimento). Limites de coluna em VarcharLimits.
Consequências¶
- Memória ~constante, independente do tamanho do arquivo.
- Mais código que o
usermodel(handler de eventos manual), mas é o preço de suportar arquivos grandes sem OOM. - Export JSON disponível via
ProcessamentoJsonExporter.
Alternativas consideradas¶
- POI usermodel (
XSSFWorkbook): simples, mas estoura heap nos arquivos reais do cliente. Descartado. - Biblioteca de planilha alternativa: POI já é a stack padrão cross-project X-Adm; trocar não se justifica.
0003 — Contrato canônico JSON Python↔Java¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-11 · Decidido em: 2026-04-03
Contexto¶
Este serviço Java substitui um fluxo Python que já rodava em produção. A migração só é segura se o resultado do parse Java for idêntico ao do Python para os mesmos arquivos — caso contrário, divergências entram silenciosas.
Decisão¶
Definir um JSON Schema canônico do resultado do processamento (período +
faturamentos + movimentos + metadados): schema-processamento-xls.json. Os
testes de paridade rodam arquivos reais por fixtures (src/test/resources/
fixtures/transporte-YYYYMMDD/input.xlsx + resultado-python.json golden) e
comparam a saída Java contra o resultado do Python.
Consequências¶
- Regressão de parsing é detectável automaticamente, não por inspeção visual.
- O schema (
docs/dev/schema-processamento-xls.json) é o contrato canônico do resultado do processamento: serve de referência para os testes de paridade e é publicado como contrato de input/export no níveldev/da doc.
Correção 2026-06-12: o texto dizia que o schema "não é documentação publicada"; ele é publicado em
docs/dev/(referência não-prosa, §2/§5). Fato incidental — a decisão (contrato canônico p/ paridade) segue (§1.4). - Cobre vários meses (uma fixture por período) com asserção forte (golden).
Alternativas consideradas¶
- Validação manual/visual: frágil e não repetível. Descartado.
- Reescrever sem rede de paridade: risco alto de divergir do legado sem ninguém perceber. Descartado.
0004 — Período de referência vem do upload¶
Decisão obsoleta — superada pela decisão 0016
Status: Obsoleto · Responsável: Gustavo Madruga · Atualizado em: 2026-06-19 · Decidido em: 2026-04-01
Obsoleta — superada pela decisão 0016. A premissa abaixo ("o layout de transporte não expõe o período de forma confiável") é falsa: o período do diff-aware vem da aba-10;
data/horasão apenas metadado.
Contexto¶
Cada upload precisa de um período de referência (para particionar storage e delimitar o DELETE seletivo do diff-aware). O projeto irmão Comercial detecta o período pelo conteúdo do arquivo; o layout de transporte não traz um período confiável o suficiente para isso.
Decisão¶
O POST /api/transporte/processar exige data (LocalDate) e hora
(LocalTime) como parâmetros multipart obrigatórios. Eles viram
data_referencia / hora_referencia em xls_processamento e alimentam o
periodoInicial usado na chave de storage.
Consequências¶
- Quem controla o período é o cliente (ERP/job agendado), não a heurística.
- Divergência legítima do Comercial (que detecta do conteúdo) — documentada na convenção cross-project.
- A partição do storage Garage usa
periodoInicialquando disponível.
Alternativas consideradas¶
- Detectar do conteúdo (como o Comercial): o layout de transporte não expõe o período de forma confiável. Descartado.
- Inferir da data de recebimento: impreciso para reprocessamento de períodos históricos. Descartado.
0005 — BIGSERIAL como PK nas tabelas bi_*¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-11 · Decidido em: 2026-04-01
Contexto¶
A regra cross-project X-Adm para PK de tabelas bi_* tem como ideal
UUID v7 DEFAULT uuidv7() (bom para geração distribuída e clientes móveis).
Mas as bi_* deste projeto sincronizam via PowerSync, e a dúvida era se
BIGSERIAL seria aceito.
Decisão¶
Usar BIGSERIAL em bi_faturamento e bi_movimento (e id TEXT = chave
semântica em bi_configuracao). Confirmado em produção: PowerSync 1.20+
aceita BIGSERIAL na replicação.
Consequências¶
- Simplicidade; não é débito técnico — é escolha consciente, documentada na
regra cross-project do
CLAUDE.md. - Difere do Onpetro (que usa
uuidv7()com sync ativo) — divergência legítima por domínio; ambas as formas são aceitas pela regra. - A escrita é centralizada no ERP, então não há ganho prático de PK distribuída.
Alternativas consideradas¶
uuidv7()DEFAULT: ideal para geração distribuída/mobile, mas desnecessário aqui (o ERP é a única fonte de escrita). Descartado por não agregar.- Paridade forçada com o Onpetro: as divergências de PK entre os dois apps já são aceitas pela regra; não vale reescrever só por uniformidade.
0006 — Chaves naturais + UPSERT diff-aware¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-11 · Decidido em: 2026-05-08
Contexto¶
Reprocessar um período (reupload do mesmo mês) não pode gerar checkpoints PowerSync desnecessários: cada linha "tocada" no Postgres re-propaga para os clientes móveis, gastando banda e bateria mesmo quando nada mudou de fato.
Decisão¶
Cada fato tem uma UNIQUE composta sobre as colunas do ERP que o identificam
logicamente (chave natural: bi_faturamento com 10 colunas; bi_movimento
com 16 + seq_dentro_grupo para colisões legítimas). O pipeline é diff-aware:
- UPSERT:
INSERT … ON CONFLICT (chave_natural) DO UPDATE … WHERE … IS DISTINCT FROM …— só atualiza se algum campo realmente mudou. - DELETE seletivo: remove só as linhas que sumiram do upload naquele período, nunca o período inteiro.
- Ordem: UPSERT primeiro (preserva
id), DELETE depois.
Consequências¶
- Linhas inalteradas mantêm
idexmin→ o PowerSync não re-propaga. - Métricas finas por tabela (inseridas/atualizadas/inalteradas/removidas).
- FKs apontam para a chave natural
UNIQUE(Postgres aceita).
Alternativas consideradas¶
- DELETE do período inteiro + INSERT: recria todas as linhas → churn massivo de checkpoint no PowerSync. Descartado.
- UPSERT cego (sem
WHERE IS DISTINCT FROM): bumpaxminde tudo, mesmo sem mudança real → re-propaga à toa. Descartado.
0007 — COPY + TEMP TABLE no upsert em lote¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-11 · Decidido em: 2026-05-08
Contexto¶
O UPSERT diff-aware (ver decisão 0006) precisa ser aplicado em lote sobre milhares de linhas por upload, com boa performance.
Decisão¶
O caminho de upsert em lote faz COPY → TEMP TABLE → UPSERT → DELETE: carrega o lote na
temp table via COPY (rápido), depois aplica o UPSERT diff-aware e o DELETE
seletivo a partir dela.
Consequências¶
COPYé a via mais rápida do Postgres para cargas grandes.- Diverge tecnicamente do Comercial, que usa
PreparedStatement.executeBatch— lá isso é necessário para preservar orowCountper-row da métricalinhas_efetivas. Aqui as métricas finas vêm por tabela, de outra forma. - Filosoficamente, os dois fazem a mesma coisa (diff-aware); a técnica difere por um motivo legítimo de cada domínio.
Alternativas consideradas¶
executeBatch(como o Comercial): preservarowCountper-row, mas é mais lento no volume deste projeto e não traz vantagem aqui. Descartado.- INSERT linha a linha: inviável na escala dos uploads reais. Descartado.
0008 — Object storage Garage com 3 modos¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-08-11 · Decidido em: 2026-05-21
Nota 2026-08-11: esta decisão é o design (3 modos, seam, chave content-addressed). A implementação (
ArquivoXlsStorage/S3ArquivoXlsStorage/ArmazenamentoModo/S3Config/...) foi extraída para a libbr.com.xadm:xadm-comum-storagee o app adotou-a — ver decisão 0025. Prefixos de config e comportamento inalterados; as classes vivem agora embr.com.xadm.comum.armazenamento.
Contexto¶
O binário do .xlsx recebido era guardado em BYTEA no Postgres, o que infla o
banco. Quer-se mover para object storage S3-compatível (Garage) sem perder a
rede de segurança durante a transição.
Decisão¶
Um seam ArquivoXlsStorage (impl S3ArquivoXlsStorage, AWS SDK v2) com 3
modos (app.storage.modo / env S3_FILE_STORAGE):
PSQL— só Postgres (arquivo_bytes).PSQL_GARAGE(default) — grava nos dois, lê do Garage. Rede de segurança.GARAGE— só Garage; oBYTEAficaNULL.
Chave content-addressed: {aaaa}/{mm}/{checksum_sha256}.xlsx. O backfill
(StorageBackfillService, @Order(-100), idempotente) roda no startup em todo
modo; é no-op no modo PSQL.
Correção 2026-06-12: o texto dizia "backfill opt-in (
app.storage.backfill.enabled)"; essa property não existe — o backfill roda sempre no startup. Fato incidental — a decisão (3 modos) segue (§1.4).
Consequências¶
- Migração reversível: a coluna
arquivo_bytesnão é dropada — o "cleanup" do modoGARAGEé zerar bytes em runtime. - Falha de I/O no upload →
StorageIndisponivelException→ HTTP 503 (sem linha persistida; o cliente reenvia).
Alternativas consideradas¶
- Só Postgres BYTEA: o banco infla com o volume de uploads. Descartado.
- Migrar direto para
GARAGEsem o modo dual: sem rede de segurança se o storage falhar durante a transição. Descartado em favor doPSQL_GARAGE.
0009 — Resetar Powersync¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-11 · Decidido em: 2026-05-04
Contexto¶
Mudanças estruturais ou estados corrompidos às vezes exigem zerar o estado replicado (slot de replicação no Postgres + base do PowerSync no MongoDB) e forçar um re-sync limpo nos clientes. É uma operação destrutiva e arriscada se feita à mão.
Decisão¶
Encapsular num ResetarPowersyncService com 9 steps idempotentes: VALIDAR,
LOCK, PAUSE, SNAPSHOT_PRE, RESET_SLOT, DROP_MONGO, RESUME, SAMPLE_POST,
FINALIZAR. Dois gatilhos para o mesmo serviço: endpoint REST
(POST /api/transporte/admin/resetar-powersync, com Bearer + X-Confirm-Nuke) e
a tela /admin (modal anti-acidente, palavra RESETAR). Restart do recurso
PowerSync automático opcional via API do Coolify.
Consequências¶
- Operação auditável e repetível (snapshot antes/depois), não um conjunto de comandos soltos.
- Sem
COOLIFY_API_TOKEN, o restart do PowerSync é manual. - O procedimento operacional vive no runbook
operacao/resetar-powersync.md; o porquê/como funciona fica aqui.
Alternativas consideradas¶
- Reset manual no banco/Mongo: propenso a erro, sem snapshot nem auditoria. Descartado.
- Não suportar o reset: clientes ficariam presos a um SQLite local stale após mudanças estruturais. Descartado.
0010 — Sinal de reset do Powersync via bi_configuracao¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-11 · Decidido em: 2026-05-26
Contexto¶
Depois de um reset do Powersync (ver decisão 0009), o cliente Dart
reaproveita o SQLite local e fica dessincronizado do servidor já zerado.
Precisa de um sinal que dispare disconnectAndClear no cliente.
Este sinal foi adicionado depois do primeiro reset real em produção (maio/2026): observou-se que o cliente Flutter não re-baixava os dados — o PowerSync só sincroniza deltas e o cliente não tinha como saber que o Mongo fora recriado.
Decisão¶
No STEP_FINALIZAR, o pipeline faz UPSERT em bi_configuracao.ultimo_nuke
(valor = NOW()::TEXT). O PowerSync replica essa linha; o cliente compara com o
último valor visto em SharedPreferences['ps.ultimo_nuke'] e dispara
disconnectAndClear quando muda.
Consequências¶
bi_configuracaoé uma tabela KV (id TEXT PK) sincronizada (streamrecent_data, priority 0); serve também para outras configs globais.- Backend consome via
ConfigService(cache in-memory, TTL 30 s). ultimo_nukeé chave de sistema (sistema=TRUE): read-only na tela/admin/configuracoes; POST manual retorna 403.
Alternativas consideradas¶
- Flag booleano (
reset=true) em vez de timestamp: entraria em loop infinito — o cliente lêreset=true, fazdisconnectAndClear, reconecta, lêreset=truede novo… O timestamp monotônico resolve: o cliente guarda o valor visto antes do clear e, ao reconectar, compara igual = igual → não redispara. Descartado. - Push notification ao cliente: exige infra de notificação extra; o sinal via tabela já replicada é mais simples. Descartado.
- Cliente detectar por heurística (ex. contagem de linhas): frágil e ambíguo. Descartado.
0011 — Autenticação em duas camadas¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-11 · Decidido em: 2026-05-25
Contexto¶
A aplicação tem duas superfícies com audiências e ameaças diferentes: a API
(consumida por máquina — o ERP/job do cliente) e as views server-rendered
(/processamentos, /admin, consumidas por humanos da X-Adm).
Decisão¶
Duas camadas independentes:
/api/**— Bearer token estático viamicronaut-security(@Secured("ROLE_API")).- Views — login Google (Firebase), restrito a e-mails
@xadm.com.br; cookie de sessão JWT HS256 (xadm_session), gate nosecurity/AuthFilter, token CSRF (_csrf) nos forms POST.
Sem as envs AUTH_*: em dev/test as views ficam públicas (bypass); em
produção o acesso é fail-closed (503).
Consequências¶
- As duas camadas evoluem isoladas — não mexer numa ao trabalhar na outra.
- Procedimento de setup (Firebase, envs) no runbook
operacao/google-auth.md.
Alternativas consideradas¶
- OAuth2/JWT na API: excesso para um único cliente confiável com segredo compartilhado. Descartado.
- Mesma auth para API e views: audiências incompatíveis (máquina vs humano). Descartado.
0012 — Preservação de cadastro de veículo no UPSERT¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-11 · Decidido em: 2026-05-08
Contexto¶
Correções de cadastro de veículo (marca, modelo, ano de construção, ano de modelo, tipo de frota) feitas direto no banco eram sobrescritas por reprocessamentos do Excel quando a planilha trazia aquelas colunas vazias.
Decisão¶
As colunas em ColunasConteudo.PRESERVAR_SE_VAZIO_NA_ENTRADA recebem tratamento
especial no UPSERT: quando o valor de entrada é "vazio" (NULL, string vazia ou
0), o UPSERT mantém o valor já gravado em vez de sobrescrever.
Consequências¶
- Correções manuais de cadastro sobrevivem a reprocessamentos de Excel.
- O XLS só sobrescreve essas colunas quando traz um valor real (não-vazio).
Alternativas consideradas¶
- Sobrescrever sempre (UPSERT cego): perde as correções manuais a cada reupload. Descartado — era o bug que motivou a decisão.
- Nunca sobrescrever essas colunas: impediria a atualização legítima via XLS quando a planilha de fato traz o dado novo. Descartado.
0013 — GRANT automático ao powersync_role¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-11 · Decidido em: 2026-05-26
Contexto¶
O powersync_role (user que o PowerSync usa para replicar) lê as tabelas com
SELECT. Mas tabelas criadas pelo user das migrations (user_vantroba) nascem
sem GRANT para ele — Postgres não propaga GRANT automático entre roles.
Sintoma: PowerSync para de replicar com permission denied for table (42501),
o cliente trava no sync e o reset do Powersync morre em SNAPSHOT_PRE.
Decisão¶
A migration V13 aplicou ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT
SELECT ON TABLES TO powersync_role rodando como user_vantroba — assim toda
tabela futura em public herda o GRANT. Como cinto de segurança, cada
migration que cria uma tabela bi_* nova adiciona um GRANT SELECT … TO
powersync_role explícito (no-op se o default já cobriu).
Consequências¶
- Tabelas
bi_*novas herdam oSELECTsem intervenção manual. - "Confia desconfiando": sempre conferir
\dp <tabela>antes de redeployar o PowerSync com a tabela nova nosync_rules.yaml. - Tabelas
xls_*não precisam (não sincronizam), mas ganham o GRANT sem custo.
Alternativas consideradas¶
- GRANT manual a cada tabela: fácil esquecer → 42501 em produção. O default privileges + cinto reduz o risco. Parcialmente descartado (vira o cinto).
- Rodar migrations como superuser: quebraria o isolamento de privilégios do banco. Descartado.
0014 — Documentação no portal central¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-12 · Decidido em: 2026-06-11
Contexto¶
A documentação deste repo era arc42 em AsciiDoc, com diagramas PlantUML
renderizados no build e o HTML embarcado no próprio JAR da aplicação (servido
em /docs/). Esse modelo mantinha a doc presa ao artefato de deploy, exigia
toolchain extra no build (AsciiDoctor + PlantUML, que precisa de Graphviz) e
não se integrava ao portal de documentação da X-Adm, onde os demais projetos
passaram a publicar.
A X-Adm consolidou uma constituição de documentação (versão 0.5.5) que
define seis níveis, frontmatter padronizado, docs/ como fonte pública e
publicação via MkDocs no portal central (docs.xadm.biz).
Decisão¶
Adotar a constituição X-Adm e migrar a documentação para MkDocs publicado no
portal central, organizada em docs/ (visão geral, projeto, decisões, dev,
operação, manual). A publicação passa a ser feita por um workflow dedicado de
docs que sobe o site gerado para o storage do portal.
Em consequência, aposenta-se o pipeline arc42: removidos do build a geração
de HTML AsciiDoctor, a geração de diagramas PlantUML e a opção que embarcava a
doc no JAR (-PincludeDocsInJar); o job de documentação do CI também sai, e o
deploy deixa de depender dele. Os diagramas passam a ser Mermaid inline nos
docs de projeto.
Consequências¶
- A doc deixa de viajar dentro do JAR e do endpoint
/docs/da aplicação — uma responsabilidade a menos no artefato de deploy. - O build fica mais simples e rápido (sem AsciiDoctor/PlantUML, sem dependência de Graphviz).
- A doc fica visível no portal central junto dos outros projetos, com busca e frontmatter validado no CI.
- O conteúdo arc42 anterior foi destilado para
docs/(decisões, projeto, operação, dev, manual); o original permanece no histórico do Git. - O
docs/app.jsonpassa a registrar a versão-base0.5.5da constituição; o hook de sessão avisa quando defasar.
Alternativas consideradas¶
- Manter arc42 embarcado no JAR: acopla doc ao deploy, fica fora do portal central e exige toolchain pesada no build. Descartado.
- Publicar o HTML arc42 numa branch de pages do Forgejo: resolveria a visibilidade, mas continuaria fora do padrão e do portal único da X-Adm. Descartado em favor do MkDocs central.
0015 — GETs de leitura da API são anônimos¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-12 · Decidido em: 2026-06-12
Contexto¶
A API tem um POST de upload protegido por Bearer (@Secured(ROLE_API)) e um
conjunto de GETs de leitura (/processamentos, /{id}, /{id}/logs,
/{id}/arquivo). A pergunta: esses GETs exigem autenticação?
Decisão¶
Os GETs de leitura ficam anônimos (IS_ANONYMOUS), por escolha consciente.
Só o POST de upload e as operações destrutivas exigem credencial.
O que isso expõe a quem alcançar a rede do app:
- a lista e o detalhe dos processamentos (nomes de arquivo, métricas, status);
- os logs de execução (
/{id}/logs); - o XLS original (
/{id}/arquivo) — que contém dados de cliente.
Aceitável porque a API não é exposta na internet aberta: vive atrás do Traefik/rede interna do Coolify, e o acesso de leitura é conveniência de integração/diagnóstico. Se o cenário mudar (exposição pública), revisar e proteger os GETs sensíveis.
Consequências¶
- Integração e diagnóstico não precisam de token para ler — mais simples.
- Trade-off de segurança explícito: quem alcança a rede do app lê dados de cliente sem credencial. Documentar; não tratar como invisível.
- Detalhe confidencial que precise ser referenciado na doc sem publicar vai pra
docs/anexos/privado/(convenção do repo).
Alternativas consideradas¶
- Proteger os GETs com Bearer/sessão: mais seguro, mas quebra a integração de leitura e o diagnóstico rápido; desproporcional dado o isolamento de rede. Descartado por ora — reabrir se a API for exposta publicamente.
0016 — Período do diff-aware vem da aba-10¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-12 · Decidido em: 2026-06-12
Supera a decisão 0004, cuja premissa ("o layout de transporte não expõe o período de forma confiável") era falsa.
Contexto¶
A decisão 0004 afirmava que o layout de transporte não traz um período confiável
e que os parâmetros data/hora do upload delimitavam o DELETE seletivo do
diff-aware. O código mostra o contrário, e como isso descreve um caminho
destrutivo (a janela do DELETE), a correção merece registro próprio.
Decisão¶
Separar dois conceitos que a 0004 confundia:
- Período do relatório → vem da aba-10. O processador da aba de config lê
E1(data inicial) eF1(data final) e lança se ausentes — o layout expõe o período de forma confiável e obrigatória. Esse período é o que delimita o DELETE seletivo do diff-aware (… WHERE dt_em BETWEEN inicio AND fim …). data/horado upload → metadado. Viramdata_referencia/hora_referenciaemxls_processamento(quando o relatório foi gerado) e a chave de partição do object storage ({aaaa}/{mm}). Não delimitam o DELETE.
Ambos seguem obrigatórios no POST /api/xls/processar (a decisão
operacional da 0004 — exigir data/hora — continua válida; muda só o porquê
e o o que eles fazem).
Consequências¶
- O período destrutivo é determinístico (vem do conteúdo da planilha), não do que o cliente digita no upload.
data_referencia(metadado) eperiodo_inicial/periodo_final(da aba-10) são colunas distintas emxls_processamento— não confundir.- A divergência com o Comercial cai: os dois detectam o período do conteúdo; aqui é a aba-10, lá é outro layout.
Alternativas consideradas¶
- Manter a narrativa da 0004 (período do upload): contradiz o código e descreve errado um caminho destrutivo. Descartado — daí esta superação.
0017 — Contrato de erro único {code, message}¶
Decisão obsoleta — superada pela decisão 0021
Status: Obsoleto · Responsável: Gustavo Madruga · Atualizado em: 2026-06-19 · Decidido em: 2026-06-17
Superada pela decisão 0021: o app alinhou ao default da casa (RFC 7807). O contrato de erro hoje é
application/problem+json.
Contexto¶
A API tinha três formatos de corpo de erro convivendo: o Hateoas default do
Micronaut ({message, _links, _embedded}) nas HttpStatusException; um envelope
ad-hoc {error, message} no login; e o status-no-corpo do /processar
(ProcessamentoResponse.httpStatus). Não havia ponto único de tradução, nem
Bean Validation — toda validação era if manual. Cliente recebia formato
diferente conforme o endpoint.
Decisão¶
- Corpo de erro único
{code, message}(ErrorResponse), renderizado num ponto só peloUnifiedErrorResponseProcessor(@ReplacesdoHateoasErrorResponseProcessor). CobreHttpStatusException, falhas de validação, 404 de rota e parse de JSON.codeé estável/legível por máquina (ex.:CONFLICT); o status HTTP vai na resposta. OAuthExceptionganha umExceptionHandler(rede de segurança). - Bean Validation ativado nos pontos limpos (
@Positivenowait_secs,@Sizenomotivo). A trilha de segurança do login (CSRF constant-time, limite de token) fica manual de propósito: convertê-la em constraints reordenaria a sequência de checagem (CSRF-first) e genericizaria os códigos estáveis (csrf_mismatch,token_too_large). Migra junto do login no micronaut-security.
Consequências¶
- Contrato de erro consistente e descobrível; um teste fixa o
{code, message}em 404/400 (não existia teste de corpo de erro antes). - O corpo público do
/processarfoi preservado (status2xxmantém o corpo de negócio, nunca ocode; ohttpStatussegue interno, nunca serializado) — nenhum cliente quebra. - Validação declarativa onde cabe; o resto (parse de data/hora, segurança) permanece manual por ser parse de formato ou ordem-sensível.
Alternativas consideradas¶
- RFC 7807 / ProblemDetail: mais completo, mas o
{code, message}já unifica e reaproveita ocodeestável que o login expunha. Descartado por simplicidade. - Push completo de Bean Validation (inclusive login): descartado — degrada a trilha de segurança sem ganho real (ver acima).
0018 — Auth das views no micronaut-security nativo¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-17 · Decidido em: 2026-06-17
Contexto¶
O gate de login das views era um AuthFilter (@Filter("/**")) caseiro, rodando
em paralelo ao SecurityFilter do micronaut-security — reimplementando
autenticação, autorização por path e tradução de rejeição que o framework já dá.
A API (/api/**, Bearer estático — decisão 0011) já
era nativa. Manter dois mundos de auth aumentava a superfície e divergia do
framework.
Decisão¶
Substituir o AuthFilter por primitivos nativos (passo 1 — mantendo o
Firebase client-side, o SessionTokenService e o CsrfTokens desta vez):
SessionAuthenticationFetcher(AuthenticationFetcher) lê o cookiexadm_session, valida viaSessionTokenServicee devolveAuthentication.ViewSecurityRule(SecurityRule) replica o tri-estado enabled / bypass (dev/test) / fail-closed (prod) das views.ViewRejectionHandler(ExceptionHandler<AuthorizationException>— o security 5.x removeu oRejectionHandler) faz 302 browser / 401 JSON / 503 fail-closed, preservando o comportamento do filtro antigo.AuthSupportguarda constantes/helpers que sobreviveram; o fetcher mantém os atributos de request, entãoGlobalViewModeleCsrfTokensficam intactos.
Escolha entre rotas (Regra nº 1): preferida a migração por peças nativas
(mantém Firebase) em vez do full OIDC (Google como provider redirect-based). O
OIDC trocaria o mecanismo de login — justamente o que nenhum teste exercita
hoje — mudaria UX e exigiria client-secret/redirect URI no Google Console.
"Sem quebrar nada" pesou pela rota incremental.
Consequências¶
- A autenticação passa a fluir pelo framework (
Authentication,@Secured,SecurityRule) — fim do filtro paralelo (~217 linhas). - Paridade provada: o
AuthIntegrationTest(302/401/403, callback, logout, jornada, Bearer) passou sem alterar asserções. É a rede que protege os próximos passos. - Sessão (JWT,
SessionTokenService) e CSRF (CsrfTokens) permanecem caseiros por decisão — a migração deles paramicronaut-security-jwt/-csrffoi avaliada e descartada: a config nativa é sempre-ligada e exige segredo HS256 válido no startup, enquanto a auth deste app é condicional (segredo vazio em dev/test bypass e em prod mal-configurado). Conciliar isso preservando o fail-closed exigiria mudar a semântica (segredo random-por-boot ou fail-fast no startup) — risco de segurança que não compensa ~150 linhas. O caseiro é limpo, testado e já fica atrás do fetcher nativo (o ganho estrutural veio no passo 1). O CSRF nativo seria multi-aba seguro (stateless HMAC), mas esbarra na mesma impedância do segredo.
Alternativas consideradas¶
- Full OIDC (micronaut-security-oauth2): maior redução de código, mas alto risco e mudança de UX/infra; o caminho novo não tem teste. Descartado por ora.
- Manter o
AuthFilter: contraria o objetivo de usar o framework e mantém a divergência com a auth da API. Descartado.
0019 — Bump major para Java 25 + Micronaut 5¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-17 · Decidido em: 2026-06-17
Contexto¶
O projeto estava em Micronaut 4.7.6 / Java 21 — uma major atrás. O salto para Micronaut 5 exige Java 25 baseline e traz breaking changes que não são óbvios no diff; registrar aqui evita que o próximo bump redescubra cada um na marra.
Decisão¶
Subir para Java 25 (GraalVM 25.0.3) + Gradle 9.5.1 + Micronaut 5.0.2. O Gradle
roda em Java 25 (o plugin io.micronaut.application 5 exige). Efeitos colaterais
resolvidos no mesmo trabalho:
- Serde default: o MN5 expõe
JsonMapper, não registra maisObjectMapperpor padrão → umJacksonObjectMapperFactoryprovê o bean que Telegram/Coolify/ exporter injetam (era a causa sistêmica de ~24 falhas de DI). - jackson-databind não vem mais transitivo do
micronaut-jackson-databind→ dependência explícita nobuild.gradle. ViewModelProcessor<T, R>(micronaut-views 6) ganhou um 2º type arg.- Checksum S3: o AWS SDK ≥2.30 liga flexible checksums (CRC32) que o Garage
rejeita →
RequestChecksumCalculation.WHEN_REQUIREDno cliente S3. - Testcontainers com versão explícita (o BOM não propagava sob Gradle 9).
Consequências¶
- Stack atual, CVEs do Netty corrigidos, baseline alinhado ao framework.
- CI/Docker (
temurin:25,setup-java 25) precisam de um run real de CI para confirmar — validar no primeiro PR antes do merge. - Jackson 2 → 3 consolidado (feito depois): o código migrou para o namespace
tools.jackson.*(Jackson 3, que o MN5 usa) —ObjectMapperimutável viaJsonMapper.builder(), exceptions unchecked (JacksonException), java.time built-in,DateTimeFeature/StreamWriteFeatureno lugar das flags movidas. As deps explícitas de Jackson 2 (jackson-databind,jackson-datatype-jsr310) saíram do build — o uber-jar deixa de carregar as ~800 classes duplicadas. As anotações@Json*continuam emcom.fasterxml.jackson.annotation(compartilhado). Contrato do export canônico preservado (gate:ProcessamentoConformidadeTest).
Alternativas consideradas¶
- Ficar no último 4.x (Java 21): pega CVEs sem exigir Java 25, mas adia o inevitável e não destrava os primitivos do MN5. Foi o passo intermediário testado; seguimos para o 5 por ser a base alvo.
0020 — Serde (build-time) e HTTP declarativo¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-17 · Decidido em: 2026-06-17
Contexto¶
A serialização HTTP usava micronaut-jackson-databind (Jackson por reflexão em
runtime); os @Serdeable nos DTOs estavam inertes. E o TelegramService montava
o POST ao Telegram à mão com java.net.http.HttpClient + serialização manual de JSON.
Ambos eram código caseiro onde o framework oferece o primitivo recomendado.
Decisão¶
- Serialização HTTP →
micronaut-serde-jackson(o serde, recomendado pelo Micronaut): geração de serializers em build-time (sem reflexão), allowlist de segurança (só tipos@Serdeablesão (de)serializáveis — evita desserialização acidental de tipos arbitrários). Os DTOs de resposta ganharam@Serdeable. O serde 3.0.0 usa Jackson 3 (tools.jackson) como backend — sem conflito com o Jackson 3 já adotado (decisão 0019). - Jackson direto permanece para a árvore JSON:
CoolifyClient(parse deJsonNode) eProcessamentoJsonExporter(export canônico) — o serde não tem API de árvore equivalente, e oserde-jacksontraz otools.jackson.databindtransitivo. TelegramService→@Clientdeclarativo (TelegramApi): some oHttpClientmanual, a montagem de JSON e oJacksonObjectMapperFactory(que perdeu o injetor). Custo aceito:micronaut-http-clientpassa a ser dependência de runtime.CoolifyClientNÃO migra para@Client: o tratamento de erro de domínio dele é load-bearing (cada falha vira mensagem específica que o reset propaga) e a URL/token vêm de config em runtime — fica emjava.net.httpde propósito.
Consequências¶
- HTTP (de)serialização build-time + allowlist de segurança; DTOs explicitamente
@Serdeable.TelegramService~50 linhas mais enxuto e declarativo. - O contrato JSON (de resposta e do export canônico) é preservado — suíte completa
verde (293), incl.
ProcessamentoConformidadeTest. - O projeto passa a ter
micronaut-http-clientno runtime (revertendo o antigo "sem dependência HTTP nova" — decisão consciente, só pro Telegram).
Alternativas consideradas¶
- Manter jackson-databind reflexivo: funciona, mas é o caminho não-recomendado e
os
@Serdeableficariam inertes. Descartado por alinhamento + segurança. - Migrar tudo para serde (inclusive a árvore do Coolify/exporter): o serde não oferece API de árvore equivalente; reescrever seria pior. Mantém-se jackson direto ali.
@Clienttambém no Coolify: descartado — perderia o tratamento de erro de domínio.
0021 — Erro de API em RFC 7807¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-18 · Decidido em: 2026-06-18
Supera a decisão 0017 (corpo
{code, message}), que rejeitou o RFC 7807 "por simplicidade".
Contexto¶
A 0017 adotou um corpo de erro custom {code, message}. Ao consolidar o padrão
Micronaut da casa (skill micronaut), ficou claro que o default da casa para erro
de API é RFC 7807 / 9457 (application/problem+json) — padrão IETF, e este app
era o outlier. Como o app deve seguir o default da casa salvo divergência
justificada (§1.6), e o {code, message} não tinha vantagem real sobre o 7807,
alinha-se ao padrão.
Decisão¶
Erro de API no formato RFC 7807 (ProblemDetail): type (about:blank quando
sem tipo específico), title (resumo do status), status (HTTP), detail
(mensagem) e code (extensão — código estável legível por máquina, preserva o
machine-code da 0017).
Implementação: o MN5 não traz ProblemDetail nativo (não há classe; o módulo
micronaut-problem-json Zalando não está no projeto). Mantém-se o
UnifiedErrorResponseProcessor (que já substituía o Hateoas default), agora emitindo
o shape 7807 + Content-Type: application/problem+json. Sem dependência nova.
Atualização (0024): o
ProblemDetaile oUnifiedErrorResponseProcessorforam depois extraídos para a libxadm-comum-web(decisão 0024) — hoje vêm debr.com.xadm.comum.web, não de cópias locais. O shape 7807 e ocodeestável seguem idênticos; a "sem dependência nova" valia à época desta decisão.
Consequências¶
- Contrato de erro padrão, interoperável; o
codeestável continua disponível como extensão. - Menos custom no longo prazo (alinha à casa; futuros apps herdam o mesmo formato).
- O corpo de erro mudou de
{code, message}para o objeto 7807 — clientes que liammessagepassam a lerdetail. Único ponto de atenção (interno; sem cliente externo dependente hoje).
Alternativas consideradas¶
- Manter
{code, message}(0017): custom, divergente do default da casa, sem vantagem. Descartado. - Módulo
micronaut-problem-json(Zalando): dá os tipos/exceções 7807 prontos, mas adiciona dependência + modelo de exceção próprio (ThrowableProblem). Para middleware interno, o shape 7807 via o processor existente é mais leve. Fica como opção se um app quiser os tipos Zalando.
0022 — Organização package-by-feature¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-18 · Decidido em: 2026-06-18
Contexto¶
O código nasceu package-by-layer: pacotes globais api/, service/,
repository/, domain/, security/, dto/, exception/ — cada um juntando
arquivos de funcionalidades diferentes. Ao consolidar o padrão Micronaut da
casa (skill micronaut, decisão de Estrutura de código), o default ficou
sendo package-by-feature: cada funcionalidade carrega suas próprias camadas, e
o que é transversal vai para uma base comum. Este app era o outlier; como ele
deve seguir o default da casa salvo divergência justificada (§1.6), migra-se.
Decisão¶
Package-by-feature com camadas Controller → Service → Repository dentro de
cada feature. Pacotes de topo sob br.com.vantroba.bi.transporte:
| Pacote | Papel |
|---|---|
processamento |
pipeline XLS ponta a ponta: controllers, services, repositórios, entidades bi_*/xls_*, storage; subpacote processamento.processing — leitura Excel SAX (POI streaming) e mapeamento de colunas |
powersync |
reset do PowerSync e o que orbita o sinal de reset |
configuracao |
configuração global runtime (admin) |
seguranca |
as duas portas de auth — Bearer estático (/api/**) + login Google das views, handlers de rejeição |
comum |
base transversal, não é feature: comum.config (typed config, modos de storage), comum.exception (só StorageIndisponivelException, 503 do domínio XLS), comum.util, comum.notificacao (Telegram). A infra web (RFC 7807 ProblemDetail + processor, exceções HTTP base, health, Sentry, versão) migrou para a lib br.com.xadm.comum.web — decisão 0024 |
Regras:
- Entidade (
@MappedEntity) não cruza o controller. A borda HTTP fala DTO (record+@Serdeable) na API ou view-model no Thymeleaf. - Injeção por construtor; service concentra regra de negócio e
@Transactional; repository só acessa dado (Micronaut Data JDBC, decisão 0001). comumnão depende de feature — sem exceção. O Telegram fica partido em transporte (comum.notificacao.TelegramService, recebe texto pronto) e formatação (um notificador por feature:ProcessamentoNotificador,ResetarPowersyncNotificador), então quem conhece o domínio é a feature, nãocomum.
Trava: ArchUnit (divergência consciente do default da casa)¶
A skill da casa diz que a governança das camadas é convenção + revisão de PR +
./gradlew check, e descarta ArchUnit. Este app mantém o ArchitectureTest
(que já existia para o layout antigo), reescrito para três invariantes do
package-by-feature:
- sem ciclos entre as features de topo (a invariante central);
- nenhum subpacote de
comum(inclusivecomum.notificacao) depende de feature; processamento.processingnão depende da camada HTTP (nada que termine emController) — a leitura do Excel não conhece HTTP.
Mantido porque o teste já estava no projeto (custo zero), roda em ./gradlew
check (não é dependência nova) e trava exatamente as invariantes que importam no
package-by-feature. É divergência declarada do default da casa → registrada
como feedback §6 para a constituição reavaliar o "ArchUnit descartado"
categórico.
Consequências¶
- O código de uma feature fica junto — some o vai-e-volta entre seis pastas de camada conforme o app cresce.
comumvira a fronteira explícita do que é transversal; o ArchUnit impede que ela volte a depender de feature por descuido.- Migração mecânica (mover arquivo + ajustar
package/imports), sem mudança de comportamento: 108 classes main + 44 de teste movidas, suíte verde (293 testes, ArchUnit e checkstyle limpos).
Alternativas consideradas¶
- Manter package-by-layer: era o estado do app; divergente do default da casa, dispersa os arquivos de uma mesma funcionalidade. Descartado.
- Remover o ArchUnit para alinhar 100% à casa: descartado por ora — o teste já existia e trava as invariantes do package-by-feature de graça. A reavaliação fica para o §6 (se a casa mantiver "sem ArchUnit", removê-lo aqui é trivial).
0023 — Adotar a lib xadm-seguranca¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-08-04 · Decidido em: 2026-08-04
Contexto¶
O motor de auth das views nasceu caseiro (decisão 0018)
e era cópia byte-idêntica da mesma stack em ≥3 apps X-Adm (onpetro, thoms, vantroba): validador
do Firebase ID token, sessão JWT HS256, CSRF determinístico, Bearer estático, config auth.*. Três
cópias do validador do IdP Firebase compartilhado significam três lugares para corrigir uma
falha de segurança e três chances de divergirem. O motor foi extraído para a lib canônica
br.com.xadm:xadm-seguranca (o vantroba foi a variante-fonte da extração — Javadoc completo).
Decisão¶
Depender de xadm-seguranca:0.2.0 (Forgejo Packages da org xadm, pública → leitura anônima,
mesma fonte da decisão 0015) e deletar as 9 cópias locais do
motor (AuthException, AuthSettings, MicronautProfiles, StaticBearerTokenValidator,
CsrfTokens, FirebaseIdTokenValidator, SessionTokenService, SessionAuthenticationFetcher,
AuthSupport). Migrar os consumidores para br.com.xadm.comum.seguranca. Fica local só o que é
do app: a policy de rota (ViewSecurityRule, ViewRejectionHandler), o GlobalViewModel, o
LoginController e a whitelist base (ViewWhitelist).
Duas divergências do byte-idêntico, por design:
- Issuer virou config. O
SessionTokenServiceda lib lê oissdeauth.issuer(antes hardcoded"bi-transporte-xls"). Setadoauth.issuer: ${AUTH_ISSUER:bi-transporte-xls}com default fixo = valor idêntico ao hardcode ⇒ zero sessão invalidada e deploy/dev/test sobem sem setar a env. A lib fail-closa se não achar issuer; com o default estático esse fail-closed é rede-morta (só dispara seAUTH_ISSUERfor setado vazio). - Whitelist é local. Cada app tem a sua (o vantroba não tem
/compras/,/webhook/nemisPublicView); a lib traz o contrato + helpers (AuthSupport: constantes,deveRecusarAnonimo,isHttps) mas não a whitelist. Ela virouViewWhitelistlocal;ViewSecurityRuleeViewRejectionHandlerconsomemViewWhitelist.isWhitelisted.
Consequências¶
- Uma fonte só do validador Firebase/sessão/CSRF; um bump de lib corrige os 3 apps.
- Paridade provada: o
AuthIntegrationTest(login Firebase →xadm_session→ rota protegida → CSRF) passa contra o artefato 0.2.0 publicado, sem alterar asserções — o default de issuer supre o claim sem tocar nogetProperties(). - Os testes-espelho do motor (que usavam membros package-private / o test-seam
preloadCacheForTesting) foram deletados — a fonte da verdade deles é oxadm-commonsda lib. Sobra local a cobertura da parte do app:ViewWhitelistTest,AuthExceptionHandlerTest,LoginControllerTest. - A superfície pública da 0.2.0 foi verificada contra o artefato (
javap/jar tf), não assumida:preloadCacheForTestingpúblico,AuthSettings.getIssuer(),AuthSupportsem whitelist, e as bean definitions Micronaut presentes (senão a DI não descobriria os beans).
Alternativas consideradas¶
- Manter as cópias locais: sem acoplamento à lib, mas 3 validadores do IdP compartilhado divergindo e 3 lugares para corrigir uma falha. Descartado — é o risco que motivou a extração.
- Unificar a whitelist na lib: cada app tem paths públicos distintos; forçar uma whitelist única
vazaria política de um app pro outro. Descartado — a whitelist fica local (
ViewWhitelist). - Full OIDC já fora descartado na 0018 (muda UX/infra, sem teste). Fora de escopo aqui.
0024 — Adotar a lib xadm-comum-web¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-08-05 · Decidido em: 2026-08-05
Contexto¶
A infra web transversal do app — corpo de erro RFC 7807 (ProblemDetail +
UnifiedErrorResponseProcessor, decisão 0021), as exceções HTTP base
(BadRequest/Conflict/NotFound/Forbidden), o HealthController, o SentryInitializer (Bugsink) e o
VersaoInfo (/info + log de startup) — vivia em comum/ e era cópia byte-a-byte da mesma
stack em ≥3 apps X-Adm. Cada cópia é mais um lugar para corrigir e mais uma chance de divergir. O
conjunto foi extraído para a lib canônica br.com.xadm:xadm-comum-web (ADR 0019 do xadm-commons;
o vantroba foi a variante-fonte). As cópias locais em comum/ divergiam do canônico comum.web.
Gêmeo da decisão 0023 (motor de auth), que deixou o
AuthExceptionHandler local com re-extração adiada para cá.
Decisão¶
Depender de xadm-comum-web:0.1.0 (Forgejo Packages da org xadm, leitura anônima — mesma fonte
da decisão 0015 e da 0023) e deletar as 9 cópias locais:
ProblemDetail, UnifiedErrorResponseProcessor, BadRequestException, ConflictException,
NotFoundException, ForbiddenException, HealthController, SentryInitializer, VersaoInfo.
Migrar os consumidores (~22 fontes) para br.com.xadm.comum.web e repointar o
seguranca/AuthExceptionHandler para o ProblemDetail da lib (fecha o adiamento da 0023).
Fica local só o que é do app: comum/util (ChecksumSha256, DataConverter,
AnosVeiculoParser — utilitário, não infra web), comum/exception/StorageIndisponivelException
(503, domínio XLS — estende HttpStatusException, sem dep nas classes movidas), comum/config
(typed config do app) e comum/notificacao (Telegram do app). Nenhum pacote local é renomeado.
Consequências¶
- Uma fonte só do formato de erro / health / Sentry / versão; um bump de lib corrige todos os apps.
- Paridade provada contra o artefato
0.1.0publicado, sem alterar asserção:TransporteControllerTest(RFC-7807code+status,/health) eAuthExceptionHandlerTest(usaProblemDetail) verdes com-PdockerTests(plataforma real,@MicronautTest). - O teste-espelho do motor
SentryInitializerTestlocal foi deletado: chamavaconfigurar()/parseTracesSampleRate()package-private, que não compilam com a classe noutro pacote — a fonte da verdade dele é oxadm-comum-web, que traz o seu próprio. - Os beans da lib registram sozinhos: o
UnifiedErrorResponseProcessor(@Replacesdo Hateoas +@Singleton), oHealthController(@Controller) e oVersaoInfo(InfoSource) vêm com as bean definitions Micronaut compiladas no jar (senão a DI não os descobriria). Não recriar o processor local — o@Replacesvem da lib. - A geração por-app do
version.propertiescontinua (aVersaoInfo.versaoAtual()da lib o lê do classpath); osentry-logbackjá presente cobre ocompileOnlydo SDK Sentry que a lib declara. - Corpo funcionalmente equivalente ao local; o javadoc foi generalizado na lib (removidas refs app-específicas) — não é byte-idêntico, mas o comportamento HTTP é o mesmo do pré-adoção.
Alternativas consideradas¶
- Manter as cópias locais: sem acoplamento à lib, mas N lugares para corrigir o mesmo formato de erro/health e N chances de divergirem. Descartado — é o risco que motivou a extração.
- Recriar o
UnifiedErrorResponseProcessorlocal (só depender dos tipos): duplicaria o@Replacese o ponto único de render de erro. Descartado — o processor vem da lib. - Adotar já nos outros 3 apps / mover
utileStorageIndisponivelExceptionjunto: fora de escopo;utilnão é infra web e aStorageIndisponivelExceptioné domínio XLS (503) deste app.
0025 — Adotar as libs xadm-comum-storage / -util / -teste¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-08-11 · Decidido em: 2026-08-11
Contexto¶
Depois de adotar o motor de auth (decisão 0023) e a infra web
(decisão 0024), uma auditoria de aderência ao xadm-commons achou
mais três libs publicadas (xadm-comum-storage, xadm-comum-util, xadm-comum-teste, todas
0.1.0) que o app ainda duplicava localmente — ~13 classes cópia da mesma stack, extraída
deste app. A decisão 0024 deixou util explicitamente adiado
("fora de escopo; util não é infra web"); esta decisão fecha esse adiamento e estende o dedup ao
object storage (o design é a decisão 0008) e à infra de
teste (Testcontainers).
Decisão¶
Depender de xadm-comum-storage:0.1.0, xadm-comum-util:0.1.0 e
xadm-comum-teste:0.1.0 (Forgejo Packages da org xadm, leitura anônima — mesma fonte das
0023/0024) e deletar as 13 cópias locais, repointando os consumidores para
br.com.xadm.comum.{armazenamento,util,teste}:
- storage (7) →
br.com.xadm.comum.armazenamento:ArmazenamentoConfig,ArmazenamentoModo,S3Config,ArquivoXlsStorage,S3ArquivoXlsStorage,S3ClientFactory,ReferenciaObjeto. - util (2) →
br.com.xadm.comum.util:ChecksumSha256,DataConverter(a da lib é superset — trazfromDdMmYyyySlash/fromMmYyyy/fromDdMmYyyyDotalém dofromDdMmYyyyque o app usa). - teste (4) →
br.com.xadm.comum.teste:AbstractIntegrationTest,PostgresTestResource,PostgresTestPropertyProvider,GarageTestResource(testImplementation).
Fica local só o que é do app: comum/util/AnosVeiculoParser (parser de frota),
comum/exception/StorageIndisponivelException (503, domínio XLS), o comum/config typed do app,
comum/notificacao (Telegram), o ArchitectureTest (regras ArchUnit específicas dos pacotes
br.com.vantroba) e a composição de teste do app (IntegracaoTestPropertyProvider,
S3TestPropertyProvider, AbstractRepositoryIntegrationTest — limpeza das bi_*). Nenhum pacote
local é renomeado.
Consequências¶
- Prefixos de config idênticos (
app.storage.*,s3.*) — oapplication.ymle as envs (S3_FILE_STORAGE, credenciais S3) não mudam. Os 3 modos (PSQL/PSQL_GARAGE/GARAGE) e a chave content-addressed seguem os da decisão 0008 — a lib é a mesma impl extraída. - Os beans da lib registram sozinhos (
@Singleton/@ConfigurationPropertiescom as bean definitions Micronaut compiladas no jar) — a DI os descobre por classpath, como na 0024. - Testes white-box da impl deletados (
S3ArquivoXlsStorageKeyTest,S3ArquivoXlsStorageIntegrationTest): chamavamderivarChave/garantirBucketpackage-private, que não compilam com a classe noutro pacote — a fonte da verdade deles é oxadm-comum-storage. A cobertura de storage no app fica no seam (ProcessamentoServiceModoGarageTest,StorageBackfillService*Test, via a interfaceArquivoXlsStorage). GarageTestResource: o bucket de teste deixou de ser fixo"vantroba"e passou ao default"teste"da lib (parametrizável por-Dgarage.test.bucket). Transparente — o nome flui vias3Properties()(cria e usa o mesmo bucket); nenhum teste assertava"vantroba".- Paridade provada contra os artefatos
0.1.0, sem alterar asserção:./gradlew check -PdockerTestsverde (Postgres/Garage reais,@MicronautTest). ODataConverterTestlocal vira contract-test contra ofromDdMmYyyyda lib. - O
checkstyle.xmlcanônico (constituição 0.28.0) é mais estrito emConstantName; os campos@ArchTest(idioma ArchUnit, camelCase) ganharam umsuppressapp-específico nosuppressions.xml.
Alternativas consideradas¶
- Manter as cópias locais: sem acoplamento às libs, mas N lugares para corrigir a mesma impl de storage/util/infra-de-teste e N chances de divergirem. Descartado — é o risco que motivou a extração (mesmo raciocínio das 0023/0024).
- Só storage (adiar util/teste): menor superfície, mas deixaria de novo um adiamento explícito
em aberto (como o
utilda 0024). Descartado — as três são drop-in ou quase, feitas na mesma leva. - Preservar o bucket de teste
"vantroba"via-Dgarage.test.bucket: config extra por ganho nulo (o nome é arbitrário em teste). Descartado — adota-se o default"teste"da lib.
0026 — Migrar leitura de XLSX de POI para FastExcel (native-image)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-11 · Decidido em: 2026-08-19
Supersede a decisão 0002 (leitura via POI/SAX).
Contexto¶
O alvo é deployar o binário GraalVM native-image no lugar do jar — cortar RAM e tempo de
startup no servidor (Coolify). O bloqueio era o Apache POI: ele lê XLSX via XMLBeans, cujo
schema type-system é carregado por reflexão + recursos .xsb e não compila em native-image
(ClassCastException no StylesTable, sem fix estável no GraalVM CE). Enquanto POI estivesse no
grafo — inclusive só nos testes, arrastando o bridge log4j-to-slf4j — não havia native limpo.
A decisão 0002 já resolvia o problema de memória lendo em streaming (SAX, memória ~constante). O requisito novo (native) não invalida aquele — apenas exige outra lib de leitura streaming que compile em native.
Decisão¶
Ler XLSX com FastExcel (org.dhatim:fastexcel-reader), que usa apenas o StAX do JDK (zero
XMLBeans) e compila limpo em native, preservando o streaming linha-a-linha.
ExcelProcessorreescrito sobreReadableWorkbook/Sheet.openStream(), mantendo a semântica de célula idêntica ao reader SAX anterior (string→trim/null; numérico com formato de data→ddMMyyyy; numérico comum→bruto; boolean; erro/vazio→null). A paridade é garantida pelo golden-masterProcessamentoConformidadeTest(Java × Python sobre planilhas reais).- O path DOM legado (
ExcelCellValue,SheetSaxHandler,AbaConfigProcessor,AbaFaturamentoProcessor,AbaMovimentoProcessor) foi removido. O período virou o tipoPeriodoRelatorio. - POI zero, inclusive nos testes: a escrita de fixture XLSX migrou para o FastExcel-writer
(
org.dhatim:fastexcel) via o helper de testeXlsxFixtureWriter. POI elog4j-to-slf4jsaíram dobuild.gradle.ktspor completo. - Build native configurado no
build.gradle.kts(configure<GraalVMExtension>): perfil-PnativeQuick→-Ob(loop de dev), sem a flag →-Os(imagem menor, prod);--gc=serial.reflect-config.jsonregistra oSentryAppenderdo logback (instanciado por reflexão no boot). Evolução (2026-09-11): desde axadm-comum-web0.9.1 essa hint vem no jar da lib, com os métodos, maisSentryOptionseLevel.valueOf; oreflect-config.jsonlocal perdeu as entradas do appender e doSentryOptions.
Consequências¶
- Native-image desbloqueado: dá pra buildar/deployar o binário no lugar do jar.
- Memória ~constante mantida (streaming StAX), como na 0002.
isDateFormaté código nosso (reproduz oDateUtil.isADateFormatdo POI, que saiu do classpath) — coberto por teste unitário direto (ExcelProcessorDateFormatTest).- Ramo de leitura de data numérica sem teste dedicado (defer explícito): as planilhas reais do
cliente exportam datas como texto (verificado nas fixtures: zero célula com formato de data),
e o roundtrip FastExcel writer→reader não preserva a detecção de formato-de-data
(
getDataFormatString()voltanull). Logo não há como montar o input desse ramo via oXlsxFixtureWriter, e o ramo não dispara em produção. É código defensivo; mantido, mas sem teste de ponta a ponta — cobri-lo exigiria um.xlsxbinário forjado por ferramenta externa para código de valor ~zero. Se um dia o cliente passar a exportar datas como células Excel, revisitar.
Correção 2026-09-15: o roundtrip preserva o formato quando o reader abre com
ReadingOptions(true, false); sem otrue, ogetDataFormatString()voltanull. OExcelProcessorpassou a abrir assim, e o ramoasDate()ganhou teste de ponta a ponta com a célula de data escrita peloXlsxFixtureWriter.
Alternativas consideradas¶
- Manter POI só em
testImplementation: deixaria o objetivo (zero POI) pela metade e manteria o bridgelog4j-to-slf4j; sem ganho, já que o FastExcel-writer cobre a escrita de fixture. - Forjar
.xlsxbinário com data real para cobrir o ramoasDate: reintroduz dependência de ferramenta externa (Excel/LibreOffice/POI) e um binário no repo para exercitar código que dado real nunca toca. Descartado (baixo valor). - Remover o ramo
asDate/isDateFormat: perderia a defesa caso o export do cliente mude. Descartado — mantido como defensivo.
0027 — Bump das libs xadm-comum + prefixo de storage s3.* → garage.*¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-08-20 · Decidido em: 2026-08-20
Contexto¶
Auditoria de defasagem contra o xadm-commons (Forgejo Packages, org xadm): 4 das 5 libs
adotadas (0023, 0024,
0025) estavam uma minor atrás. xadm-comum-util
já na última (0.1.0).
| lib | de → para | mudança de API |
|---|---|---|
xadm-seguranca |
0.3.0 → 0.4.0 | aditiva (AuthSettings.getFirebaseJwksUrl) |
xadm-comum-web |
0.4.0 → 0.5.0 | nenhuma (API pública idêntica) |
xadm-comum-storage |
0.1.0 → 0.2.0 | ver abaixo |
xadm-comum-teste |
0.1.0 → 0.2.0 | GarageTestResource.s3Properties() → garageProperties() |
O xadm-comum-storage 0.2.0 trouxe duas mudanças que tocam o app:
- Interface nova
ObjetoStorage(+ implS3ObjetoStorage):S3ArquivoXlsStoragedeixou de receber(S3Client, S3Config)e passou a receber(ObjetoStorage). Refactor interno da lib — DI resolve sozinho (o app não constrói o bean à mão;compileJavado main passou limpo). - Prefixo de config
@ConfigurationProperties("s3")→("garage")noS3Configda lib. Oapplication.ymldo app amarrava sobs3:; precisa mover pragarage:ou o binding some (endpoint/credenciais nulos → storage quebra). Os componentes (endpoint, region, accessKey, secretKey, bucket, criarBucket) não mudaram. O modo (app.storage.modo/ envS3_FILE_STORAGE) não mudou — o prefixoapp.storagefoi preservado.
Decisão¶
Bumpar as 4 libs. Migrar o bloco s3: → garage: no application.yml, mantendo compatibilidade
das duas envs (S3_* antigas e GARAGE_* novas) durante a transição no Coolify.
Compatibilidade das duas envs — SEM placeholder aninhado¶
O reflexo é ${GARAGE_X:${S3_X:default}}. Não funciona no Micronaut: quando o default do
placeholder externo contém : (a URL do endpoint, http://…:3900), o resolver quebra o aninhamento
— o valor vaza como http://localhost:3900} (URISyntaxException, "Illegal character in
authority"). Backtick de escape só cobre um nível; aninhar backtick (`${…`…`}`) também
vaza (3900\}`).
Solução adotada — uma env por property, override por prioridade de fonte:
garage:
endpoint: ${S3_ENDPOINT:`http://localhost:3900`}
region: ${S3_REGION:garage}
# … demais componentes idem, lendo a env antiga S3_* como default
O YAML lê a antiga S3_* como default; a nova GARAGE_* sobrescreve automaticamente
porque a env var vira a property garage.* direto e a fonte de ambiente vence o
application.yml no Micronaut. Precedência efetiva GARAGE_* > S3_* > default, verificada
com teste discriminante (setar GARAGE_ENDPOINT inválido faz a instanciação do storage falhar
citando esse valor — prova que a env chegou na property).
Migração no Coolify (sem downtime)¶
Cadastrar as GARAGE_* com os mesmos valores das S3_* atuais e só então remover as S3_*:
| Novo (cadastrar) | Antigo (fallback) |
|---|---|
GARAGE_ENDPOINT |
S3_ENDPOINT |
GARAGE_REGION |
S3_REGION |
GARAGE_ACCESS_KEY |
S3_ACCESS_KEY |
GARAGE_SECRET_KEY |
S3_SECRET_KEY |
GARAGE_BUCKET |
S3_BUCKET |
GARAGE_CRIAR_BUCKET |
S3_CRIAR_BUCKET |
S3_FILE_STORAGE (o modo) não muda. Detalhe operacional: operacao/deploy;
mapa env→property: projeto/03-object-storage-garage.
Alternativas preteridas¶
- Placeholder aninhado
${GARAGE_X:${S3_X:default}}— quebra no Micronaut (colisão do:da URL com o separador de default). Descartada por evidência de teste, não por gosto. - Só
GARAGE_*, sem fallback — exigiria janela de downtime (renomear todas as envs no Coolify de uma vez). A cadeia de fallback torna a migração sem interrupção.
Consequências¶
- Migração de env no Coolify é gradual e reversível; as
S3_*seguem válidas até removê-las. - Gotcha de stack (vale pra outros apps Micronaut da casa): placeholder aninhado com
:no default é furado — preferir uma env por property + override por prioridade de fonte de ambiente.
0028 — Views em JTE no lugar de Thymeleaf¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-08-26 · Decidido em: 2026-08-26
Contexto¶
As telas server-render do bi-transporte (login, processamentos, admin) rodavam em Thymeleaf
(micronaut-views-thymeleaf). O alvo da casa é deployar o binário GraalVM native-image
(decisão 0026); sob native o Thymeleaf/OGNL resolve o modelo
por reflexão — cada tipo alcançado por template exigiria @ReflectiveAccess e a falha aparece
só em runtime (500), tela por tela: whack-a-mole de reflexão. A casa fixou o alvo na ADR central
0025 — Views server-render com JTE.
Decisão¶
Adotar JTE (gg.jte) como motor das views server-render, substituindo o Thymeleaf, seguindo a central 0025. Template compilado, type-check no build, zero reflexão de modelo em runtime.
Consequências¶
- Plugin
gg.jte.gradle3.2.4 casado ao runtimemicronaut-views-jte; templates emsrc/main/jte/**(login.jte,kit/,processamentos/,admin/),generateJteantes docompileJava. generate()mode — os.javagerados são compilados junto do app (native-safe; sem.binpor reflexão em runtime).contentType = Html.reflect-config.jsonde reachability-metadata dos templates gerados emsrc/main/resources/META-INF/native-image/jte-views/.- Checkstyle e JaCoCo excluem
**/gg/jte/generated/**(código gerado, não humano). - Os globais de view (currentUserEmail etc.) vêm do
XadmViewModelda libxadm-seguranca(decisão 0023), injetados noHashMapmutável do model.
Fora de escopo: o motivo e as alternativas da migração (vivem na central 0025).
0029 — Adota o smoke de produção pós-deploy¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-09 · Decidido em: 2026-09-09
Contexto¶
Este RD não decide nada novo: o smoke pós-deploy é norma da casa (constituição §9, ADR central 0031, página smoke-producao). O que faltava aqui era o rastro local — o que este app declarou como crítico, e por quê.
O problema alcança este repo em cheio: o POST /api/ci/deploy é assíncrono, então o pipeline
ficava verde antes de o container novo servir. E /health verde é liveness — prova que o
processo subiu e o Flyway passou, não que o app responde o que deveria. A frota já entregou, com
o container healthy, tela nova em 500 e <APP>_API_TOKEN ausente que só apareceu como 401 no
cliente.
Decisão¶
Adota o smoke declarando o manifesto no docs/app.json:
"smoke": {
"bases": ["https://excel.vantroba.xadm.biz"],
"glitchtip": { "org": "x-adm", "project": "bi-transporte-xls" },
"routes": [ { "path": "/health", "class": "public", "status": 200 } ]
}
Três escolhas locais, com o porquê:
basesé o fqdn PRINCIPAL, não os dois próprios. Canário 50/50 (excel-native.vantrobadono do principal +excel.vantroba-jar-pull): o principal é o que o cliente usa. Ponto cego declarado: com o split, uma sonda pode cair no par que ainda não trocou — o retry mitiga.- Só
/healthnesta onda. Camada 2 no mínimo honesto. Ampliar exige conferir cada rota contra ointercept-url-mapdeste app (anônimas:/health,/swagger/**,/public/**,/css/**,/images/**; o resto exige sessão).classque mente é o defeito caro que a norma descreve. session/m2mficam para depois. O seam exigexadm-seguranca ≥ 0.7.1(aqui 0.6.0),SMOKE_TOKENno secret e a propertysmoke.tokenno recurso Coolify.
Consequências¶
GLITCHTIP_API_TOKEN(read-only) é obrigatório nos secrets do repo no GitHub. Declararsmoke.glitchtipsem o secret REPROVA o smoke — é defeito de configuração, não degradação: sem ele a camada 3 não roda e o job ficaria "verde provando 3 de 4".- Bump
xadm-comum-web0.6.1 → 0.9.0, que é quem emite ocommitno/healthe resolve oreleasedo GlitchTip a partir deXADM_COMMIT. Sem ele a camada 1 degrada paraversaoe o rollback fica sem alvo. O bump traz junto oflavorno/health(0.7.0) e o 404 autenticado vai a ERROR (0.7.1) — este último aumenta o volume no GlitchTip de propósito: é o sinal que pegou rota podada pelo AOT em produção. - O
commitdo/healthpassa a ser o discriminador de entrega:XADM_COMMITentra como build-arg no pipeline e viraARG/ENVtarde no Dockerfile (declarado no topo, invalidaria o cache de toda camada abaixo a cada commit). Um identificador só atravessa pipeline, imagem,/healthe tag imutável. - Reprovar reverte para
<imagem>:<sha anterior>-<target>e confirma a reversão repolando o/health. Isso torna a tag imutável um ativo operacional: podar a tag que está no/healthde um recurso em produção transforma o rollback em "não confirmado" no pior momento. - Este RD é ponteiro: divergência de mérito sobre o mecanismo se resolve no ADR central 0031, não aqui. O que se decide localmente é o manifesto — quais rotas não podem quebrar.
0030 — Adota o CI 100% GitHub Actions e o deploy pelo control-plane¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-10 · Decidido em: 2026-08-31
Contexto¶
Este RD não decide nada novo: são quatro normas da casa, cada uma com o seu ADR central —
- o CI/CD 100% GitHub Actions (ADR central 0027):
um
pipeline.ymlúnico, Forgejo sem CI; - o control-plane de deploy (ADR central 0026): o CI dispara o deploy por API M2M no central-backend, sem ponte SSH;
- o native-image como alvo de deploy do server Micronaut elegível (ADR central 0022);
- o slug de registry (ADR central 0024):
o slug é o nome da imagem e pode divergir do
app_id.
O que faltava aqui era o rastro local — quando este app adotou e o que declarou.
Antes, o caminho foi em degraus: build native no modelo Docker com o slug vantroba-xls no
registry (7b967a1, 2026-08-24); deploy do native pela ponte SSH (34ef27c, 2026-08-28); build
do jar fora do host, com o Coolify só puxando a imagem (41c4fa3, 2026-08-29, e recurso pull em
c3b1585). Gate, doc e release ainda rodavam em workflows separados no runner Forgejo.
Decisão¶
Adota os quatro ADRs; o corte final foi em 2026-08-31, em dois commits:
2cdfcae— deploy pelo control-plane (0026). O CI chamaPOST https://central-backend.xadm.biz/api/ci/deploy, autenticado peloCENTRAL_DEPLOY_TOKEN(secret do GitHub), no lugar da ponte SSH. A primeira release entregue assim foi a v2.2.3 (2026-08-31).8e44eed— pipeline único no GitHub (0027). O.github/workflows/pipeline.ymljunta gate, doc, release, build e deploy; o repo não tem mais workflow no Forgejo.
Declarações locais:
- Três identidades, divergentes de propósito (0024). Slug de registry
vantroba-xls(= imagemfonte.xadm.biz/xadm/vantroba-xls, e o slug do portal de docs);app_idbi-transporte-xls(GlitchTip, Central de Apps); repovantroba-bi-xls(Forgejo). Não "corrigir" um pelo outro. - Dois alvos (0022):
build.targets: [jar, native]nodocs/app.json. Cada build publica a tag móvel<target>-amd64(a que o deploy pede) e a imutável<sha>-<target>— o alvo do rollback do smoke (0029). - Canário 50/50 no Traefik.
vantroba-xls-native-pullé dono do domínio principalexcel.vantroba.xadm.bize tem o fqdn próprioexcel-native.vantroba.xadm.biz;vantroba-xls-jar-pullresponde emexcel-jar.vantroba.xadm.biz. O principal divide 50/50 entre as duas pernas; o fqdn próprio isola uma perna. - O pedido de deploy não carrega uuid. Leva só alvo + imagem; quem resolve o recurso Coolify é
o central-backend (reconcile pelo slug da imagem). Os uuids ficam em
docs/app.json→deploy.*só como registro. - Deploy só no release: tag
v*(ouworkflow_dispatch). Push e PR rodam gate/doc por caminho alterado, sem deploy; o deploy espera o gate (gate vermelho não sobe).
Consequências¶
- As imagens são buildadas no runner do GitHub; o Coolify só puxa (auto-deploy desligado). O host de produção não compila nada.
- Recriar um recurso Coolify (uuid novo) não quebra o deploy: não há allow-list de uuid para ficar órfã (o problema que o 0026 resolve).
- Os segredos do CI vivem no repo GitHub:
CENTRAL_DEPLOY_TOKEN,FORGEJO_USER/FORGEJO_TOKEN(push no registry),DOCS_S3_*eGLITCHTIP_API_TOKEN(smoke, 0029). - Com duas pernas no mesmo domínio, as envs de runtime têm de ser iguais nos dois recursos — senão o canário responde diferente conforme a perna. Operação no runbook de deploy.
- Este RD é ponteiro: divergência de mérito sobre o mecanismo (pipeline único, control-plane, native, slug) se resolve nos ADRs centrais, não aqui. O que se registra localmente é quando e com que declarações o app adotou.
0031 — Mescla concorrente: duas defesas contra deadlock (40P01)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-10 · Decidido em: 2026-07-27
Contexto¶
Desde o envio em lote (0032), um .zip traz N planilhas e cada uma
vira um processamento próprio no executor processamento (4 threads): dois arquivos do mesmo lote
mesclam em paralelo no banco. A mescla é o UPSERT diff-aware + DELETE seletivo
(0006, 0007), uma transação
por arquivo, em bi_faturamento e bi_movimento.
Três fatos do PostgreSQL tornam isso um deadlock (40P01) latente — o Postgres derruba uma das
transações e o processamento vai a ERRO:
INSERT … ON CONFLICT … DO UPDATEtrava a linha conflitante mesmo quando oWHERE … IS DISTINCT FROMnão atualiza nada — linha inalterada também entra na disputa.- Sem ordem definida, cada transação adquire os row locks na ordem em que o
SELECTdaTEMP TABLEdevolve as linhas; duas transações que tocam as mesmas chaves em ordens diferentes se travam mutuamente. - O DELETE seletivo, na mesma transação, trava linhas na ordem do scan, que nenhum
ORDER BYcontrola.
O bi-comercial-xls tinha o mesmo risco e o corrigiu com o ORDER BY. Aqui o risco é maior, por
causa do DELETE seletivo.
Decisão¶
Duas defesas, ambas obrigatórias (commit ff1f811, release 2.1.3):
ORDER BYpela chave natural noSELECTdoINSERT … SELECT … ON CONFLICTdoBulkUpserter: a ordem de aquisição de row lock fica determinística entre transações concorrentes (convenção da frota bi-*).- Advisory lock de mescla:
ProcessamentoTransactionalHelper.mesclarDadosDoPeriodoabre, como primeira instrução, comBulkUpserter.adquirirLockMescla→SELECT pg_advisory_xact_lock(<chave>). Serializa a fase de banco inteira e cobre o que oORDER BYnão cobre: UPSERT × DELETE seletivo e DELETE × DELETE.
Detalhes que fazem parte da decisão:
- Uma chave só, global (
ADVISORY_LOCK_MESCLA, o ASCII de"bitransp"), para as duas tabelas: faturamento e movimento são sempre mesclados juntos, na mesma transação. pg_advisory_xact_lock, liberado sozinho no COMMIT/ROLLBACK da transação da mescla.adquirirLockMesclanão tem@Transactional, de propósito: exige a transação do chamador (e falha alto sem ela). Numa transação própria, o lock seria liberado na saída do método — inútil, em silêncio.- Espera acima de 50 ms vai para o log do processamento ("Aguardou N ms pelo lock de mescla").
Consequências¶
- Só a fase de banco enfileira; o parse do Excel (a fase cara) segue paralelo nas 4 threads.
- O custo é throughput de banco serializado: mesclas não se sobrepõem, nem entre lotes diferentes (a chave é global).
- Remover qualquer uma das duas reintroduz o deadlock com 2+ arquivos em paralelo. O
ORDER BYé guardado peloBulkUpserterSqlTest(pré-condição estrutural no SQL gerado); a invariante está no javadoc doBulkUpsertere no gotcha doCLAUDE.md.
Alternativas consideradas¶
- Só o
ORDER BY(a correção do bi-comercial-xls): não cobre o DELETE seletivo, cuja ordem de lock segue o scan. Insuficiente aqui. - Serializar o executor inteiro (uma thread): também elimina o deadlock, mas serializa o parse, que é a parte cara. O advisory lock serializa só o banco.
0032 — Envio em lote por .zip¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-10 · Decidido em: 2026-07-06
Contexto¶
Até a v2.0.x, o envio era POST /api/transporte/processar com um .xlsx por chamada. O ciclo
da Vantroba gera várias planilhas de uma vez: eram N chamadas, cada arquivo com a sua notificação,
sem noção de "este ciclo terminou". O bi-comercial-xls já recebia em lote por zip, e os
processadores XLS da frota deviam falar o mesmo contrato.
Decisão¶
POST /api/xls/processar,multipart/form-data, campo únicoarquivo: um.zipcom 1..N.xlsx. O zip é o lote. O cliente não manda período — ele vem da aba 10 de cada planilha (0016).- O servidor gera o id do lote:
conjuntoId = "zip-"+ os 60 primeiros hex do SHA-256 do zip (cabe noVARCHAR(64); o mesmo zip gera o mesmo id). Cada.xlsxválido vira umxls_processamentopróprio, com checksum próprio, carimbado com oconjunto_id. - Migration V16 (
conjunto_lote_e_zip): tabelaxls_conjunto_lote(conjunto_idPK,total,duplicados,nao_processados,criado_em,notificado_em) e colunaconjunto_id, com índice, emxls_processamento. - Resposta
202= recibo do lote:conjuntoId,totaleitens[]com ohttpStatuspor arquivo (202enfileirado,200já processado,409em andamento,400não aceito) — a idempotência por checksum continua valendo por arquivo.400no nível do lote: zip inválido, vazio ou sem.xlsx. - O lote fecha quando todas as entradas terminam — contando as duplicatas já processadas e as
entradas não processadas (tipo/magic inválido ou já em andamento) — e então sai um resumo
único no Telegram (
notificado_em).
Consequências¶
- Quebra de contrato (v2.1.0): endpoint e payload mudaram, e as integrações com o endpoint
antigo tiveram de migrar (o
@OpenAPIDefinitionfoi a2.0.0na v2.1.1). Contrato narrado emdev/api-rest. data_referencia/hora_referenciaemxls_processamentoviraram legado (não populadas).- Arquivos do mesmo lote processam em paralelo — concorrência na mescla, que expôs o deadlock 40P01 resolvido em 0031.
- Continua sem tela de upload: o envio é só por API.
Alternativas consideradas¶
- Manter o
.xlsxavulso (status quo): N chamadas e N notificações por ciclo, sem fechamento de lote, e contrato divergente do bi-comercial-xls. Descartado.
0033 — Adota a tela de login da xadm-seguranca e renomeia o token de API¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-10 · Decidido em: 2026-09-10
Contexto¶
Três normas da casa alcançaram este app ao mesmo tempo, na migração para a constituição 1.4.10:
- A tela de login passou a ser da lib (ADR central 0032,
xadm-seguranca0.7.1). A frota tinha cincologin.jtecom o mesmo mecanismo de auth copiado (Firebase,signInWithPopup,POST /login/callbackcom CSRF); um bug de auth custava cinco PRs. Este app tinha o dele emsrc/main/jte/login.jte. - Piso
xadm-seguranca≥ 0.7.2 (constituição 1.4.8). A 0.7.2 recusa o boot quandoapp.api-tokenestá declarado e vazio. Abaixo dela, o par feature ligada + env ausente subia healthy e respondia 401 a tudo em/api/**, e o sintoma chegava horas depois como "a integração parou". Este app declaraapp.api-token(Bearer doPOST /api/xls/processar). - Env de token nomeada pelo destino (seguranca):
<APP>_API_TOKEN.API_BEARER_TOKEN(nomear pelo papel) é o anti-padrão citado nominalmente — o mesmo nome vira valores diferentes por servidor e não diz a direção.
Decisão¶
- Sobe
xadm-seguranca0.6.0 → 0.7.2 e apaga ologin.jtelocal. A tela vem da lib (xadm/login.jte); aqui ficam só os cosméticos, emxadm.views.login.*, escolhidos para manter a tela que o usuário já conhecia:marca: true, a tagline "Um ERP Completo para sua empresa" e o rodapé "BI Transporte · Processador XLS". O título é derivado pela lib ("Login — BI Transporte"). - Re-deriva o kit de UI (
kit/layout.jteecustom-theme.css, verbatim do central) e adota o Bootstrap 5.3.8 vendorizado da casa em/css/e/js/. A cópia local 5.3.3 empublic/vendor/era uma divergência consciente (air-gap, sem CDN); desde a constituição 1.4.0 a casa também vendoriza, então a divergência perdeu o motivo — e o passo 5 da migração da lib manda apagar a cópia própria. O SDK do Firebase segue nogstatic, de propósito (é da lib). - Libera
/js/**nos três lugares que decidem o acesso a estático neste app:static-resources,intercept-url-mape aViewWhitelist. Sem isso o bundle JS responde 401 — inclusive na tela de login, onde por definição não há sessão. - Renomeia
API_BEARER_TOKEN→VANTROBA_XLS_API_TOKEN(o destino é este app, slugvantroba-xls). A property segueapp.api-token, que é o que a lib valida. Sem janela de convivência dos dois nomes: o Micronaut não aninha placeholder (${A:${B}}), e a guarda do pipeline reprova o aninhamento — o rename é atômico.
Consequências¶
- Deploy da release que traz isto exige a env nova ANTES, nos dois recursos do Coolify
(
vantroba-xls-jar-pullevantroba-xls-native-pull), com o mesmo valor da antiga. Sem ela, oBearerTokenGuardrecusa o boot: o container novo não fica healthy, o rolling update não troca e o smoke pós-deploy (0029) reprova. É ruidoso de propósito. Depois do deploy verde, apague aAPI_BEARER_TOKENantiga. - Quem chama a API não muda nada: o valor do token é o mesmo; só o nome da env no servidor mudou.
EstaticosDoKitTesttrava a classe de defeito do item 3: lê oshref/srcdokit/layout.jtee da tela de login renderizada e exige 200 anônimo para cada um, com a segurança ligada; e confere que o tema traz as classes.xadm-login-*(tema velho + lib nova = marca sem estilo, e nenhum gate amarra os dois repos).- A tela de login deixa de ser código deste app: mudança de markup ou de fluxo de auth é PR na lib. O custo declarado pelo ADR central — o header da tela de login é duplicado do kit e pode divergir visualmente do header logado — vale aqui também.
- Complementa a 0023 (adoção da lib) e a etapa 07 (login das views).
0034 — Adota a constituição 2.0.0: só native, libs na corrente e mavenLocal filtrado¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-15 · Decidido em: 2026-09-15
Contexto¶
A constituição da casa passou à 2.0.0, com norma e kit versionados à parte (ADR central 0036). Duas decisões centrais alcançam este app: server Micronaut é native por padrão, e jar só com motivo (ADR central 0033); e o app acompanha a versão corrente de cada lib da casa (ADR central 0034).
O app já servia 100% native no domínio principal: o canário 50/50 não estava efetivo, e cada release religava uma perna jar sem tráfego.
Decisão¶
- Só native. O
build.targetsdodocs/app.jsontem sónative, e opipeline.ymlnão tem mais obuild_jarnem o deploy jar. ODockerfileJVM fica no repo como fallback, fora do deploy; o recurso jar do Coolify fica parado. - Libs na corrente:
xadm-seguranca0.8.0,xadm-comum-web0.10.0,xadm-comum-storage0.3.0 exadm-comum-teste0.4.0, com a seção Migração de cada uma aplicada. - Versão ainda não publicada vem do
mavenLocal, filtrado: depois do registro e só parabr.com.xadm, conforme Bibliotecas da casa. A versão publicada sempre sai do registro. - Credencial do Garage pelo dual-read da lib: o
application.ymlmantém o par antigo (garage.access-key/garage.secret-key) como fallback, e axadm-comum-storagelê antes os nomes do broker (GARAGE_ACCESS_KEY_ID/GARAGE_SECRET_ACCESS_KEY). - Smoke com rota de cada classe de credencial:
m2mna listagem do reset esessionem/processamentos, com o seam de sessão daxadm-segurancaligado porSMOKE_TOKEN. - Histórico de execução no volume de
/app/data: o log por processamento que a UI exibe é dado do app;app.logs.dirpassa adata/execucoes, e a V17 reescreve os caminhos já gravados.
Consequências¶
- Uma perna só para operar, verificar e reverter (tag imutável
<sha>-native). Voltar ao jar exige decisão nova com o motivo. - A
/xadm-releaserecusa enquanto alguma lib declarada não estiver no registro: as quatro versões acima precisam estar publicadas antes da próxima release deste app. - O par antigo da credencial sai numa release futura, quando o recurso do Coolify tiver os nomes do broker.
- O smoke passa a exigir a env
SMOKE_TOKENno recurso e os secretsSMOKE_TOKENeSMOKE_M2M_TOKENno GitHub. - Detalhe operável: Deploy.
Alternativas consideradas¶
- Manter o canário jar + native: duas pernas sem ganho medido (100% native no domínio principal) e o dobro de recurso a manter.
- Cadeia
${GARAGE_ACCESS_KEY_ID:${GARAGE_ACCESS_KEY:${S3_ACCESS_KEY:…}}}no YAML: o Micronaut não aninha placeholder, e a guarda dopipeline.ymlreprova. - Nomes novos no YAML com fallback
S3_*: o app subiria com a credencial de dev se o recurso só tivesse o nome antigo do broker.
Glossário do projeto¶
Vocabulário do domínio transporte que este app usa. Termos da plataforma X-Adm (Forgejo, Coolify, Garage…) não são redefinidos aqui — linkam para o glossário da plataforma.
Planilha e domínio¶
- Aba 10
- Aba do Excel com o período/configuração do relatório (metadados lidos pelo processador).
- Aba 20
- Aba com os registros de faturamento de fretes.
- Aba 30
- Aba com os registros de movimento de frota (movimentação contábil).
- Período
- Intervalo de datas coberto pelo relatório, extraído da aba 10 da planilha
(E1 = data inicial, F1 = data final). É o que delimita o DELETE seletivo do
diff-aware. Não confundir com a data de referência (
data/horado upload = quando o relatório foi gerado, metadado) — ver decisão 0016. dt_frete- Data do frete no layout importado (
ddMMyyyyno Excel). dt_em- Data de emissão do documento fiscal.
data_lcto- Data de lançamento contábil.
tipo_frota- Indica se a placa é frota da empresa ou de terceiros — valores típicos
EMPRESAouTERCEIROS(importado das colunas AH / AB nas abas 20 / 30). marca_veiculo- Marca do caminhão (colunas AI / AC).
modelo_veiculo- Modelo do caminhão (colunas AJ / AD).
ano_construcao/ano_modelo- Anos derivados do texto
AAAA/AAAAna planilha (colunas AK / AE): à esquerda da barra = construção; à direita = modelo. - Checksum
- SHA-256 do conteúdo binário do
.xlsx; chave de idempotência — evita reprocessar o mesmo arquivo após sucesso.
Infraestrutura específica¶
- PowerSync
- Serviço de sincronização que replica as tabelas
bi_*do PostgreSQL para os clientes móveis (SQLite). Aqui as tabelasbi_faturamento,bi_movimentoebi_configuracaosincronizam; asxls_*são locais por design. - Resetar Powersync
- Operação destrutiva que zera o estado replicado (slot do PostgreSQL +
MongoDB do PowerSync) e força o re-sync dos clientes. No código, o serviço
se chama
ResetarPowersyncService. Procedimento no runbook de operação.