Imported from WT-Finance/wt-finance (
.claude/skills/ingestao-planilhas/SKILL.md). Install upstream withnpx skills add WT-Finance/wt-finance --skill ingestao-planilhas. Copyright stays with the author.
name: ingestao-planilhas
description: Ingestão de planilhas/Excel no Janus — desde a v6.0.0 as 5 bases financeiras (Demonstrativo, Vendas, Movimentação, Aberto, Operação) parseiam no SERVIDOR via API Route (/api/ingestao/{base}, checksum do arquivo como gate); Pessoas é a única que ainda parseia no cliente + Web Worker + Server Action (Server Action só quando apenas linhas já parseadas viajam). Leitura de datas e números pelo valor NATIVO da célula (cellDates/raw: true), coerção canônica única em @/lib/carga/coercao.ts (toNum/toIsoDate/toCentavos — o lint wt/no-coercao-reimpl bloqueia reimplementação), parser único de Vendas e pipeline atômico de carga. Use ao mexer em upload, parser, importação, coerção de número/data vindos de arquivo ou input do usuário, ou quando uma soma do cliente for comparada com uma soma do banco.
Ingestão de planilhas (Janus)
Esta skill cobre o caminho completo de um arquivo Excel/CSV entrando no Janus: onde o
parse roda, como uma célula vira número/data sem perder precisão, e como a carga chega
ao banco sem corromper a base em caso de erro. As quatro áreas (upload, parse, coerção,
pipeline) formam uma cadeia — um erro em qualquer uma delas costuma parecer um bug de
"dado errado" em produção, não um erro de build, porque nada aqui é pego por tsc/lint
sozinho (exceto a coerção, que tem lint dedicado — ver abaixo).
1. Parse de ARQUIVO no servidor → API Route, nunca Server Action
Bibliotecas de parse de planilha (@e965/xlsx) falham quando rodam dentro do contexto
de React Server Components — o runtime de Server Action não é Node puro o bastante para
elas. A regra é: qualquer rota que receba e processe um arquivo de upload é uma API
Route (export const runtime = 'nodejs'), nunca uma Server Action.
Isso isola o parse do contexto RSC e garante um runtime Node completo. Foi descoberto na v4.7 (PEND-001) quando uma Server Action de upload quebrava silenciosamente.
⚠️ O gatilho da regra é o ARQUIVO chegar ao servidor — não a palavra "upload" (precisão acrescentada na v5.8.0, onde a redação genérica anterior induziu o plano ao caminho errado).
Desde a v6.0.0, as 5 bases financeiras vivas (Demonstrativo, Vendas, Movimentação, Aberto,
Operação) migraram para API Route (POST /api/ingestao/{base}, §8 adiante): o navegador sobe o
File cru por signed upload URL e quem parseia é o servidor, com @e965/xlsx em runtime Node
puro — exatamente o caso que esta regra sempre existiu para prever. Pessoas é a única que
continua no caminho antigo (decisão 11 do briefing v6.0.0: fica fora da fundação, parada mas
viva): o File nunca sai do navegador, o parse roda no cliente/Web Worker (§2) e só as linhas já
parseadas viajam, em lotes, para uma Server Action que as repassa às RPCs.
Escolha assim:
| O que atravessa a rede | Caminho | Precedente |
|---|---|---|
o File/buffer |
API Route runtime = 'nodejs' |
src/app/api/ingestao/{base}/route.ts (5 bases, v6.0.0) · src/app/api/gerencial/import/route.ts |
| arrays já parseados no cliente | Server Action em lotes | src/app/admin/uploads/actions.ts (só Pessoas) |
2. Parse pesado no cliente → Web Worker, nunca a main thread
Quando o parse acontece no navegador (ex.: /admin/uploads, que parseia client-side antes
de enviar ao servidor), XLSX.read + sheet_to_json + o parser da base (~45 mil linhas)
são síncronos e pesados. Rodar isso na main thread trava a aba inteira — a página
"não responde" e até o spinner de carregamento congela, porque o spinner também é DOM/JS
na mesma thread bloqueada.
A saída é rodar o parse num Web Worker: src/lib/carga/parse.worker.ts reaproveita os
parsers isomórficos existentes (um por base) e é chamado via parseArquivoEmWorker()
(src/lib/carga/parse-em-worker.ts), que tem fallback para a main thread se o worker
não carregar (ex.: ambiente sem suporte). Ao instanciar o worker, a sintaxe que builda
certo no Next 16/Turbopack é:
new Worker(new URL('./parse.worker.ts', import.meta.url), { type: 'module' })
Upload novo com volume relevante (milhares de linhas) nasce usando
parseArquivoEmWorker, não chamando o parser direto no componente. Custou caro: a
travada foi reportada por usuário real no upload de Vendas (v4.20.2) antes de virar
regra — o parse funcionava perfeitamente em dev com arquivos pequenos e só quebrou com
volume de produção.
Desde a v6.0.0, este caminho (parse no navegador + Web Worker) vale só para Pessoas. As outras cinco bases parseiam no servidor (§8) — o navegador delas só calcula sha256 e sobe o arquivo cru.
3. Célula de planilha: ler o valor NATIVO — vale para data E para dinheiro
Ao ler qualquer célula tipada de um Excel, use:
XLSX.read(buffer, { cellDates: true });
XLSX.utils.sheet_to_json(sheet, { raw: true });
cellDates: true faz a lib devolver um Date JS nativo para células de data. raw: true
preserva esse valor nativo em vez de reformatá-lo para a string de exibição da célula
(que costuma vir em formato americano mm-dd-yy na origem).
O motivo de nunca confiar em heurística de string (DD/MM vs MM/DD) é que ela erra
silenciosamente: quando ambos os componentes são ≤ 12 (ex.: 03/07), qualquer
suposição de ordem "acerta por acaso" em boa parte dos casos e só se revela errada nas
datas em que dia e mês divergem visivelmente — momento em que já pode haver meses de
dado importado com data invertida. O Date nativo da célula é inequívoco porque o Excel
já resolveu a ambiguidade internamente; delegue a ele. Heurística de string só entra como
fallback para células que cheguem genuinamente como texto (não como data formatada).
Custou caro: a importação da base Gerencial inverteu dia/mês (ADR-0099, v4.9) e o bug ficou mascarado por semanas porque a maioria dos dias no mês é > 12 e acertava por coincidência — só uma auditoria de dados achou a inconsistência.
O mesmo vale para DINHEIRO — e custou mais caro ainda (v5.5.2)
A regra acima nasceu para datas, mas o argumento é idêntico para número: reformatar para
texto reintroduz uma ambiguidade que o Excel já tinha resolvido. A célula numérica
-40.933 (R$ 40,93, ponto decimal) vira a string "-40.933", que casa o padrão de milhar
BR do toNum (^-?\d{1,3}(\.\d{3})+$) e é lida como −40933 — ×1000 silencioso.
O gatilho é exatamente 3 casas decimais com 1–3 dígitos inteiros; com 4 casas
(-30.4322) ou 4+ dígitos inteiros (1234.567) o padrão não casa e o valor sempre passou.
Por isso o defeito é esparso e plausível, nunca visível em massa. Três casas nascem de
divisão de título (parcelamento, rateio, câmbio): 377,23 ÷ 2 = 188,615.
Os dois parsers do Fluxo/DRE pediam raw: false explicitamente ao sheet_to_json e
ficaram anos assim; os demais omitem a opção e o default do SheetJS (raw: true) já os
protegia. O modo seguro é não escrever a opção.
CSV é uma SEGUNDA porta para o mesmo estrago, e maior
XLSX.read(texto, { type: 'string', raw: false }) faz o SheetJS rodar um heurístico
americano sobre o texto antes de qualquer coerção nossa. Isso não atinge só valores de
3 casas — destrói todo valor BR com vírgula decimal: "40,93" → 4093, "0,05" → 5,
"-26,39" → −2639, "-1.234,56" → −1,23456.
No ramo CSV use sempre read(..., { raw: true }), para a string sobreviver e a regra BR do
toNum valer — inclusive para datas, onde a leitura americana do SheetJS é justamente a
armadilha da ADR-0099. Estava vivo em oito parsers na v5.5.2, três em bases financeiras que
aceitam .csv pela UI (Vendas, Rateio, Faturamento Corp).
Ali a ambiguidade de "-40.933" é irredutível: sem tipo, não há o que consultar.
A sonda mecânica (parse-fluxo-caixa-valor-nativo.test.ts) vigia as duas portas em
src/lib/carga/, src/lib/rateio/, src/lib/faturamento/ e src/lib/gerencial/. Ela extrai
os argumentos por parênteses balanceados e aceita parâmetro de tipo — a 1ª versão usava
janela de caracteres e não casava sheet_to_json<unknown[]>(...), ficando cega justamente para
os arquivos que devia vigiar.
Leitura dupla não é imunidade — vale só para a coluna que a usa. O gerencial/parser.ts
fazia leitura dupla desde a v4.9 e por isso foi tratado como seguro; mas a versão nativa
alimentava só Vencimento, e Valor Final seguia pela string de exibição, com o mesmo
risco. Corrigido na v5.5.2. Ao citar leitura dupla como proteção, diga de qual coluna.
Um teste de parser que monta a matriz na mão NÃO cobre isto. O defeito mora na
extração (a opção do sheet_to_json), então toda prova que chama parseXxxRows(matriz)
passa por cima dele — foi o que aconteceu com 753 testes verdes. Guard de ingestão precisa
montar um arquivo de verdade e entrar por parseXxxFile().
Custou caríssimo: distorceu a DRE e o Fluxo a ponto de inverter o sinal do resultado de
2024 e de 2025, e a auditoria de paridade da v5.3.0 chegou a carimbar o delta como
"re-lançamento retroativo no Monde". Investigação completa em
docs/investigacoes/2026-08-10-coercao-milhar-dre-fluxo.md.
4. Coerção de célula: UM módulo só, e o lint segura isso
Todo valor de célula (número, data, string) vindo de upload — ou de qualquer input do
usuário que precise virar número — passa por @/lib/carga/coercao.ts:
toNum(value), toIsoDate(value), toStr(value).
Por que não escrever um toNum local: a versão ingênua
(Number(String(v).replace(',', '.'))) devolve NaN/null para número BR com
separador de milhar — 8.840,00 ou 1.234,56 — porque o . de milhar não é removido,
só o , decimal é trocado. Isso é perda silenciosa de dado, da mesma classe do bug
de saldo da v4.23.1 (ver docs/ ou o out-briefing daquela versão).
O toNum canônico:
- Desambigua ponto de milhar (grupos de 3 dígitos) vs. ponto decimal americano (≤ 2 dígitos à direita).
- Trata prefixo
R$e espaços. - Converte negativo entre parênteses — convenção contábil
(1.000)→-1000(v4.27, vale para a plataforma inteira; essa regra só passou a valer para entradas(x), que antes davamnull).
O toIsoDate canônico lê o Date nativo sem deslocamento de fuso, aceita
DD/MM/YYYY sem inverter a ordem, e rejeita o serial 0 do Excel (que representa
"nenhuma data", não uma data real). Não repita essa lógica de fuso aqui — a exibição de
timestamptz (fuso de São Paulo) é assunto da skill ui-design-system; esta skill cobre
só a leitura/coerção do valor cru vindo da planilha.
O lint wt/no-coercao-reimpl (AST, v4.27/ADR-0130) bloqueia reimplementação fora
deste arquivo. Ele pega:
parseFloatfora decoercao.ts..replacede separador decimal/milhar quando o resultado alimentaNumber/parseFloatou uma função tipada: number(a direção "texto → número"). O guard isenta a direção inversa — sanitizador de<input>e.toFixed().replace(...)para exibição, que vão de número para string.- Definir função/const com nome de coerção — regex
/^(to|para|parse).*(num|valor|money|reais|float|decimal)/i— fora dos arquivos isentos (coercao.ts,**/*.test.ts,src/lib/email/**).
Se a coerção existente não cobre um caso novo, a saída é estender toNum/toIsoDate
dentro de coercao.ts, nunca criar um segundo parser em outro arquivo. Ao estender,
prove que os casos atuais continuam corretos — coercao.test.ts precisa passar sem
alteração (é o "oráculo congelado": a suíte que já existe é a prova de que a extensão não
regrediu nada que já funcionava). Há também coercao-lint.sonda.test.ts, uma sonda que
verifica que o próprio lint continua pegando os padrões proibidos.
toCentavos — quando o número vai ser COMPARADO com o banco (v5.8.0)
toNum devolve o número; toCentavos devolve dinheiro em centavos inteiros, arredondado
pela MESMA regra que o Postgres aplica ao gravar em NUMERIC(x,2): meio-para-longe-de-zero
sobre a representação DECIMAL.
Use-o sempre que uma soma calculada no cliente for confrontada com uma soma calculada no banco
(é o caso do alarme de ingestão da base de competência). Nunca Math.round(valor * 100)
para isso, por duas razões independentes:
- Float. O que viaja no JSON é a representação decimal (
(1.005).toString() === '1.005') e o Postgres a lê com aritmética exata ('1.005'::numeric(18,2)=1.01); em JS1.005 * 100avalia para100.49999999999999eMath.rounddevolve100. - Regra de desempate.
Math.rounddesempata para +∞ (Math.round(-1.5) === -1) e o Postgres desempata para longe de zero (-1.5 → -2). Ou seja: todo meio-centavo negativo discorda — e base de despesa é majoritariamente negativa.
Medido na v5.8.0: as duas divergem em 1.005, -1.005, -0.125, -188.615 e concordam em
188.615, 0.125, 2.675 — o acordo depende de qual lado do meio a representação binária
caiu, isto é, é imprevisível caso a caso.
Consequência prática de projeto: se a coluna é NUMERIC(x,2), o parser deve emitir o valor
já arredondado a 2 casas (toCentavos(v)/100). Assim o que se envia é idêntico ao que se
grava, e a fronteira não arredonda de novo por conta própria.
Caso especial em Vendas: valor_total e receitas viajam como string numérica
(const n = toNum(v); n === null ? null : String(n)), não como number, porque o
staging do banco faz ::numeric na chegada. Não "corrija" isso para number achando que
está mais correto — o cast fica no SQL, de propósito.
5. Parser único de Vendas + pipeline atômico de carga
A ingestão de Vendas tem um parser só: @/lib/carga/vendas-parser.ts — isomórfico
(sem 'use client'), reaproveitado tanto no worker do cliente quanto em qualquer outro
caminho que precise ler a planilha. Não crie um segundo parser "mais simples" para um
caminho novo: dois parsers divergentes já regrediram silenciosamente a plataforma nos
tempos da v4.9.x (o caminho via servidor não populava a coluna operacao_propria, e o
bug só apareceu quando alguém comparou os dois caminhos). Paridade de colunas é garantida
pelo parser único e reforçada pelo lado SQL do staging (migration 0118).
A carga em si segue um pipeline atômico (ADR-0111, v4.15.0):
limpar_staging_vendas → inserir_lote_staging → validar_carga_staging → promover_carga_vendas
Essas RPCs rodam via getAdminClient (service role, sem o timeout de 3s do anon) e o
swap para a tabela final acontece numa transação — se qualquer etapa falhar, o ROLLBACK
preserva a base como estava antes da carga. Antes disso, uma carga com erro podia deixar
a tabela de vendas vazia em produção; o pipeline atômico fechou esse buraco. O detalhe das
RPCs do pipeline (assinatura, orçamento de tempo, RBAC) é da skill banco-e-rpc — aqui
importa saber que o caminho existe e que ele é o único vivo.
RPCs do caminho destrutivo antigo (truncate_dynamic_tables, inserir_lote_raw)
permanecem no banco só porque npm run seed ainda as usa — não são consumidor de
nenhuma request viva da aplicação. Não as trate como órfãs/candidatas a DROP sem
conferir o seed primeiro (precedente: a v4.17.1 quase as removeu por engano). A trinca
de recuperação (transform_raw_to_analytics → regenerar_dim_operacao_weddings →
refresh_all_materialized_views) segue intacta e serve para recompor as tabelas
analíticas sem precisar re-subir o arquivo original.
Operação da carga — o que fazer quando ela reprova (migrado do runbook v4.15, v5.10.0)
O pipeline é fail-safe por construção: a base de leitura só muda no promover, e ele é uma
transação única. Toda mensagem de erro da tela de upload significa base preservada, não base
corrompida — o reflexo certo é corrigir e re-subir, nunca "limpar na mão".
- Reprovou na validação (ex.: "N venda(s) com data fora do calendário … A base atual foi
preservada.") — nada foi gravado. Causa comum é data fora do range de
analytics.dim_data(seção 6). Conferirmin/max(data_venda)naraw.vendas_excel_stagingcontramin/max(data)daanalytics.dim_data: data legítima → estender adim_datapor migration; data digitada errada → corrigir a planilha. - Falhou DURANTE a promoção — rollback automático; a contagem anterior continua de pé
(
get_upload_status() -> 'vendas' ->> 'total'). Não há limpeza manual: o swap não chegou a acontecer. - Re-subir o mesmo arquivo não duplica.
promover_carga_vendasé substituição completa (truncate + reload), não append — a verdade é sempre o último arquivo promovido. - Uploads concorrentes serializam. As três RPCs de escrita tomam o MESMO
pg_advisory_xact_lock(4017001)(v4.17.0/M3): o segundo upload espera o primeiro, e ninguém trunca a staging enquanto outro promove. É transparente — não há ação manual. - A staging é
UNLOGGED(decisão de performance): não sobrevive a crash/failover no meio de uma carga multi-lote. O sintoma é "Nenhuma linha válida na carga" na validação; a base viva fica intacta e a ação é refazer o upload do zero. - Aviso de
operacao_proprianão bloqueia. Se a carga vier com a coluna preenchida em menos da metade do percentual da base atual, a validação anexa um AVISO à mensagem de sucesso — sinal de que a origem (ERP) pode ter parado de exportar a coluna. Conferir a planilha, não o código.
Inspeção não-destrutiva da staging (select count(*), min/max(data_venda),
select public.validar_carga_staging()) é segura a qualquer momento. Recuperação a partir de
backup lógico e a trinca transform_raw_to_analytics → regenerar_dim_operacao_weddings →
refresh_all_materialized_views: runbook docs/runbooks/db-backup-gate-runbook.md e skill
banco-e-rpc.
6. Sintoma cruzado: dim_data com range fixo
Se uma carga de Vendas abortar com um erro de foreign key em fato_venda_data_venda_fkey,
a causa não está no parser nem na coerção — é que dim_data tem um range de datas fixo
no banco, e a planilha trouxe uma data fora dele. O procedimento de correção (estender
dim_data, recuperar sem re-upload) é detalhado na skill banco-e-rpc; aqui basta saber
reconhecer o sintoma: erro de FK ligado a data, não a formato de célula.
7. O caminho de VOLTA: exportar CSV que abre certo no Excel pt-BR
O espelho da ingestão. "Abre no Excel" e "abre certo no Excel pt-BR" são coisas diferentes, e
a diferença são quatro detalhes — nenhum deles aparece em gate nenhum, só na planilha de quem
recebeu o arquivo. Receita canônica em src/lib/patrimonio/csv.ts (v5.6.0):
- BOM UTF-8 (
'') no início. Sem ele o Excel do Windows lê como ANSI e "Informática" chega "Informática". - Separador
;, não vírgula — é o separador de listas do Excel pt-BR. - Decimal com VÍRGULA (
v.toFixed(2).replace('.', ',')). Com ponto, o Excel pt-BR entra o valor como texto (ou pior:4321.99virando432199). - CRLF (
\r\n) no fim da linha.
E duas regras de conteúdo:
- Célula de TEXTO que começa com
=,+,@ou tab é FÓRMULA para o Excel. Descrição e observação são digitadas pelo usuário:=cmd|...num CSV é execução remota clássica. Prefixar com apóstrofo desarma sem mudar o que se lê na célula. Não aplicar a número — o-de um negativo legítimo passa pela célula numérica. - Valor ausente sai VAZIO, nunca
0. "Não sei quanto custou" e "custou zero" são fatos diferentes, e a diferença tem de sobreviver à planilha (o mesmo cuidado que a coerção de entrada tem com célula vazia, na seção 4).
Escapar com aspas quando a célula contém ;, " ou quebra de linha (aspas internas dobradas) —
uma descrição com ponto e vírgula, sem isso, parte a linha numa coluna extra e desalinha a
planilha inteira. O teste que pega isso compara a contagem de campos do cabeçalho com a da
linha, não o texto.
Manter o gerador puro (sem DOM) e isolar o download (Blob + <a download> + revokeObjectURL)
numa função separada: é o que permite testar o arquivo caractere a caractere em ambiente node.
8. Ingestão v6.0.0: parser único por base no SERVIDOR, checksum do arquivo como gate
Desde a v6.0.0 as cinco bases vivas (Demonstrativo, Vendas, Movimentação, Aberto, Operação) entram
por POST /api/ingestao/{base} (contrato: docs/contratos/ingestao-v1.md) — o navegador sobe o
cru por signed upload URL, o servidor parseia (núcleo *Rows de src/lib/ingestao/parsers/,
compartilhado com o card de /admin/uploads) e a carga inteira roda numa transação. Cada base tem
oráculo src/lib/ingestao/oraculo-*.test.ts reproduzindo célula a célula o arquivo TRATADO do
script R (fixtures dos anexos do briefing v6.0.0). Pessoas continua no caminho antigo (§1/§2
acima, client-side + Server Action) — decisão 11 do briefing: fica fora da fundação, parada mas
viva.
Lições permanentes, detalhadas nos anexos docs/briefings/anexo-v6-0-0-m{3,4,5,7,9}-*.md:
- O arquivo já carrega a prova do checksum — ler totais/outline ANTES de descartar. Linha de
TOTAL e de outline (
Grupo de Categoria: Nome (15, R$ x)) que o script R sempre jogava fora é, lida antes, o checksum de graça (557/149/95/5-por-arquivo). E o subtotal do export é o arredondamento da soma dos valores EXATOS, não da soma já arredondada a 2 casas — o cru de Movimentação tem células com mais de 2 casas (717.710,7392), e somar linha a linha já arredondada erra de 1 a 6 centavos por grupo. Some em INTEIROS e arredonde uma vez só no fim (AcumuladorBruto), nunca linha a linha já truncada. trimexplícito cobre\xa0(NBSP) e tab, não só espaço comum — 17% deProdutoem Vendas tem espaço nas pontas;.trim()puro não remove NBSP, e uma classificação por igualdade de string (Setor Micro) falha em silêncio para a linha suja.- Faixa de data ANCORADA NO FIM DO ANO (
[2015-01-01, 31/12 do ano de hoje+5]), não "hoje + 5 anos" ao pé da letra — ao pé da letra o veredito depende de QUANDO a carga rodou (errata 1 do contrato). "Rejeitada" não é "descartada": a linha PERMANECE, só o campo de data sainull(contado emrejeitadas_por_data) — é o único comportamento compatível com os checksums de linha/soma (uma linha sumindo quebraria a contagem). - Descoberta posicional de coluna por NOME normalizado (
normalizeHeader), nunca por vetor de tipos fixo por posição — Movimentação/Aberto trocam entre 14 e 15 colunas conforme o export; layout fora do esperado (coluna de rótulo virada campo, formato largo) aborta a carga (guarda estrutural), em vez de gravar dado deslocado. - "Diff só vale com a MESMA grandeza" — reapareceu 4 vezes só nesta versão. Venda distinta ×
linha de item (
get_upload_status× parser);diff.soma(a DIFERENÇA) rotulado como "Σ do arquivo" no modal; título do modal comparando contagem do cru com contagem promovida enquanto o filtro Welcome ainda não existia; e o aviso de Operação contando LINHAS de um lado e NÚMEROS distintos do outro (ainda divergente, registrado no backlog). Cada ocorrência foi achada por um método diferente (smoke de servidor, leitura de código, tela ao vivo); corrigir a primeira nunca achou as outras. Ao ver dois números lado a lado ("antes/depois", "esperado/lido"), pergunte primeiro o que cada lado está contando, não se a conta está certa. - O CSV de Operação é SAÍDA DO R —
NAé ausente (não zero) emLançamento N°/Venda/Liquidação, eNúmero da Parcelatem valores como"197848-2", que não é inteiro. Um cast direto parabigintquebra nos dois. O fato só converte o que É inteiro puro (semNaDoRnas três colunas) — o mesmo que otoNumdo caminho antigo já fazia. - Filtro de negócio numa VIEW só vale para quem lê a VIEW — inclusive guardas de VALIDAÇÃO.
Mover
Setor Macro != Welcomepara uma view nomeada (analytics.vendas_excel_para_fato) não alcançou os seis leitores de Weddings que liamraw.vendas_exceldireto, nem a guarda devalidar_carga_staging(migration 0132, pré-existente) que também lia a STAGING direto (fix: 0284). Migrar um filtro de negócio para uma view exige grep de TODOS os leitores da tabela por baixo — inclusive os que parecem só "checar", não "ler para exibir".
Ver também
banco-e-rpc— RPCs do pipeline de carga (assinatura, RBAC, orçamento de tempo), o range fixo dedim_datae a recuperação sem re-upload, e o schema de staging.ui-design-system— como exibir a data já coerida (fmtDataSP/Intl, nunca split de string); esta skill cobre só a leitura do valor cru vindo da planilha.
