Javis OS
Javis OS는 기업가를 돕는 에이전시 AI로, 업무를 지원하고 함께 성장합니다
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
Dashboard executivo de operação de armazém (CD Vulcabras — Extrema/MG) para acompanhamento de Outbound, Inbound, Estoque, Reversa e Balanço WMS×SAP do e-commerce. Este documento não é só um manual de uso: é o registro do processo usado para construir o projeto — decisões de arquitetura, por que cada padrão técnico existe, quais bugs reais ele resolveu, e como replicar esse mesmo processo para um novo contexto (ex.: recriar o projeto para uma distribuidora, com fluxo de dados diferente mas a mesma base tecnológica e a mesma identidade visual).README 전문 보기 ↗
Dashboard executivo de operação de armazém (CD Vulcabras — Extrema/MG) para acompanhamento de Outbound, Inbound, Estoque, Reversa e Balanço WMS×SAP do e-commerce. Este documento não é só um manual de uso: é o registro do processo usado para construir o projeto — decisões de arquitetura, por que cada padrão técnico existe, quais bugs reais ele resolveu, e como replicar esse mesmo processo para um novo contexto (ex.: recriar o projeto para uma distribuidora, com fluxo de dados diferente mas a mesma base tecnológica e a mesma identidade visual).
Se você está lendo isto para recriar o projeto em outro contexto, vá direto para Recriando para um novo contexto. O restante do documento explica o "porquê" de cada peça, que é o que você vai precisar decidir de novo lá.
O projeto é deliberadamente simples em número de peças, e deliberadamente rígido na separação de responsabilidades entre elas:
Planilhas/exports do WMS, SAP, Forecast, etc.
│
│ (upload manual, feito pelo operador no navegador)
▼
┌───────────────┐
│ ingest.js │ Lê os arquivos, cruza dados, calcula os
│ (processa e │ indicadores, grava tudo já pronto no banco.
│ só escreve) │ NUNCA desenha nada na tela.
└───────┬────────┘
│ grava
▼
┌───────────────┐
│ Supabase │ Postgres (dados estruturados) +
│ (Postgres + │ Storage (backup dos arquivos originais,
│ Storage) │ comprimidos em gzip)
└───────┬────────┘
│ lê (snapshot já pronto)
▼
┌───────────────┐
│ index.html │ Lê o snapshot mais recente e desenha os
│ (renderiza e │ cards/gráficos/tabelas. NUNCA processa ou
│ só lê) │ recalcula regra de negócio.
└───────────────┘
│
▼
Vercel (hospedagem estática do index.html)
Por que essa separação existe (e por que ela não é opcional):
No início do projeto, cálculo e renderização estavam misturados no mesmo
arquivo. Isso criava dois problemas recorrentes: (1) qualquer ajuste visual
arriscava re-executar lógica de negócio sem querer, e (2) toda vez que a base
crescia, a tela travava porque o navegador do operador tentava reprocessar
tudo a cada carregamento. A solução foi mover todo o processamento pesado
para um script único (ingest.js), rodado uma vez por atualização de dados,
que grava o resultado já pronto como um "snapshot" (JSON) no Postgres. O
index.html ficou livre para ser burro e rápido: ele só busca o snapshot mais
recente da página que está exibindo e desenha. Essa regra foi reforçada
várias vezes ao longo do desenvolvimento e deve ser tratada como inegociável
em qualquer evolução futura — inclusive na recriação para a distribuidora.
ingest.js — processamentodashboard_snapshots — uma tabela com uma linha por
pagina (outbound, inbound, estoque, reversa, balanco), cada
linha contendo o JSON já pronto para renderizar e um timestamp
gerado_em (sempre gravado como new Date().toISOString(), ou seja, UTC).document.querySelector, manipulação de DOM, ou
qualquer coisa relacionada a exibir informação na tela. Se um ajuste pede
para "mudar como algo aparece", ele pertence ao index.html, não a este
arquivo — mesmo que pareça mais fácil calcular ali.index.html — renderizaçãodashboard_snapshots o registro mais recente de cada pagina e
desenha os cards, tabelas e gráficos a partir do JSON já pronto.ingest.js (como o número foi calculado), não o index.html (como ele é
exibido) — essa distinção economizou bastante tempo de debug ao longo do
projeto.ca-central-1.dashboard_snapshots (uma linha "mais recente" por
pagina, versionada por gerado_em).dim_embalas, pedidos
etc.) — o ingest.js lê e escreve nelas antes de consolidar o snapshot.index.html.index.html como site estático. Sem variáveis de ambiente
sensíveis no build — as credenciais do Supabase usadas pelo index.html
são a anon key pública (protegida por RLS no backend, não por estar
"escondida" no front).A identidade visual segue a skill vulcabras-visual-identity como base
oficial (paleta, tipografia, padrão de cards, logos). Ela foi adaptada neste
projeto ao longo de várias rodadas de ajuste solicitadas pela gerência —
essas adaptações fazem parte do padrão atual do projeto e devem ser
herdadas por qualquer recriação, não tratadas como "customização única do
report da Vulcabras".
Os tokens abaixo substituem/estendem os tokens-base da skill. A skill define
o padrão de marca (--accent-gold, --positive, --negative,
--brand-miz/oly/ua); o dashboard precisou de tokens adicionais para cobrir
o vocabulário próprio de KPI de armazém (status de NF, ocupação, backlog
FIFO), mantendo a mesma lógica de "cor tem significado, não é decoração".
Tema escuro (padrão):
--olive: #45C645; /* positivo institucional (equivalente a --positive da skill) */
--olive-soft: #1B3320; /* fundo suave para badges/linhas positivas */
--amber: #F6BD00; /* atenção / --accent-gold da skill */
--amber-soft: #332B10;
--red: #C8102E; /* negativo institucional (equivalente a --negative da skill) */
--red-soft: #331015;
--accent: #297ADF; /* azul institucional (--brand-oly da skill), usado como
accent secundário fora do contexto de marca Olympikus */
--accent-soft: #16283F;
--gelo: #93A5B8; /* neutro/"sem dado"/"livre" — --text-secondary da skill */
--laranja: #E8830D; /* estado intermediário (alerta antes do vermelho) */
Tema claro (alternativo):
--olive: #2E9E2E;
--olive-soft: #E4F3E4;
--amber: #C9960A;
--amber-soft: #FBF0D9;
--red: #C0392B;
--red-soft: #FBE4E1;
--accent: #1A6FC4;
--accent-soft: #EFF6FF;
--gelo: #8FA3B8;
--laranja: #C26A00;
Regra de aplicação (decidida em rodada de ajuste com a gerência, "pode
aplicar em tudo"): qualquer barra de destaque de KPI card, badge de status,
ou indicador de card deve usar esses tokens — nunca uma cor solta
hard-coded. Isso vale mesmo para elementos que a skill, no padrão genérico,
recomendaria como "neutros sem cor decorativa" (ex.: a barra lateral de
destaque dos KPI cards, .kpi-card .bar) — no caso deste dashboard esse
padrão de barra é anterior à adoção da skill e foi mantido e recolorido em
vez de removido, por já ser uma referência visual reconhecida pelos usuários
do report. Ao recriar o projeto do zero (sem esse legado visual), prefira
seguir a skill à risca e não introduzir barras decorativas novas.
O gráfico de donut "Backlog FIFO" (dias 01 / 02 / 03 / 04+ em aberto) passou por uma rodada de ajuste dedicada. A primeira proposta (verde-água) foi rejeitada por gerar confusão visual com o indicador de "positivo". A versão aprovada usa lógica de semáforo institucional, com uma exceção explicitamente pedida pela gerência:
// Dia 01 fica no tom de oliva ORIGINAL do gráfico (fixo, não usa var(--olive) —
// essa variável virou o verde institucional mais vivo depois do ajuste de
// paleta geral, e a gerência pediu para manter especificamente neste gráfico
// o tom que já existia antes, para não perder a referência visual de quem já
// lê esse card no dia a dia).
// Dia 02/03/04+ seguem a rampa de semáforo institucional.
const cores = {
'01': '#6B7F3A', // oliva fixo, histórico do gráfico — NÃO trocar por var(--olive)
'02': 'var(--amber)',
'03': 'var(--laranja)',
'04+': 'var(--red)',
};
Lição para a recriação: nem toda paleta institucional se aplica 1:1 a todo componente. Quando um usuário/gestora já tem uma leitura visual consolidada de um gráfico específico, vale preservar aquele tom mesmo que ele destoe levemente do restante da paleta — documentando o motivo no código, como acima, para que a próxima pessoa não "corrija" isso sem contexto.
Seguem a skill sem alteração:
Segoe UI (fallback "Segoe UI Semibold", Roboto, "Helvetica Neue", Arial, sans-serif).--card-bg, borda 1px --card-border, cantos ~8px,
sem sombra pesada.Ajuste feito neste projeto (bug real: a última linha de uma tabela era estilizada como total só por ser a última, mesmo quando era um registro comum — ex. Top 10 NFs do FIFO mostrava a 10ª NF com aparência de "total geral"). Corrigido trocando o seletor CSS de posição por uma classe semântica:
/* ANTES (errado — estilizava por posição): */
table.matrix tbody tr:last-child td { ... }
/* DEPOIS (correto — estilizado por semântica): */
table.matrix tbody tr.total td,
table.matrix tbody tr.total th {
border-top: 2px solid var(--accent);
background: var(--bg-input);
font-weight: 700;
}
E a classe class="total" foi adicionada explicitamente só nas linhas que
são de fato totais/somatórios, em cada função de renderização
(renderMatriz, renderMatrizPendente, renderizarBalanco,
renderizarEstoque). Padrão a seguir em qualquer tabela nova: nunca
estilizar "a última linha" por CSS posicional — sempre marcar a linha de
total com uma classe explícita no HTML gerado.
Esta seção documenta decisões técnicas que não são óbvias olhando só o código final — o valor de registrá-las é evitar reintroduzir os mesmos bugs numa recriação do zero.
Sintoma real que isso corrigiu: o card "Itens por Segmento" (Outbound)
começou a mostrar 100% "Calçados", escondendo os outros 4 segmentos reais
(Chinelos, Acessórios, Vestuários, Meias — confirmado por comparação com
outro sistema). Causa raiz: o Supabase/PostgREST limita a quantidade de
linhas retornadas por requisição a um teto do próprio servidor (comumente
1000), independente do tamanho pedido em .range(). O código antigo
decidia "acabou a paginação" comparando o tamanho da página retornada com o
tamanho do lote pedido — então, ao pedir lotes de 5000, a primeira página
já vinha com só 1000 linhas (o teto do servidor), o código interpretava isso
como "só tem 1000 registros no total" e parava, quando na verdade a tabela
tinha ~263 mil linhas.
Padrão correto (usado agora em toda paginação do ingest.js): parar
somente quando a página vier vazia, e avançar o offset pelo tamanho
real retornado, nunca pelo tamanho do lote pedido:
const mapa = new Map();
let offset = 0;
const LOTE = 5000; // tamanho pedido — pode ser maior que o teto do servidor, sem problema
while (true) {
const { data, error } = await supabaseClient
.from("dim_embalas")
.select("codigo_barra, segmento, marca")
.range(offset, offset + LOTE - 1);
if (error || !data || data.length === 0) break; // só para quando NÃO vem nada
data.forEach(function (e) {
mapa.set(String(e.codigo_barra).trim(), { segmento: e.segmento, marca: e.marca });
});
offset += data.length; // avança pelo que voltou de verdade, não pelo LOTE pedido
}
Regra para qualquer paginação nova (nesta ou em outra base): nunca assumir que "voltou menos que pedi" significa "acabou". Só "voltou zero" significa "acabou".
Sintoma real: erros 409 no console ao reprocessar inbound_nfs /
inbound_itens — a mesma NF aparecia mais de uma vez no arquivo-fonte, em
estágios de status diferentes (ex.: IMPORTADA → EM CARGA/OR →
PROCESSADA), e o insert em lote tentava gravar a mesma chave duas vezes.
Padrão: antes de inserir, reduzir a lista para um registro por chave natural, mantendo o "mais avançado" segundo uma ordem de prioridade de status:
const prioridadeStatusNF = { "IMPORTADA": 0, "EM CARGA/OR": 1, "PROCESSADA": 2 };
const nfPorId = new Map();
linhas.forEach(function (r) {
const reg = { id_nota_fiscal: Number(r["idNotaFiscal"]), status: r["status"], /* ... */ };
const existente = nfPorId.get(reg.id_nota_fiscal);
if (!existente || prioridadeStatusNF[reg.status] > prioridadeStatusNF[existente.status]) {
nfPorId.set(reg.id_nota_fiscal, reg);
}
});
const nfRegistros = Array.from(nfPorId.values());
Esse padrão já existia em outro ponto do ingest.js (pedido_itens) antes
deste ajuste — a correção foi replicar o mesmo raciocínio para inbound_nfs.
Ao adaptar para outro fluxo de dados: qualquer entidade que possa
aparecer mais de uma vez no arquivo-fonte por causa de mudança de status
precisa desse tratamento antes do insert.
Sintoma real: o horário exibido no topbar e no histórico de versões ficava errado (a lógica antiga fazia aritmética manual de "-3 horas", que quebra em horário de verão e com formatos de timestamp inconsistentes vindos do banco).
Padrão: normalizar o timestamp como UTC explícito e formatar com
Intl.DateTimeFormat fixando o timezone de destino — nunca subtrair/somar
horas manualmente:
function paraDataBR(isoStr) {
let s = String(isoStr).replace(' ', 'T');
if (!/[Zz]|[+-]\d{2}:?\d{2}$/.test(s)) s += 'Z'; // assume UTC se não vier timezone
return new Date(s);
}
function componentesDataBR(isoStr) {
const partes = new Intl.DateTimeFormat('pt-BR', {
timeZone: 'America/Sao_Paulo',
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit', hour12: false,
}).formatToParts(paraDataBR(isoStr));
const m = {};
partes.forEach(p => { m[p.type] = p.value; });
return m;
}
function formatarDataBR(isoStr) {
const c = componentesDataBR(isoStr);
return `${c.day}/${c.month}/${c.year} ${c.hour}:${c.minute}`;
}
Toda gravação de timestamp no ingest.js usa new Date().toISOString()
(UTC puro) — a conversão para horário de Brasília acontece só na
exibição, no index.html, nunca na gravação. Regra para recriar em outra
região/fuso: trocar apenas o timeZone: 'America/Sao_Paulo' — o resto do
padrão (gravar em UTC, converter só na exibição) vale para qualquer fuso.
Sintoma real: erros 400 de "tamanho excede o limite" ao subir os arquivos originais (TSV) para o Supabase Storage como backup.
Padrão: comprimir no navegador antes do upload, com fallback silencioso para o upload cru caso a compressão falhe (compatibilidade com navegadores antigos e com arquivos já enviados sem compressão antes deste ajuste):
async function uploadArquivoOriginal(caminho, file) {
try {
const cs = new CompressionStream('gzip');
const comprimido = await new Response(file.stream().pipeThrough(cs)).blob();
await supabaseClient.storage.from('backups').upload(caminho, comprimido, {
contentType: 'application/gzip',
});
} catch (e) {
// fallback: navegador sem suporte a CompressionStream, ou erro na compressão
await supabaseClient.storage.from('backups').upload(caminho, file);
}
}
No download (baixarArquivoOriginal), o processo é o inverso — tenta
DecompressionStream('gzip') e cai para o blob cru se falhar, cobrindo os
arquivos que foram enviados antes deste padrão existir.
Sintoma real: o card "Forecast × Expedição" nunca mostrava o dado de sexta-feira, mesmo depois de reprocessar a base — o fechamento diário só rodava para "hoje", então se o operador reprocessava numa segunda, a sexta-feira nunca tinha sido fechada.
Padrão: em vez de fechar só o dia corrente, o ingest.js percorre os
últimos 7 dias a cada execução e fecha (ou refaz) o fechamento de cada um,
tornando o processo auto-recuperável independente de quando o operador rodar
a atualização:
const hoje = new Date();
for (let diasAtras = 1; diasAtras <= 7; diasAtras++) {
const dAlvo = new Date(hoje);
dAlvo.setDate(hoje.getDate() - diasAtras);
await fecharExpedicaoDoDia(pedidos, paraDataISOLocal(dAlvo));
}
Sintoma real: o Balanço WMS×SAP e a tabela "Estoque WMS — Detalhado" mostravam totais diferentes (1.270.011 vs 1.273.397). A diferença de 3.386 era exatamente uma linha — "Aguardando ação fiscal (Emissão NF-D)" — aparecendo com o valor certo no detalhado e zerada no balanço.
Causa raiz: o cruzamento do balanço é montado a partir de uma lista fixa
(CROSS_MAP) que traduz o nome da classificação no WMS para o BIN
equivalente no SAP. A chave do lado WMS estava escrita como
"Aguardando ação fiscal", sem o sufixo "(Emissão NF-D)" que a regra de
classificação no Postgres realmente produz. A busca não encontrava a chave,
caía no default { qtde: 0 } — e não gerava erro nenhum. O volume
simplesmente evaporava do balanço.
Esse é o pior tipo de bug para um relatório de conferência: o número errado não parece errado. Duas defesas foram adicionadas:
1. Rede de segurança — qualquer classificação do WMS ou BIN do SAP que
não esteja no CROSS_MAP é anexada ao cruzamento como linha própria, em vez
de descartada:
var wmsCobertas = {}, sapCobertos = {};
CROSS_MAP.forEach(function (m) { wmsCobertas[m.wms] = true; sapCobertos[m.bin] = true; });
Object.keys(wmsPorClass).forEach(function (c) {
if (wmsCobertas[c]) return;
var d = wmsPorClass[c];
if (!d.qtde && !d.valor) return;
console.warn("Balanço: classificação do WMS fora do CROSS_MAP:", c, d);
cruzamento.push(montarLinha(c + " (sem BIN no SAP)", "—", c, d, { qtde: 0, valor: 0 }));
});
// ...e o espelho equivalente para BINs do SAP fora do CROSS_MAP
2. Conferência de fechamento — depois de montar o cruzamento, os totais são comparados com os totais gerais; divergência vira erro no console durante a atualização de base, em vez de virar um número errado na tela:
var confWMS = cruzamento.reduce(function (s, r) { return s + r.wms_qtde; }, 0);
var confSAP = cruzamento.reduce(function (s, r) { return s + r.sap_qtde; }, 0);
if (confWMS !== totWMSQ || confSAP !== totSAPQ) {
console.error("Balanço: divergência de reconciliação! ...");
}
Regra para a recriação: todo relatório que cruza duas fontes através de uma tabela de-para fixa precisa dessas duas defesas. Uma lista de tradução escrita à mão vai ficar dessincronizada da regra que gera os nomes — a questão é só quando. O sistema tem que reagir a isso mostrando a sobra, nunca descartando-a.
Regra irmã (ver 7.1): quando a mesma regra de negócio existe em dois lugares — a function no Postgres e o espelho em JS —, os dois precisam ser alterados juntos. Foi essa dessincronização que produziu o gabarito errado de endereços antes deste mesmo bug.
Situação real: o Balanço mostrava a classificação "Integração de NFs Reversa" com 2.214 itens no WMS contra 78.022 no SAP (BIN 009-24) — uma divergência enorme que não representava perda nenhuma.
Causa: uma NF de reversa entra no SAP assim que é recebida fiscalmente, mas
só aparece no arquivo de estoque do WMS depois de ser fisicamente
armazenada num endereço. Entre os dois momentos, a NF fica com status
IMPORTADA ou EM CARGA/OR — existe no SAP, ainda não existe em endereço do
WMS. Comparar os dois lados sem considerar isso transforma uma diferença de
tempo de processo em aparente diferença de saldo.
Padrão: o lado WMS recebe o saldo pendente somado à classificação correspondente, com três cuidados:
IMPORTADA + EM CARGA/OR); bastou publicá-lo no payload dela
(pendente_integracao) e o Balanço passar a consumi-lo.O valor em R$ do ajuste é calculado com a mesma base de custo (dim_custo)
que o resto do Balanço usa, para os dois lados não saírem de réguas
diferentes.
Regra para a recriação: antes de tratar qualquer divergência entre dois sistemas como erro, mapear as etapas de processo entre eles. Toda vez que um sistema registra um evento antes do outro, existe um saldo em trânsito que precisa entrar na conta explicitamente. Vale perguntar à operação, para cada classificação: "existe algum momento em que isso já está num sistema e ainda não está no outro?"
Situação real: o report diário do operador (enviado por e-mail à
diretoria) usava dois arquivos montados à mão no Excel — um PROCV do pedido
do WMS contra o Primário do SAP para extrair Pedido VTEX e valor, e um
filtro manual de pedidos com 3+ dias de backlog. Automatizamos os dois como
botões de exportação.
A decisão de design aqui é diferente dos outros exports do projeto (que
sempre baixam de novo o arquivo original do Storage, sem processar nada). Os
dois botões do report diário fazem algo nunca feito antes no projeto:
consultam a tabela pedidos ao vivo e montam a planilha .xlsx na hora,
no navegador, com SheetJS (já carregado para ler .xlsx de entrada — reaproveitado
aqui para escrever). Motivo: o PROCV do usuário já existia dentro do
ingest.js (sapPorPedido, ver 1.1), só não persistia os dois campos que o
report precisava — bastou persistir e consultar, sem inventar um cálculo novo
nem um snapshot novo só para isso.
Dois cuidados replicam padrões já estabelecidos neste documento:
ingest.js para os cards, replicado em index.html só para o rótulo do
arquivo exportado — é leitura de apoio para exibição, não recálculo de
regra de negócio, então não fere a separação da seção 1.Regra para a recriação: nem toda exportação precisa de um snapshot dedicado. Quando o dado já está numa tabela relacional e a extração é simplesmente "filtra e formata", uma consulta ao vivo no clique do botão é mais simples e sempre está atualizada — reserve o padrão de snapshot pré-calculado (seção 1) para o que realmente precisa de agregação pesada.
Situação real: um pedido específico (parado desde 14/08) apareceu nas exportações do report diário sem Pedido VTEX/Valor, e sumido do gráfico de Backlog FIFO na tela — dois sintomas aparentemente sem relação, mesma causa.
Causa: pedidos.situacao era escrito como "ABERTO" a cada processamento e
nunca mais tocado se aquele pedido parasse de aparecer no arquivo
Acompanhamento_Op do dia seguinte (resolvido por outro caminho, corrigido
manualmente, etc.). O campo ficava congelado em "ABERTO" para sempre — um
registro fantasma. A tela nunca mostrava esse pedido (o payload é calculado
do zero a cada rodada, só com o que está no arquivo do dia), mas qualquer
consulta nova que confiasse no situacao já persistido no banco (como as
exportações, que precisam buscar o dado ao vivo — ver 3.8) herdava o
fantasma.
Padrão: "em operação" não é um status que se grava e mantém — é uma pergunta que se refaz a cada consulta: "este pedido está no Acompanhamento_Op da atualização mais recente?" Implementado com um timestamp de rodada, não com um booleano:
// ingest.js — um único timestamp por rodada inteira do Outbound
const momentoOpIso = hoje.toISOString();
// ... por linha, só quando vem do Acompanhamento_Op (nunca do Exp):
presente_no_op_em: linha.origem === "Acompanhamento_Op" ? momentoOpIso : null,
Isso resolve o registro fantasma dentro do ingest.js: o filtro do KPI
(gerarPayloadOutbound) passou a checar presente_no_op_em explicitamente,
em vez de confiar só em situacao, então pedidos_detalhe (o array que
monta a tela — KPIs, cards, o gráfico de Backlog FIFO) nunca mais inclui um
pedido congelado.
Mas isso, sozinho, não bastava para as exportações — e aqui veio uma
correção direta do usuário que vale registrar como princípio, não só como
fix pontual: minha primeira versão fazia as exportações reconstruírem "quem
está em operação" com uma SEGUNDA consulta ao banco (mesma lógica de
presente_no_op_em, mas repetida em index.html). Funcionava, mas duas
implementações independentes da mesma regra de negócio são exatamente o tipo
de coisa que diverge silenciosamente mais cedo ou mais tarde — a pergunta do
usuário foi direta: "não quero uma informação se o pedido está no
Acompanhamento Op, quero que os pedidos extraídos sejam os mesmos que estão
nessa tela."
Padrão corrigido: quando já existe um array que decide o que aparece na tela, uma exportação relacionada não deve recalcular o mesmo critério — ela deve ler os IDs desse mesmo array e buscar só as colunas que faltam:
// index.html — os pedidos exportados são, por construção, os mesmos da tela:
// os IDs vêm do array que já monta os KPIs/gráficos, não de uma nova regra.
function idsPedidosDaTela(filtro) {
const detalhe = outboundPayloadAtual.pedidos_detalhe; // o mesmo array da tela
return (filtro ? detalhe.filter(filtro) : detalhe).map(p => p.pedido_venda);
}
// Depois: supabaseClient.from('pedidos').select(colunas).in('pedido_venda', ids)
// — só busca as colunas que pedidos_detalhe não carrega (datas, transportadora,
// Pedido VTEX, Valor), nunca decide de novo QUEM entra.
Regra para a recriação: duas coisas ficam registradas aqui, uma dentro da outra:
presente_no_op_em resolve isso
onde o dado nasce, dentro do ingest.js).pedidos_detalhe), qualquer funcionalidade derivada dela (como um
export) deve ler dali, não reimplementar o mesmo critério em paralelo
— mesmo que a reimplementação pareça, no papel, equivalente.Pedido do usuário: em dia sem nenhum pedido com 3+ dias de backlog (bom
sinal operacional), o botão "Backlog ≥ 3 dias" não pode simplesmente exportar
um arquivo vazio nem um recorte pequeno/arbitrário — precisa de um volume
comparável dia a dia para quem recebe o report. Critério definido: na
ausência de pedidos em 03/04+, exportar os 500 pedidos mais antigos que
estão na tela (por importado_em, do mais antigo pro mais novo).
let ids = idsPedidosDaTela(p => p.backlog_fifo_bucket === '03' || p.backlog_fifo_bucket === '04+');
if (ids.length === 0) {
// Fallback: mesmo array da tela (pedidos_detalhe), ordenado por
// importado_em — nunca uma nova consulta com critério próprio.
ids = detalhe.filter(p => p.importado_em)
.sort((a, b) => new Date(a.importado_em) - new Date(b.importado_em))
.slice(0, 500)
.map(p => p.pedido_venda);
}
Reforça o mesmo princípio da seção 3.9: mesmo o fallback lê do array que já
monta a tela (pedidos_detalhe), nunca de uma query paralela — só muda o
critério de corte dentro dos mesmos dados. Precisou de um campo a mais nesse
array (importado_em, que antes não estava lá — só existia nas colunas
buscadas por ID depois) porque é o campo que define "mais antigo".
Regra para a recriação: quando um relatório recorrente pode legitimamente ficar vazio em um dia bom, decida com o usuário desde já qual é o comportamento de fallback — não deixe pra descobrir depois que alguém recebeu um e-mail sem anexo, ou com um anexo de tamanho estranho. Se o fallback existir, ele deve ser tão previsível/auditável quanto a regra principal (um critério de corte fixo e documentado, não "o que sobrar").
Situação real: os cards "Itens Pendentes Recebimento" (35.211) e "Itens Pendentes Armazenagem" (16.918) somavam mais do que o total da tabela "Pendente de Entrada — Marca × Segmento" ao lado (52.075 contra 52.129) — uma diferença de 54 itens que não deveria existir: são o mesmo dado, só organizado de duas formas diferentes.
Causa: a marca de um item vem de dim_embalas (via código de barras) e, se
não encontrada ali, de um fallback por palavra-chave na descrição do produto
(inferirMarcaPorDescricao) — que pode não reconhecer nada e devolver null.
A tabela cruzada só cria uma coluna por marca reconhecida e soma célula a
célula comparando item.marca === marca — um item com marca null nunca
bate com nenhuma coluna e simplesmente não aparece em lugar nenhum da tabela.
Os cards, por outro lado, somam todos os itens do status pendente sem filtrar
por marca — contam esse item normalmente. A tabela "perde" itens que os cards
"têm".
Padrão: toda vez que um total aparece em dois lugares — um card resumido e uma tabela que abre o mesmo total por categoria —, a categoria "eu não sei classificar isso" precisa ser uma opção explícita da tabela, nunca um motivo pra excluir o item da soma:
const SEM_MARCA = "Sem Marca";
function marcaOuSemMarca(i) { return i.marca || SEM_MARCA; }
// A lista de colunas passa a incluir "Sem Marca" (sempre por último), e toda
// soma por célula usa marcaOuSemMarca(i) === marca em vez de i.marca === marca.
Com isso, a soma da tabela sempre fecha com a soma dos cards — não porque os dois foram calibrados pra bater um dia, mas porque estruturalmente todo item cai em alguma célula, sem exceção.
Regra para a recriação: ao publicar um card com um total e, ao lado, uma tabela que abre esse total por categoria, pergunte: "todo item tem uma categoria garantida, ou existe algum caminho em que a categorização falha silenciosamente?" Se existe (marca não reconhecida, segmento sem mapeamento, cliente sem CNPJ cadastrado etc.), essa tabela precisa de uma linha/coluna "não classificado" — a alternativa é o card e a tabela divergirem aos poucos, de um jeito pequeno demais pra virar bug óbvio, mas grande o suficiente pra alguém desconfiar do relatório inteiro.
Ajuste direto do gestor sobre o padrão anterior (3.11): em vez de uma
categoria "Sem Marca"/"Sem Segmento" para os itens que a base de embalagem
(dim_embalas) não resolve, aplicar as regras de negócio reais que a
operação já usa manualmente para esses casos:
dim_embalas (via código de barras) → palavra-chave no título
do produto (inferirMarcaPorDescricao, já existia) → Olympikus como
default final (é a marca dominante do mix; nunca deixar um item sem marca
nenhuma).dim_embalas primeiro → quando não resolve, o tamanho no
final do Código do Produto decide. Formato observado:
PREFIXO-COR-TAMANHO (ex.: 101182182-014-42, P2GEDW01-030-M) — tamanho
numérico (28-44) = Calçados; tamanho de letra (PP/P/M/G/GG) = Vestuário.function segmentoPorCodigoProduto(codigoProduto) {
var partes = String(codigoProduto || "").trim().toUpperCase().split(/[-_\s]+/).filter(Boolean);
var tamanho = partes.length ? partes[partes.length - 1] : "";
if (["PP","P","M","G","GG"].indexOf(tamanho) !== -1) return "Vestuário";
if (/^\d+$/.test(tamanho)) return "Calçados";
return "Calçados"; // fallback conservador quando o código foge do padrão
}
Essa regra só se aplica onde o Código do Produto existe (Recebimento — NFs).
O Stage (endereços H/I/J) só traz Local/Barra/Produto no arquivo-fonte, sem
Código do Produto — nesse caso, sem match em dim_embalas, o segmento cai no
fallback anterior ("Calçados", o segmento dominante).
Regra para a recriação: uma categoria "não identificado" genérica é aceitável como estrutura de segurança (garante que a soma sempre feche — ver 3.11), mas não é o destino final de um dado sem classificação clara. Sempre vale perguntar ao gestor da operação: "existe algum sinal no próprio dado (código, descrição, endereço) que já resolve isso na prática, mesmo sem o cadastro formal?" — geralmente existe, e é mais preciso que qualquer default genérico.
Ao longo do projeto surgiu um padrão implícito de decisão que vale explicitar: todo indicador numérico que soma/fecha um agrupamento (capacidade, estoque, balanço) ganha uma linha "Total Geral" separada por uma borda sutil, abaixo dos indicadores individuais — nunca escondido dentro de um dos indicadores existentes. Exemplo: nos cards de Capacidade × Ocupação (Calçados/Vestuário), a soma de "Ocupado + Livre" ganhou uma linha própria de "Total Geral" abaixo de "Livre":
// dentro de renderizarEstoque(), por segmento (Calçados/Vestuário)
// segCalc.cap / segVest.cap já vêm prontos do snapshot do ingest.js
`<div class="linha-total" style="border-top:1px solid var(--border); line-height:1.2">
<span>Total Geral</span><span>${segCalc.cap}</span>
</div>`
Esse padrão de "não sobrecarregar um indicador existente, sempre abrir uma linha nova para o total" deve ser seguido para qualquer card de capacidade ou ocupação que a distribuidora venha a precisar.
O modelo de perfil hoje é perfis_acesso (Supabase) — tabela que mapeia
user_id → role, com três valores possíveis: admin, gestor, e o padrão
implícito de operador (qualquer usuário sem role elevada).
aplicarVisibilidadeMenu() esconde grupos inteiros do menu lateral conforme
o perfil:
function aplicarVisibilidadeMenu() {
const labels = document.querySelectorAll('.sidebar-section-label');
labels.forEach(label => {
const nome = label.textContent.trim();
let visivel = true;
if (nome === 'Gestão') visivel = (perfilAtual === 'gestor' || perfilAtual === 'admin');
if (nome === 'Admin') visivel = (perfilAtual === 'admin');
label.style.display = visivel ? '' : 'none';
let irmao = label.nextElementSibling;
while (irmao && !irmao.classList.contains('sidebar-section-label') && !irmao.classList.contains('sidebar-historico')) {
irmao.style.display = visivel ? '' : 'none';
irmao = irmao.nextElementSibling;
}
});
// Reversa é um caso à parte: mora dentro do grupo "Operação" (visível a
// todo mundo por padrão), mas o acesso a ela é restrito só ao Admin — nem
// Gestão vê. Por isso é tratado fora do loop acima, sobrescrevendo o que o
// grupo "Operação" definiu para esse item específico.
const menuReversa = document.getElementById('menuReversa');
if (menuReversa) menuReversa.style.display = (perfilAtual === 'admin') ? '' : 'none';
}
Segunda trava, no roteamento de seção — cobre o caso de alguém forçar a navegação manualmente (ex. console do navegador), não só esconder o botão:
function mostrarSecao(secao) {
if (secao === 'reversa' && perfilAtual !== 'admin') return;
// ...segue o fluxo normal de troca de seção
}
Isto precisa ficar registrado com destaque: as duas travas da seção 5.1
protegem a interface, não o dado — um usuário com o anon key público e
conhecimento técnico ainda consulta a API do Supabase direto. RLS é a camada
que garante que isso não vaza nada, e está habilitada: todas as tabelas
relevantes têm policy verificando perfil_atual() (function SECURITY DEFINER que lê role de perfis_acesso pelo auth.uid()), com o padrão
"gestor/admin sempre, operador só nas páginas dele" replicado em
dashboard_snapshots (pagina in (outbound, inbound, reversa, estoque)) e
no bucket de Storage arquivos-abastecimento (mesmo filtro, por pasta).
O gap real que já aconteceu neste projeto não foi RLS ausente — foi RLS
que ficou pra trás quando uma feature nova passou a ler uma tabela que
antes só o ingest.js (rodando como Admin) tocava. As tabelas pedidos e
dim_acronimos nasceram com uma única policy, somente_admin (ALL,
role = 'admin') — correto na época, porque só o Admin gravava e só o Admin
lia (via ingest.js). Quando os botões de exportação do Outbound (3.8)
foram adicionados fazendo SELECT ao vivo direto nessas duas tabelas em
vez de ler do dashboard_snapshots já liberado por perfil, ninguém
atualizou a RLS — e RLS falha em silêncio: a query não dá erro, só
volta vazia. Gestor e Operador testaram e o arquivo saía sem nenhuma linha;
Admin testava e funcionava normal, porque só o Admin testava com a policy
certa. Corrigido adicionando uma segunda policy SELECT-only pras duas
tabelas, liberando gestor/operador/admin, mantendo a somente_admin
original intacta pra escrita (INSERT/UPDATE/DELETE continuam só
Admin, via ingest.js).
Checklist obrigatório sempre que uma feature nova passa a ler uma tabela
que antes só o ingest.js tocava (nesta base ou numa recriação):
index.html, checar
select * from pg_policies where tablename = '<tabela>' — se só existir
uma policy ALL/admin, ela não cobre leitura de nenhum outro perfil.SELECT separada pros perfis que a tela em questão já
expõe (espelhar o que dashboard_snapshots já faz), sem tocar na
policy de escrita existente.Documentar o processo de trabalho é tão importante quanto o código, porque é o que deve se repetir na recriação:
index.html de produção) e obter aprovação explícita antes de aplicar no
arquivo real. Isso evitou retrabalho em pelo menos dois ajustes deste
projeto (paleta do FIFO, card de capacidade).ingest.js, nunca no
index.html. Como a renderização só lê o que já veio pronto, um número
errado na tela quase sempre nasce no cálculo, não na exibição — foi assim
com o bug do "Itens por Segmento" (paginação) e com o dos 409 de NF
duplicada.O pedido que originou este README foi: recriar o projeto para a distribuidora, mantendo a base técnica e a identidade visual, mas com diferenças claras de processo e fluxo de dados em relação ao CD Vulcabras Extrema-MG. Este é o roteiro para fazer isso sem perder os aprendizados acima.
ingest.js (processa e escreve) /
Supabase (armazena) / index.html (lê e renderiza). Não misturar
responsabilidades, mesmo que o novo contexto pareça "mais simples" no
começo.vulcabras-visual-identity como base, com os
tokens de paleta já ajustados na seção 2 (inclusive
a exceção do oliva fixo no gráfico de FIFO, se a distribuidora também tiver
um indicador de backlog por dias em aberto).Isto é o que muda de fato entre "CD de e-commerce da Vulcabras" e "distribuidora" — não são detalhes técnicos, são decisões de negócio que determinam o schema e as seções do dashboard:
ingest.js (nomes de coluna, formatos
de arquivo, frequência de atualização) — mas não muda o padrão de como
processar (dedup, paginação segura, etc.).admin / gestor / operador
fazia sentido no CD porque reflete a hierarquia real de quem usa o
report. Confirmar se a distribuidora tem a mesma hierarquia ou outra
(ex. representante comercial, coordenador de logística, financeiro) —
o padrão de código (esconder grupo de menu + trava de rota +
RLS no banco) se replica, os nomes de role e as regras de "quem vê o
quê" precisam ser levantados de novo.dashboard_snapshots (mesma
estrutura conceitual: uma linha "mais recente" por página, JSON pronto
para renderizar).pedidos e
dim_acronimos quando os exports do Outbound foram criados). Testar
logado como o perfil mais restrito antes de considerar qualquer feature
"pronta para operação", não só como Admin.index.html: manter a estrutura de tema (data-tema claro/
escuro), os tokens de paleta da seção 2.1, o padrão de cards da skill, e
trocar apenas o conteúdo das seções pelas equivalentes da distribuidora.ingest.js: manter os padrões da seção 3 (paginação, dedup,
data em UTC, gzip), trocar o parsing dos arquivos-fonte pelos formatos
reais da distribuidora.vulcabras-visual-identity (paleta,
tipografia, padrão de card, logos, filtro de tingimento para tema claro).dashboard_snapshots.index.html.index.html (render), ingest.js (processa),
favicon.png / MIZ.png / OLY.png / UA.png (logos), mapa-brasil.js
(dado geográfico auxiliar), relatorio.html (relatório correlato).Fluxograma do processo operacional do CD (Recebimento → Armazenagem → Separação → Packing → Expedição, mais o fluxo à parte de Reversa), no menu Processos → Fluxo de Processos, visível a todos os perfis — é material de treinamento, não só de gestão. Quem tem perfil admin também edita, ao vivo, no navegador.
É a mesma página do report-DISTR (seção 10 do README de lá): mesmo motor de desenho, mesmo editor, mesmo CSS, mesmo modelo de dados. Só muda o conteúdo do processo.
| Arquivo | O que é |
|---|---|
ecom-fluxo.js | Renderizador + editor. Cópia do distr-fluxo.js. |
index.html | Menu "Processos", seção #secao-fluxo, CSS #dfRoot, busca na topbar. |
migracao_rls_ecom_fluxo.sql | Libera leitura de ecom_fluxo ao operador. |
seed_ecom_fluxo.sql | Primeira versão publicada (esqueleto das fases). |
dashboard_snapshots.pagina = 'ecom_fluxo'.tipo_snapshot e data_snapshot são
NOT NULL, então o "Salvar alterações" grava tipo_snapshot='manual',
data_snapshot e gerado_em (UTC, mesmo padrão do ingest.js).leitura_por_perfil lista as páginas do operador — ecom_fluxo foi
adicionada (migracao_rls_ecom_fluxo.sql). A escrita continua só admin
(escrita_somente_admin). Checklist da seção 5.2 aplicado.payload.guia ([{titulo, itens:[[termo, texto]]}]). Cada grupo é
um botão que abre uma janela flutuante (o <dialog> do detalhe, versão
larga) com os itens em colunas. Não é editável pela tela (ainda): muda por
seed — o "Salvar alterações" só preserva o que já está lá.payload.mapa: classificação,
BIN do SAP, endereços do WMS, vendável/bloqueado, etapa do fluxo). Espelha a
regra do banco — function calcular_balanco_wms_final + tabela
gabarito_endereco_excecao (K04, K05). Mudou o gabarito lá, republique o
mapa junto.status: "aberto" na etapa): contorno tracejado
laranja + selo, status no detalhe e opção no editor. Para etapas cuja regra
ainda depende de validação conjunta com outras áreas (Fiscal, Controladoria).divergencias):
disparada na Devolução 4 (losango "confere com a NF de devolução?").
Bifurcação com 4 caminhos — a CSS dá a cada coluna no mínimo a largura da
caixa (.df-frow com 4+ .df-fcol); a trilha rola para o lado se precisar.ESCALA em ecom-fluxo.js). O antigo "ajuste à
tela" media a largura incluindo as linhas full-bleed das fases e sempre caía
no piso de 85%. A medição agora esconde essas linhas (.df-medindo).Botão ✏️ Editar fluxo → clicar numa seta insere etapa/quebra/B.O.; clicar numa etapa ou losango abre o painel de edição; Setores, + Nova fase, Salvar alterações / Descartar. Detalhes completos no README do report-DISTR, seção 10.
Rodar uma vez, no SQL Editor do projeto "Report - Operacional":
migracao_rls_ecom_fluxo.sql e depois seed_ecom_fluxo.sql. O seed publica
o esqueleto (5 fases + Reversa, uma "Primeira etapa" em cada).
seed_ecom_fluxo_v3.sql publica o fluxo real validado com a operação
(8 fases, 97 etapas, guia da operação) como versão nova.
mostrarSecao('fluxo') chama EcomFluxo.redesenhar().sis, ex.: WMS/SAP) em cada etapa: aparece no detalhe e
no editor e entra na busca. "Armazém origem → destino" só aparece no detalhe
quando a etapa movimenta entre armazéns.left/right:-9999px)
são recortadas com .conteudo:has(#secao-fluxo.ativa) { overflow-x: hidden },
porque aqui quem rola é o .conteudo (na DISTR era o <html>).