Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions db/snapshot/SNAPSHOT.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,32 @@

| Campo | Valor |
|---|---|
| **Status** | 🔴 **OBSOLETO desde 2026-07-28** — duas migrations foram APLICADAS no banco e não estão neste dump: `20260728000000_profiles_guia_visto` (coluna `profiles.guia_visto`) e `20260728120000_plano_tipo_saas_completo` (CHECK de `client_onboarding.plano_tipo` com `saas`/`completo`). Todo o resto do schema segue fiel — nenhuma outra migration entrou desde o re-dump de 2026-06-30. |
| Dump gerado em | **2026-06-30** (`supabase db dump --linked` + `--role-only` + `--data-only`, projeto `tdlxqqgechxhkygdmsxq`, CLI v2.84.0) |
| Commit git no dump | `main` @ `55f2c61` (trilha catálogo detalhamentos A–E mergeada: PRs #24 + #28) |
| `ultima_migration_incluida` | **`20260718000000`** (`20260718000000_renome_mov_patrimonial.sql`) — confirmado no seed (`Empréstimos, Sócios e Bens`) |
| **Status** | 🟢 **AUTORITATIVO** — re-dump em 2026-07-30. As duas migrations que faltavam entraram: `20260728000000_profiles_guia_visto` e `20260728120000_plano_tipo_saas_completo`. |
| Dump gerado em | **2026-07-30** (`supabase db dump --linked` + `--role-only` + `--data-only`, projeto `tdlxqqgechxhkygdmsxq`, CLI v2.84.0, Docker 29.1.3) |
| Commit git no dump | `fix/ia-resguardo-provider` @ `24b9967` |
| `ultima_migration_incluida` | **`20260728120000`** (`20260728120000_plano_tipo_saas_completo.sql`) — confirmado no schema (`'saas'::text` no CHECK de `client_onboarding.plano_tipo`) |
| `ultima_migration_no_repo` | **`20260728120000`** (`20260728120000_plano_tipo_saas_completo.sql`) |
| **Veredito** | 🔴 **obsoleto** — `ultima_migration_incluida (20260718000000) < max(repo) (20260728120000)`. Faltam só as duas colunas/CHECK acima; o histórico abaixo segue válido para tudo que já estava dentro. ~~🟢 autoritativo~~ — Validado no dump fresco: **(schema)** `get_dre_readiness` com `#variable_conflict use_column` (fix `20260625` aplicado — não é mais buraco; RPC validada ao vivo nos 4 projetos, pode_publicar=true na Vertímetal); **(seed)** `socio_entrada`/`socio_saida` (B, +4 tx Vertímetal re-mapeadas) · 4 subs de sócio legado `categorizavel=false` sem impeditivo · categoria renomeada `Empréstimos, Sócios e Bens` (D, 190× incl. denormalizado). Trilha A/C/E (D8 split antecipação, pílula intercompany, gatilhos cartão) é TS puro — sem artefato de banco. |
| **Veredito** | 🟢 **autoritativo** — `ultima_migration_incluida (20260728120000) >= max(repo) (20260728120000)`. Diff contra o dump de 2026-06-30: **10 linhas, só as duas migrations pendentes** (`profiles.guia_visto` jsonb + CHECK `plano_tipo` com `saas`/`completo`). **Zero drift fora de migration** — nenhum outro objeto mudou. `calculate_monthly_dre_v3` **byte-idêntica** à do dump anterior. |

> Regra: autoritativo ⟺ `ultima_migration_incluida >= max(timestamp em supabase/migrations/)`.
> Nota: `20260625` (fix `get_dre_readiness`) **deixou de ser buraco** — aplicado em 2026-06-30.
> Arquivos: `schema.sql` **14.596 linhas** (CLI v2.84.0) · `roles.sql` (13 ln, re-dumpado) · `seed.sql` (24.641 ln, dados, gitignored; re-dumpado nesta rodada). Ver `README.md`.
> Arquivos: `schema.sql` **14.605 linhas** (CLI v2.84.0) · `roles.sql` (13 ln) · `seed.sql` (24.800 ln, dados, gitignored). Ver `README.md`.

### Validação da cascata da DRE (re-dump 2026-07-30)

Confirmado no `seed.sql` fresco, tabela `dre_categories` (12 linhas, espelha
`src/lib/categorization-rules.ts`):

| id | linha_dre | sinal | entra_no_resultado |
|---|---|---|---|
| `pessoas` | **50** | `-` | **true** |
| `despesas_administrativas` | **50** | `-` | **true** |

Duas categorias dividem a **L50** — e `calculate_monthly_dre_v3` agrupa por
`linha_dre` (`GROUP BY mes_ref, linha_dre`), logo o **motor SQL soma as duas**.
Os motores TS (`calcularDREMultiMensal`, análise forense, `buildCascade`)
enumeram `categoria_id` na mão e **ignoram `pessoas`** — divergência medida na
Vertímetal: **R$ 465.406,74/mês** (R$ 13.962.202,16 em 30 meses fechados).
Auditoria completa: sessão 2026-07-30.

## Checklist de validação — Fases 0–4 + cadeia multi-CNPJ + trilhas pós-19/mai

Expand Down
11 changes: 10 additions & 1 deletion db/snapshot/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -4335,7 +4335,7 @@ CREATE TABLE IF NOT EXISTS "public"."client_onboarding" (
"progresso" integer DEFAULT 0,
"created_at" timestamp with time zone DEFAULT "now"() NOT NULL,
"updated_at" timestamp with time zone DEFAULT "now"() NOT NULL,
CONSTRAINT "client_onboarding_plano_tipo_check" CHECK (("plano_tipo" = ANY (ARRAY['modulo_unico'::"text", 'plano_voo_completo'::"text", 'acompanhamento'::"text"]))),
CONSTRAINT "client_onboarding_plano_tipo_check" CHECK (("plano_tipo" = ANY (ARRAY['saas'::"text", 'completo'::"text", 'modulo_unico'::"text", 'plano_voo_completo'::"text", 'acompanhamento'::"text"]))),
CONSTRAINT "client_onboarding_reuniao_semanal_dia_check" CHECK (("reuniao_semanal_dia" = ANY (ARRAY['segunda'::"text", 'terca'::"text", 'quarta'::"text", 'quinta'::"text", 'sexta'::"text"]))),
CONSTRAINT "client_onboarding_status_check" CHECK (("status" = ANY (ARRAY['incompleto'::"text", 'completo'::"text", 'em_revisao'::"text"])))
);
Expand All @@ -4344,6 +4344,10 @@ CREATE TABLE IF NOT EXISTS "public"."client_onboarding" (
ALTER TABLE "public"."client_onboarding" OWNER TO "postgres";


COMMENT ON COLUMN "public"."client_onboarding"."plano_tipo" IS 'Plano contratado. Vigentes: saas (só sistema) | completo (12m consultoria + sistema). Legado somente-leitura: modulo_unico, plano_voo_completo, acompanhamento (era dos 3 módulos).';



CREATE TABLE IF NOT EXISTS "public"."project_members" (
"id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL,
"user_id" "uuid" NOT NULL,
Expand Down Expand Up @@ -6728,13 +6732,18 @@ CREATE TABLE IF NOT EXISTS "public"."profiles" (
"updated_at" timestamp with time zone DEFAULT "now"() NOT NULL,
"must_change_password" boolean DEFAULT false,
"onboarding_completed_at" timestamp with time zone,
"guia_visto" "jsonb" DEFAULT '{}'::"jsonb" NOT NULL,
CONSTRAINT "profiles_role_check" CHECK (("role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text", 'sdr'::"text", 'cliente'::"text"])))
);


ALTER TABLE "public"."profiles" OWNER TO "postgres";


COMMENT ON COLUMN "public"."profiles"."guia_visto" IS 'Faixas do guia já dispensadas por este usuário: { "<id da tela>": "<ISO timestamp>" }. Ver docs/atros-v3/guia-cliente-conteudo.md.';



COMMENT ON CONSTRAINT "profiles_role_check" ON "public"."profiles" IS '5 roles: owner (dono único), admin (sócios), consultor (toca projetos), sdr (pré-vendas), cliente';


Expand Down
13 changes: 10 additions & 3 deletions src/app/api/projetos/[id]/retrato/narrativa/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,10 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
const { data: proj } = await ssr.from("projects").select("id").eq("id", id).maybeSingle();
if (!proj) return NextResponse.json({ error: "sem acesso ao projeto" }, { status: 403 });

const isOpenAI = (process.env.AI_PROVIDER ?? "openai").toLowerCase() === "openai";
// Override da família OpenAI — ignorado quando a chamada roda no Gemini
// (default do ai-client e destino do fallback). Ver `resolveModel`.
const res = await callAIWithMeta(buildPromptNarrativa(fatos), {
model: isOpenAI ? (process.env.OPENAI_RETRATO_MODEL || "gpt-5-nano") : undefined,
model: process.env.OPENAI_RETRATO_MODEL || "gpt-5-nano",
jsonMode: true,
maxTokens: 1600,
temperature: 0.5,
Expand All @@ -46,7 +47,13 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
});

if (!res.text) {
return NextResponse.json({ error: "IA indisponível" }, { status: 502 });
// A causa real (429 de cobrança, chave ausente, 5xx) vai junto: "IA
// indisponível" sozinho manda quem depura procurar no lugar errado.
console.error("[retrato/narrativa] IA sem resposta:", res.meta);
return NextResponse.json(
{ error: "IA indisponível", causa: res.meta.error ?? null, provider: res.meta.provider },
{ status: 502 },
);
}
const narrativas = extractJson(res.text, narrativasSchema);
if (!narrativas) {
Expand Down
101 changes: 101 additions & 0 deletions src/features/dre/components/DREPivotTable.cascata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* A tabela tem que FECHAR DE CIMA PRA BAIXO.
*
* Este teste anda a cascata renderizada linha a linha, acumulando as categorias,
* e exige que o acumulado bata com cada subtotal exibido. É a regressão do bug
* que o Lucas viu na tela: em jan/25 da Vertímetal, "Lucro Bruto − Despesas
* Administrativas" dava R$ 463.250,03 mas o EBITDA exibido era R$ 35.090,51 —
* os R$ 428.159,52 da folha estavam desenhados lá embaixo, em
* "Informativo · fora do resultado", enquanto o motor já os subtraía.
*/
import { describe, it, expect } from "vitest";
import { buildCascade } from "./DREPivotTable";
import { calcularDREMultiMensal, type TransacaoCategoriazada } from "@/lib/extrato-processor";
import { getCategoriaById } from "@/lib/categorization-rules";

function tx(cat: string, sub: string, credito: number, debito: number, i: number): TransacaoCategoriazada {
return {
id: `t${i}`,
data: new Date("2025-01-15T12:00:00"),
lancamento_original: sub,
lancamento_tratado: sub,
credito,
debito,
categoria_id: cat,
subcategoria_id: sub,
categoria_nome: cat,
subcategoria_nome: sub,
tipo: credito > 0 ? "credito" : "debito",
} as TransacaoCategoriazada;
}

/** Recorte fiel a jan/2025 da Vertímetal (números da tela). */
const JAN25: Array<[string, string, number, number]> = [
["receita_bruta", "vendas_loja_fisica", 1_297_479.91, 0],
["deducoes_receita", "impostos_sobre_vendas", 0, 156_273.69],
["custos", "materia_prima_insumos", 0, 586_886.31],
["despesas_administrativas", "aluguel_condominio", 0, 91_069.88],
["pessoas", "salarios_backoffice", 0, 428_159.52],
["despesas_financeiras", "tarifas_bancarias", 0, 55_876.31],
["tributos_lucro", "irpj", 0, 8_000.00],
["movimentacao_patrimonial", "amortizacao_emprestimos", 0, 20_522.06],
];

describe("cascata renderizada — fecha de cima pra baixo", () => {
const dre = calcularDREMultiMensal(JAN25.map(([c, s, cr, db], i) => tx(c, s, cr, db, i)));
const rows = buildCascade(dre);

it("cada subtotal bate com o acumulado das categorias acima dele", () => {
let acumulado = 0;
const conferidos: string[] = [];
for (const row of rows) {
if (row.kind === "sectionLabel") break; // daqui pra baixo é informativo
if (row.kind === "category") {
acumulado += row.categoria.total;
continue;
}
expect(row.spec.total, `subtotal "${row.spec.label}" não fecha com as linhas acima`)
.toBeCloseTo(acumulado, 2);
conferidos.push(row.spec.label);
}
expect(conferidos).toEqual([
"Receita Líquida",
"Lucro Bruto",
"Resultado Operacional (EBITDA)",
"Resultado Líquido",
]);
});

it("Pessoas / Equipe é desenhada DENTRO da cascata, acima do EBITDA", () => {
const iPessoas = rows.findIndex((r) => r.kind === "category" && r.categoria.id === "pessoas");
const iEbitda = rows.findIndex((r) => r.kind === "subtotal" && r.spec.label.startsWith("Resultado Operacional"));
const iInformativo = rows.findIndex((r) => r.kind === "sectionLabel");
expect(iPessoas).toBeGreaterThan(-1);
expect(iPessoas).toBeLessThan(iEbitda);
expect(iPessoas).toBeLessThan(iInformativo);
});

it("a L50 fecha as DUAS categorias antes de emitir o EBITDA", () => {
const iAdm = rows.findIndex((r) => r.kind === "category" && r.categoria.id === "despesas_administrativas");
const iPessoas = rows.findIndex((r) => r.kind === "category" && r.categoria.id === "pessoas");
const iEbitda = rows.findIndex((r) => r.kind === "subtotal" && r.spec.label.startsWith("Resultado Operacional"));
expect(Math.max(iAdm, iPessoas)).toBeLessThan(iEbitda);
});

it("os números de jan/25 batem com a tela (EBITDA e Resultado Líquido)", () => {
// 1.297.479,91 −156.273,69 −586.886,31 −91.069,88 −428.159,52
expect(dre.totais.resultadoOperacional).toBeCloseTo(35_090.51, 2);
// −55.876,31 −8.000,00
expect(dre.totais.resultadoLiquido).toBeCloseTo(-28_785.80, 2);
});

it("abaixo da linha só entra quem tem entraNoResultado === false", () => {
const iInformativo = rows.findIndex((r) => r.kind === "sectionLabel");
const abaixo = rows.slice(iInformativo + 1);
expect(abaixo.length).toBeGreaterThan(0);
for (const row of abaixo) {
if (row.kind !== "category") continue;
expect(getCategoriaById(row.categoria.id)?.entraNoResultado ?? false).toBe(false);
}
});
});
125 changes: 60 additions & 65 deletions src/features/dre/components/DREPivotTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
} from 'lucide-react';
import { DREMultiMensal, CategoriaDRE, SubcategoriaDRE, PeriodoKey } from '@/lib/extrato-processor';
import type { MonthlySnapshot } from '@/lib/dre-snapshot-service';
import { SUBTOTAL_APOS_LINHA, categoriasDaCascata, sinalDeExibicao } from '@/lib/dre/cascata';

// ============================================================
// PROPS
Expand Down Expand Up @@ -71,7 +72,7 @@ interface SubtotalSpec {
}

/** Linhas que compõem a cascata, em ordem de render. */
type CascadeRow =
export type CascadeRow =
| { kind: 'category'; categoria: CategoriaDRE; sinal: '+' | '−' | null }
| { kind: 'subtotal'; spec: SubtotalSpec }
| { kind: 'sectionLabel'; label: string };
Expand Down Expand Up @@ -114,82 +115,76 @@ function TrendIcon({ value }: { value: number }) {
}

/**
* Monta a cascata a partir dos IDs canônicos do motor. Categorias fora da escada
* (custom de topo, movimentação patrimonial, transferências) caem num bloco
* "informativo · fora do resultado" no pé — honesto, pois o motor não as soma.
* Monta a cascata a partir do CATÁLOGO (`linhaDre` / `entraNoResultado`), na
* mesma ordem de leitura que o motor usa pra somar — ver `src/lib/dre/cascata.ts`.
*
* Antes esta função enumerava ids na mão (`take('despesas_vendas',
* 'despesas_administrativas')`). Quando `pessoas` nasceu na linha 50 pela
* migration `20260713000000`, ela não estava em nenhum `take` e caía no bloco
* "informativo · fora do resultado" — enquanto o motor SQL a subtraía dentro do
* EBITDA. A tabela deixava de fechar de cima pra baixo: na Vertímetal, em
* jan/25, R$ 428.159,52 sumiam entre "Despesas Administrativas" e o EBITDA.
*
* Agora: categoria nova no catálogo aparece no lugar certo sozinha, e só L90/L95/
* L99 (`entraNoResultado === false`) ficam no bloco informativo — que é o que de
* fato não é resultado.
*/
function buildCascade(data: DREMultiMensal): CascadeRow[] {
export function buildCascade(data: DREMultiMensal): CascadeRow[] {
const byId = new Map(data.categorias.map((c) => [c.id, c]));
const used = new Set<string>();
const take = (...ids: string[]): CategoriaDRE[] =>
ids
.map((id) => {
const c = byId.get(id);
if (c) used.add(id);
return c;
})
.filter((c): c is CategoriaDRE => !!c);

const rows: CascadeRow[] = [];
const r = data.resultados;
const t = data.totais;

const pushCats = (cats: CategoriaDRE[], sinal: '+' | '−' | null) => {
for (const categoria of cats) rows.push({ kind: 'category', categoria, sinal });
const subtotalTotais: Record<string, number> = {
receitaLiquida: t.receitaLiquida,
lucroBruto: t.lucroBruto,
resultadoOperacional: t.resultadoOperacional,
resultadoLiquido: t.resultadoLiquido,
};
const margemTotais: Record<string, number> = {
margemBruta: t.margemBrutaMedia,
margemLiquida: t.margemLiquidaMedia,
};

// (+) Receita Bruta → (−) Deduções → = Receita Líquida
pushCats(take('receita_bruta'), '+');
pushCats(take('deducoes_receita'), '−');
rows.push({
kind: 'subtotal',
spec: { label: 'Receita Líquida', valores: r.receitaLiquida, total: t.receitaLiquida, level: 'medium' },
});

// (−) Custos (CMV) → = Lucro Bruto · margem bruta
pushCats(take('custos'), '−');
rows.push({
kind: 'subtotal',
spec: {
label: 'Lucro Bruto',
valores: r.lucroBruto,
total: t.lucroBruto,
level: 'medium',
margem: { label: 'Margem bruta', valores: r.margemBruta, total: t.margemBrutaMedia },
},
});

// (−) Despesas operacionais → = EBITDA
pushCats(take('despesas_vendas', 'despesas_administrativas'), '−');
rows.push({
kind: 'subtotal',
spec: {
label: 'Resultado Operacional (EBITDA)',
valores: r.resultadoOperacional,
total: t.resultadoOperacional,
level: 'medium',
},
});

// (±) Financeiro + Tributos → = Resultado Líquido · margem líquida (herói)
pushCats(take('receita_financeira'), '+');
pushCats(take('despesas_financeiras', 'tributos_lucro'), '−');
rows.push({
kind: 'subtotal',
spec: {
label: 'Resultado Líquido',
valores: r.resultadoLiquido,
total: t.resultadoLiquido,
level: 'hero',
margem: { label: 'Margem líquida', valores: r.margemLiquida, total: t.margemLiquidaMedia },
},
// Categorias que entram no resultado, na ordem da cascata. O subtotal de uma
// linha só sai DEPOIS de desenhar todas as categorias dela — é isso que faz a
// L50 (Pessoas + Administrativas) fechar no EBITDA.
const doCatalogo = categoriasDaCascata();
doCatalogo.forEach((cat, i) => {
const categoria = byId.get(cat.id);
if (categoria) {
rows.push({ kind: 'category', categoria, sinal: sinalDeExibicao(cat) });
}
const proximaLinha = doCatalogo[i + 1]?.linhaDre;
if (proximaLinha === cat.linhaDre) return; // ainda há categorias nesta linha

const spec = SUBTOTAL_APOS_LINHA[cat.linhaDre];
if (!spec) return;
rows.push({
kind: 'subtotal',
spec: {
label: spec.rotulo,
valores: r[spec.chave],
total: subtotalTotais[spec.chave] ?? 0,
level: spec.heroi ? 'hero' : 'medium',
margem: spec.margem
? {
label: spec.margem.rotulo,
valores: r[spec.margem.chave],
total: margemTotais[spec.margem.chave] ?? 0,
}
: undefined,
},
});
});

// Tudo que sobrou NÃO entra na escada do motor — informativo, fora do resultado.
const informativo = data.categorias.filter((c) => !used.has(c.id));
// Abaixo da linha (L90/L95/L99) + qualquer categoria que o motor trouxe e o
// catálogo não conhece — informativo, fora do resultado.
const naCascata = new Set(doCatalogo.map((c) => c.id));
const informativo = data.categorias.filter((c) => !naCascata.has(c.id));
if (informativo.length > 0) {
rows.push({ kind: 'sectionLabel', label: 'Informativo · fora do resultado' });
pushCats(informativo, null);
for (const categoria of informativo) rows.push({ kind: 'category', categoria, sinal: null });
}

return rows;
Expand Down
Loading
Loading