Performance — Relatórios de Emplacamento (foco: Por Marca)
Análise do loading lento das telas de relatório. Levantamento feito em
2026-07-28, branch feature/vona/relatorios-emplacamento-backend. Atualizado
no mesmo dia após a página já ter fundido a 1ª onda (getReportData +
getMonthlyComparison + opções de filtro num único Promise.all) e a
inclusão do chassi-service.ts (drill-down por chassi no drawer).
Atualizado novamente no mesmo dia — itens 1, 4, 5, 6 e 7 (quick wins "só no app") foram implementados. Ver checklist na tabela priorizada abaixo. Os demais itens (2, 3, 8, 9, 10) seguem como backlog — 2 e 3 já eram cobertos pelo item 1; 8 já era coberto na prática (ver nota no item); 9 e 10 seguem pendentes de decisão/investigação. Itens que exigem o time de dados (índices, view de chassi, histórico multi-ano) continuam bloqueados, sem mudança.
Todas as queries estão contra dw_emplacamento_mart.emplacamento_kpi_dia
(agregado diário) via postgres.js, exceto o drill-down de chassi que usa
emplacamento_detalhe + joins no star schema cru. Cliente: singleton em
por-territorio/_lib/server/supabase-dw-client.ts, reusado por todos os
relatórios (por-marca, por-territorio, por-ranking, de-acessos).
Como as queries rodam hoje (cold load / cache miss)
por-marca/page.tsx:51-113 — 3 ondas, 2 delas já paralelas entre si
1. await getAvailableMonths() ← 1 query, bloqueia tudo
(decide o mês/anchor)
2. await Promise.all([ ← onda A — JÁ FUNDIDA
service.getReportData(grupoId, filters) → internamente:
getLastUpdated
+ monthDatesRows (dep. de nada externo)
+ aggRows (dep. monthDatesRows)
+ dailyRows (dep. monthDatesRows)
— aggRows/dailyRows hoje SEQUENCIAIS
service.getMonthlyComparison(grupoId, filters) → internamente:
getLastUpdated (2ª vez, redundante)
+ aggRows
+ currentMonthCountRows — SEQUENCIAIS
getFieldsAreaAbrangencia() ← array_agg no mart INTEIRO (~2,6M linhas)
getValidEmplacamentosCategories() ← bate em dw_emplacamento.dim_veiculo (não no mart)
getTypeOfSales()
])
3. await Promise.all([ ← onda B (4 KPIs, paralela entre si,
getEmplacamentosIn, getMonthDailyAverage, mas SEQUENCIAL em relação à onda A)
getMarketLeader, getTotalBrandsAndTotalEmplacamentos
])
A fusão que o doc anterior recomendava (item antigo #3, unir getReportData +
opções de filtro) já foi feita. O que falta é a onda B: ela só depende do
anchor calculado no passo 1, não de nada da onda A — hoje espera a onda A
terminar por estar em um await separado.
Dentro de brand-service.ts — getReportData (linhas 517-665)
getLastUpdated() (linha 521, MAX(data))
→ monthDatesRows (558) — depende de ano/mes (calculados de getLastUpdated OU filters.intervalStart)
→ aggRows (590) — depende de monthDatesRows via `currentDay` (linha 580-582,
usado no corte do acum_mes_ant)
→ dailyRows (633) — depende de monthDatesRows via `days` (linha 576)
aggRows e dailyRows não dependem um do outro — só de monthDatesRows
(ambos usam currentDay/days derivados dela). Hoje rodam em sequência
(dois await seguidos, 590 e 633). getLastUpdated → monthDatesRows é
dependência genuína (precisa do ano/mês antes de filtrar por eles), a não ser
que filters.intervalStart já esteja setado (então getLastUpdated nem
precisaria rodar, mas roda sempre — ver item novo #8 abaixo).
Dentro de brand-service.ts — getMonthlyComparison (linhas 667-760, novo)
getLastUpdated() (671) — MESMA query que getReportData acabou de rodar
→ aggRows (700) — depende de ano/mes/currentDay (de getLastUpdated)
→ currentMonthCountRows (725) — depende só de ano/mes, NÃO de aggRows
aggRows e currentMonthCountRows também não dependem uma da outra — hoje
sequenciais (700 → 725). Podem paralelizar.
chassi-service.ts (novo, drawer de drill-down) — sob demanda, fora do critical path
queryChassiDetails (linha 106) roda 1 query com 4 LEFT JOIN contra o
star schema cru (fato_emplacamentos, dim_veiculo, dim_local,
dim_concessionaria) sobre emplacamento_detalhe (~4M linhas/ano). Só
dispara quando o usuário abre o drawer (não no load da página), então não
afeta o TTFB do relatório — mas cada abertura de drawer paga o custo dos 4
joins em runtime. Está documentado como problema conhecido em
chassi-detalhamento-view.md: a proposta é
estender emplacamento_detalhe com as colunas que faltam
(grupomodeloveiculo, regiões) para eliminar os joins. Já está cacheado
(cacheService.wrap, tag MARCA, TTL HISTORICO) — então só o cache miss
paga o custo.
Estado de cache (atualizado)
Cacheados hoje (cacheService.wrap, tag REPORT_TAGS.MARCA, TTL
CACHE_TTL.HISTORICO = 3600s):
getReportData(brand-service.ts:480)getFilterOptions(brand-service.ts:487) — não está sendo chamada pela página (page.tsxusa as funções soltas dereport-utils.tsxem vez desse método do service; ver item novo #9)getMonthlyComparison(brand-service.ts:493)queryChassiDetails(chassi-service.ts:204)
Sem cache (batem no DW a cada navegação, mesmo warm):
getAvailableMonths(report-utils.tsx:294) — roda antes de qualquerPromise.all, bloqueia literalmente tudogetFieldsAreaAbrangencia(42) —array_aggno mart inteirogetValidEmplacamentosCategories(14) — bate emdw_emplacamento.dim_veiculo(tabela crua, não o mart — outra rota de I/O)getTypeOfSales(27)getEmplacamentosIn(103),getMonthDailyAverage(150),getMarketLeader(205),getTotalBrandsAndTotalEmplacamentos(256) — os 4 KPIs da onda B
Otimizações (ranqueadas)
1. Cache nas 8 funções sem cache — MAIOR GANHO, risco baixo — ✅ feito no app
report-utils.tsx: getAvailableMonths, getFieldsAreaAbrangencia,
getValidEmplacamentosCategories, getTypeOfSales, getEmplacamentosIn,
getMonthDailyAverage, getMarketLeader, getTotalBrandsAndTotalEmplacamentos.
Envolver em cacheService.wrap com as mesmas tags/TTL do getReportData
(tag REPORT_TAGS.MARCA, TTL CACHE_TTL.HISTORICO). Warm load fica quase
instantâneo. Atenção: getEmplacamentosIn/getMonthDailyAverage/etc.
recebem filters como argumento — a key do cache já varia por eles
automaticamente (mesmo padrão de getReportData), só repetir a receita.
- Impacto: alto. Esforço: baixo. Risco: baixo (mesmo padrão já usado 4x no arquivo).
- ✅ Feito: cada uma das 8 funções virou um export fino
'use server'que delega para uma função interna não-exportada embrulhada emcacheService.wrap(mesmo padrão debrand-service.ts). Arquivo continua sem'use server'no topo — os exports sync (areaAbrangenciaToTreeOptions,formatCompactNumberetc.) não foram afetados. A key do cache dounstable_cache(adapter usado porcacheService.wrap) já inclui os argumentos serializados da chamada além doskeyPartsestáticos — entãorefDate/filtersnas 4 funções de KPI já entram na key automaticamente, sem precisar de nada manual. Validado comtsc --noEmit(0 erros) enext build(chegou em "✓ Compiled successfully").
2. getFieldsAreaAbrangencia = array_agg no mart inteiro (~2,6M linhas) a cada load
Coberto pelo item 1 (cache), mas vale destacar: essa é a query mais cara das
"sem cache" — array_agg(DISTINCT ...) sobre 6 colunas, mart inteiro, sem
filtro de ano. Cachear com TTL longo é suficiente; as opções quase nunca
mudam. Se quiser ir além: restringir a WHERE ano = <ano corrente> reduziria
o scan, mas mudaria semântica (opções sumiriam se um valor só existir em ano
anterior) — não recomendado sem confirmar com produto.
3. getAvailableMonths bloqueia tudo antes do primeiro Promise.all
page.tsx:57 faz await getAvailableMonths() sozinho, sequencial antes de
qualquer outra coisa. É necessário porque selectedMonth/anchor dependem
dele — dependência genuína, não dá pra paralelizar com a onda A. Mas hoje é
o único round-trip sem cache nesse ponto crítico do caminho — cachear
(item 1) já mata o problema na prática (warm: latência de rede local ao
invés de round-trip pro DW).
4. Onda A e onda B são sequenciais mas independentes — ✅ feito no app
A onda B (4 KPIs, page.tsx:103-113) só precisa do anchor (calculado no
passo 1), não de nada que a onda A (page.tsx:83-90) produz. Fundir num
único Promise.all de 9 itens — A para de esperar B terminar. Economiza uma
onda inteira de latência (≈1 round-trip a mais no caminho crítico frio).
- Impacto: alto (mata 1 onda completa). Esforço: baixo (mover os 4
awaitpara dentro do array já existente). Risco: baixo — nenhuma dependência de dado entre os dois grupos. - ✅ Feito:
page.tsxagora tem um únicoPromise.allde 9 itens (getReportData,getMonthlyComparison,getFieldsAreaAbrangencia,getValidEmplacamentosCategories,getTypeOfSales+ os 4 KPIs), todos disparados juntos assim queanchor/filtersestão resolvidos.
5. aggRows + dailyRows paralelos dentro de getReportData — ✅ feito no app
brand-service.ts:590 e :633 só dependem de monthDatesRows (:558), não
uma da outra. Trocar os dois await sequenciais por Promise.all — −1
round-trip no caminho de getReportData.
- Impacto: médio. Esforço: baixo. Risco: baixo.
- ✅ Feito: os dois
awaitsequenciais viraram umPromise.all; quandodays.length === 0odailyRowsviraPromise.resolve([])em vez de pular a query. Nota de trade-off: antes havia um early-return que evitava rodardailyRowsquandovalidAggRowsdava vazio — com a paralelização as duas queries sempre rodam juntas (perde essa otimização no caso raro de resultado vazio, ganha 1 round-trip no caso comum de resultado não-vazio).
6. aggRows + currentMonthCountRows paralelos dentro de getMonthlyComparison — ✅ feito no app
Mesmo padrão do item 5, mas no método novo: brand-service.ts:700 e :725
não dependem uma da outra, ambas só de ano/mes/currentDay. Paralelizar.
- Impacto: médio. Esforço: baixo. Risco: baixo.
- ✅ Feito:
Promise.allsubstituiu os doisawaitsequenciais.
7. MAX(data) (getLastUpdated) recalculado 6× no cold load — ✅ feito no app
getLastUpdated roda em getReportData (:521) e de novo em
getMonthlyComparison (:671) — mesma query, mesmo resultado, dois
round-trips (hoje em paralelo entre si por estarem na mesma onda A, então
não soma latência, mas dobra carga no DW). Os 4 KPIs de report-utils.tsx
também recalculam max(data) como fallback via COALESCE(refDate, max(data))
dentro de CTEs — mas como quase sempre refDate já vem preenchido (do
anchor resolvido em page.tsx), o planner pode não nem precisar escanear
pra esse max() (com refDate setado o COALESCE de fato só avalia o
max() se o Postgres não conseguir provar a curto-circuito — depende do
plano; isso é estimativa, não medido, ver seção de medição abaixo).
- Proposta: computar a âncora uma vez em
page.tsx(já é feito —anchorDate/anchor) e olastUpdatedde exibição uma vez só, passando como parâmetro pros services em vez de cada um rodargetLastUpdated(). Elimina a duplicata entregetReportDataegetMonthlyComparison(que é a única redundância real e mensurável — as outras 4 já recebemrefDate). - Impacto: baixo-médio (paralelo, não é latência do caminho crítico, mas
reduz carga no DW e simplifica código). Esforço: baixo. Risco:
baixo — precisa garantir que o
lastUpdatedretornado no payload (BrandGridData.lastUpdated/BrandMonthlyData.lastUpdated) continue correto quando passado por fora. - ✅ Feito, via caminho diferente do proposto: em vez de mover
getLastUpdatedpra fora e passar como parâmetro pelos services (o que exigiria mudar a assinatura pública degetReportData/getMonthlyComparison, hoje já embrulhadas porcacheService.wrap), a query virou uma função de módulo memoizada comcache()doreact(getLastUpdatedMemo,brand-service.ts). Os dois métodos doBrandServicechamam essa mesma função memoizada em vez de terem cada um sua própria query — quandogetReportDataegetMonthlyComparisonrodam concorrentes (mesmoPromise.alldepage.tsx), a 2ª chamada reusa a promise da 1ª em vez de disparar outro round-trip.react.cache()memoiza por render/request (mesmo padrão recomendado pelo Next.js pra dedupe de fetch dentro do App Router), então não vaza estado entre requests nem quebra fronteira server/client — não muda nenhuma assinatura pública.
8. getLastUpdated roda mesmo quando filters.intervalStart já resolve refDate
getReportData:523 e getMonthlyComparison:673: const refDate = filters.intervalStart ?? lastUpdated — o lastUpdated é sempre buscado
(linha anterior), mesmo quando intervalStart já está setado (usuário usou
o filtro de Período) e o valor seria descartado. Adiar a chamada de
getLastUpdated() pra só rodar quando filters.intervalStart for
undefined evita 1 round-trip nesse caso (comum: primeira carga da página
sem filtro de período custa igual, mas navegações com Período setado
economizam). Combina bem com o item 7 (idealmente passar lastUpdated
resolvido uma vez em page.tsx pros dois métodos).
- Impacto: baixo. Esforço: baixo. Risco: baixo.
- Nota (não implementado como item isolado): na prática já não dispara
na maioria dos casos —
page.tsxsempre populafilters.intervalStartcomanchor(mês selecionado ou período explícito), então o?? lastUpdatedquase nunca é avaliado norefDate.getLastUpdated/getLastUpdatedMemoainda roda sempre (item 7) porque o valor também é retornado no payload pra exibição ("atualizado em"), que é um propósito independente dorefDate. Deduplicar viacache()(item 7) já elimina o round-trip redundante; adiar a chamada por completo exigiria decidir se o campo de exibição pode ficarundefinedquandointervalStartestá setado — não fizemos essa mudança de contrato sem confirmar com quem consome o campo.
9. getFilterOptions do service não é usada — dead code ou gap de cache
createBrandService() (brand-service.ts:487-491) já expõe
getFilterOptions cacheado (states + grupos ABRACAF), mas page.tsx não a
chama — os filtros de Estado/Grupo do BrandFilterBar aparentam vir de
outro lugar (verificar brand-filter-bar.tsx/report-filter). Se os
selects de Estado/Grupo estiverem sem opções reais ou refazendo query em
outro componente sem cache, é um gap equivalente ao item 1. Se for
efetivamente dead code, remover reduz superfície de manutenção. Vale
confirmar com quem estiver mexendo em brand-filter-bar.tsx (outro
subagente está tocando esse arquivo agora) antes de agir.
- Impacto: indefinido até confirmar. Esforço: baixo (investigar).
10. KPIs combináveis
getEmplacamentosIn (total mês vs anterior) e getMonthDailyAverage (média
diária vs anterior) varrem a mesma janela de 2 meses na mesma tabela,
com os mesmos filtros — só mudam o agregado final (sum vs sum/dias).
Dá pra combinar numa única query (1 CTE medias computando total E média).
getMarketLeader e getTotalBrandsAndTotalEmplacamentos têm janelas
diferentes (mês vs ano) — menos óbvio combinar sem comprometer legibilidade.
- Proposta: fundir
getEmplacamentosIn+getMonthDailyAverageem 1 round-trip. Deixar os outros 2 separados. - Impacto: médio (4 KPIs → 3 round-trips). Esforço: médio (query
fica mais densa, precisa dos dois
WHERE— mesma janela então tranquilo). - Risco/trade-off: perde granularidade de cache por KPI — se um dia
quiser invalidar só um card, não dá mais. Com TTL fixo por tag (não por
função), esse trade-off já não existe na prática hoje (a invalidação é
por tag
MARCAinteira via/api/revalidate/reports), então o trade-off real é baixo.
11. Amplificador: Supavisor com prepare: false
supabase-dw-client.ts:58 desliga prepared statements (obrigatório em
transaction mode do Supavisor — comentário no código explica por quê: cada
statement pode ir pra uma conexão física diferente). Isso significa cada
query é parseada/planejada do zero a cada chamada, sem reuso de plano —
soma-se ao RTT de rede pro DW remoto. Não é algo pra "corrigir" (é
requisito do modo transaction), mas é o motivo pelo qual reduzir contagem de
round-trips (itens 1-10) importa mais aqui do que num Postgres local: cada
round-trip evitado economiza RTT + parse + plan, não só RTT.
- Alternativa (fora do escopo de código): usar Supavisor em session mode (porta 5432 direta, não 6543) para essa conexão específica, se o time de dados permitir — reabilitaria prepared statements. Precisa avaliar limite de conexões físicas do DW antes de mudar (session mode consome 1 conexão física por client conectado, transaction mode faz pooling).
Medição — o que é estimativo vs medido
Não foi possível rodar EXPLAIN (ANALYZE, BUFFERS) nesta análise. Não
há .env.local/.env.development com SUPA_DB_* configurado neste
worktree (só existe o client code, sem credenciais versionadas — como
esperado, dado que são segredos). Toda a análise acima é estática,
baseada em leitura de código: contagem de await sequenciais, dependências
de dados entre queries, e o volume de linhas citado nos comentários do
próprio código-fonte (ex.: "~2,6M linhas" em getFieldsAreaAbrangencia,
"~4M linhas" em emplacamento_detalhe) e em marts-emplacamento.md/
chassi-detalhamento-view.md. Números de impacto ("alto/médio/baixo") são
julgamento sobre estrutura de query + contagem de round-trips, não
medição de tempo real. Se alguém com acesso ao DW quiser embasar com
números reais, os candidatos mais valiosos pra rodar EXPLAIN ANALYZE são:
getFieldsAreaAbrangencia (item 2) e a query de chassi-service.ts (joins).
Tabela priorizada (quick wins primeiro)
| # | Item | Impacto | Esforço | Onde | Status |
|---|---|---|---|---|---|
| 1 | Cache nas 8 funções sem cache | Alto | Baixo | report-utils.tsx | ✅ feito no app |
| 4 | Fundir onda A + onda B num só Promise.all | Alto | Baixo | por-marca/page.tsx | ✅ feito no app |
| 5 | aggRows + dailyRows paralelos | Médio | Baixo | brand-service.ts:590,633 | ✅ feito no app |
| 6 | aggRows + currentMonthCountRows paralelos | Médio | Baixo | brand-service.ts:700,725 | ✅ feito no app |
| 7 | Deduplicar getLastUpdated entre os 2 métodos | Baixo-médio | Baixo | brand-service.ts | ✅ feito no app (via react.cache()) |
| 8 | Pular getLastUpdated quando intervalStart setado | Baixo | Baixo | brand-service.ts | Coberto na prática por #7 — ver nota no item |
| 9 | Investigar getFilterOptions não usada | Indefinido | Baixo (investigação) | brand-service.ts vs brand-filter-bar.tsx | Pendente |
| 2 | Cache em getFieldsAreaAbrangencia (já coberto por #1) | — | — | — | ✅ feito (via #1) |
| 10 | Combinar getEmplacamentosIn + getMonthDailyAverage | Médio | Médio | report-utils.tsx | Pendente |
| 3 | getAvailableMonths bloqueante (resolvido por #1) | — | — | por-marca/page.tsx:57 | ✅ feito (via #1) |
Ordem de execução sugerida: 1 → 4 → 5 → 6 → 7 → 8, depois avaliar 9 e 10 (10 é o único com trade-off de granularidade de cache — fazer por último e só se o ganho de #1/#4 não for suficiente).
Pendente: itens 9 e 10 seguem em aberto — 9 precisa de investigação
(confirmar se brand-filter-bar.tsx já busca Estado/Grupo em outro lugar
sem cache), 10 é uma reescrita de query com trade-off de granularidade de
cache, melhor avaliar depois de medir o ganho real de 1+4+5+6+7 em produção.
Itens que exigem o time de dados
- Índice em
dw_emplacamento_mart.emplacamento_kpi_dia— não dá pra confirmar semEXPLAIN(ver seção de medição), mas as queries filtram consistentemente por(ano, mes, data)e agrupam por(fabricante, grupomodeloveiculo). Se não houver índice composto cobrindo(ano, mes, data), cada query citada faz seq scan no mart inteiro. Pedir ao time de dados para confirmar índices existentes antes de sugerir criação (evitar duplicar índice já presente). - View/extensão de
emplacamento_detalhe— já pedido emchassi-detalhamento-view.md: adicionargrupomodeloveiculo,regiao_geografica,regiao_metropolitana,area_influencia_nome,conc_regiao_abracaf(eregiao_operacionalse o ETL souber derivar) diretamente no mart, eliminando os 4LEFT JOINem runtime dechassi-service.ts. - Materializar histórico multi-ano no mart agregado — já levantado em
marts-emplacamento.md: hojeemplacamento_kpi_diacobre só 2026; a fato crua tem 2025 completo. Sem isso, comparações YoY (usadas emgetMonthlyComparison) ficam limitadas ao que já está materializado.
Itens que dá pra fazer só no app (sem dependência externa)
Todos os itens 1, 4, 5, 6, 7, 8, 9, 10 da lista acima — nenhum deles precisa
de mudança de schema, índice ou ETL. São reorganização de await/
Promise.all, aplicação do padrão de cache já existente no arquivo, e
(item 10) reescrita de uma query combinando duas existentes.
TL;DR
Gargalo principal continua sendo falta de cache nas funções de
report-utils.tsx (item 1) — maior ganho, menor risco, nenhuma dependência
externa. Em seguida, a onda B (4 KPIs) ainda espera a onda A mesmo sem
depender dela (item 4) — fusão simples, mesmo ganho estrutural que a fusão
já feita na onda A. Dentro dos services, sobra paralelismo barato não
aproveitado (aggRows/dailyRows, aggRows/currentMonthCountRows —
itens 5-6) e uma duplicata nova introduzida pelo getMonthlyComparison
(getLastUpdated rodando 2×, item 7). Nada disso depende do time de dados;
os pedidos pro time de dados (índices, view de chassi, histórico multi-ano)
já estão registrados em docs separados e podem avançar em paralelo, sem
bloquear os itens de app.
Ver também marts-emplacamento.md e
chassi-detalhamento-view.md.
DECIDIDO — Próximo passo: lazy-loading da árvore marca → modelo
Status: aprovado, a implementar. Depois das otimizações de query já feitas (cache + paralelização, ~4,6s → ~2,9s no DW; warm ≈ cache-hit), o gargalo restante é client-side: a página serializa marca + TODOS os modelos + os dados diários de cada um num HTML de ~4,5 MB, pago em transferência e, sobretudo, em parse/hidratação no thread principal (o que trava o "tempo até interativo" em máquina/rede fraca).
Evidência medida (mês 2026-07, DW real)
- Payload: 105 marcas vs 609 modelos; ~3.316 células diárias no grão de modelo.
- HTML da tela: ~4,5 MB (≈ planilha de ~25 mil linhas; os dados reais são ~714 linhas / ~15 mil células — o markup infla ~20–30×).
- A query brand-only quase não economiza no DW (agg 601→541ms, daily
580→391ms; ~250ms no total): é o mesmo scan do mart, só muda a granularidade
do
GROUP BY. Logo o ganho é 100% no client (payload/render/hidratação), não no servidor.
Ideia (Hugo)
Carregar só as marcas no load inicial (query agregada por fabricante),
renderizar as linhas de topo, e buscar os modelos de cada marca sob demanda
ao expandir (server action, como o drawer de chassi já faz) — com
prefetch em background (requestIdleCallback/on-hover) das marcas visíveis,
pra que ao clicar em expandir os modelos já estejam em cache. Payload inicial
cai de ~4,5 MB / ~714 linhas para ~0,7 MB / 105 linhas.
Pontos de atenção (onde mora o trabalho/risco)
- Busca: hoje
filterBrandRowsfiltra marca e modelo no client; com modelos lazy, buscar um modelo não carregado não acha → vira busca server-side (ou "ao digitar, carrega os modelos que batem"). - Exportar: hoje exporta o grid inteiro em memória; com lazy, o botão faz fetch completo na hora (aceitável, com estado "gerando…").
- Participação % do modelo depende do total do dia (global) — passar esses totais junto do payload de marca pro cálculo bater.
- Mesmo tratamento vale para a aba Comparativo Mensal (mesma árvore).
Arquivos afetados (estimativa)
brand-service.ts (query brand-only + action getModelosByMarca),
components/reports/emplacamento-grid-table/* (subrows assíncronas + loading no
expand), brand-grid-table.tsx / brand-monthly-table.tsx, brand-report-view.tsx
(busca/export). Esforço médio; maior risco = reescrita de busca/export.