diff --git a/db/snapshot/SNAPSHOT.md b/db/snapshot/SNAPSHOT.md index 376777c..b47e325 100644 --- a/db/snapshot/SNAPSHOT.md +++ b/db/snapshot/SNAPSHOT.md @@ -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 diff --git a/db/snapshot/schema.sql b/db/snapshot/schema.sql index 62cc0f4..a38959e 100644 --- a/db/snapshot/schema.sql +++ b/db/snapshot/schema.sql @@ -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"]))) ); @@ -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, @@ -6728,6 +6732,7 @@ 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"]))) ); @@ -6735,6 +6740,10 @@ CREATE TABLE IF NOT EXISTS "public"."profiles" ( ALTER TABLE "public"."profiles" OWNER TO "postgres"; +COMMENT ON COLUMN "public"."profiles"."guia_visto" IS 'Faixas do guia já dispensadas por este usuário: { "": "" }. 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'; diff --git a/src/app/api/projetos/[id]/retrato/narrativa/route.ts b/src/app/api/projetos/[id]/retrato/narrativa/route.ts index 648a8e3..f1b39e8 100644 --- a/src/app/api/projetos/[id]/retrato/narrativa/route.ts +++ b/src/app/api/projetos/[id]/retrato/narrativa/route.ts @@ -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, @@ -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) { diff --git a/src/features/dre/components/DREPivotTable.cascata.test.ts b/src/features/dre/components/DREPivotTable.cascata.test.ts new file mode 100644 index 0000000..5d89950 --- /dev/null +++ b/src/features/dre/components/DREPivotTable.cascata.test.ts @@ -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); + } + }); +}); diff --git a/src/features/dre/components/DREPivotTable.tsx b/src/features/dre/components/DREPivotTable.tsx index 0f403bb..1136253 100644 --- a/src/features/dre/components/DREPivotTable.tsx +++ b/src/features/dre/components/DREPivotTable.tsx @@ -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 @@ -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 }; @@ -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(); - 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 = { + receitaLiquida: t.receitaLiquida, + lucroBruto: t.lucroBruto, + resultadoOperacional: t.resultadoOperacional, + resultadoLiquido: t.resultadoLiquido, + }; + const margemTotais: Record = { + 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; diff --git a/src/lib/ai-client.test.ts b/src/lib/ai-client.test.ts new file mode 100644 index 0000000..64aa32d --- /dev/null +++ b/src/lib/ai-client.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { __testables, providerConfigurado } from "@/lib/ai-client"; + +const { getProvider, getFallbackProvider, resolveModel } = __testables; + +/** + * O resguardo de provider: quando um lado cai (quota estourada, chave morta), + * o outro assume SOZINHO. Estes testes travam as três peças que faziam isso + * falhar calado em produção: + * + * 1. o fallback só existia com env setada à mão — e ninguém setou; + * 2. o override de modelo vazava entre providers, então o fallback chamava o + * Gemini pedindo "gpt-5-nano" e morria na porta; + * 3. `AI_PROVIDER` ausente resolvia gemini no cliente e openai nos callers. + */ + +const ENV = { ...process.env }; +beforeEach(() => { + delete process.env.AI_PROVIDER; + delete process.env.AI_FALLBACK_PROVIDER; + delete process.env.OPENAI_MODEL; + delete process.env.GEMINI_MODEL; + delete process.env.AI_MODEL; + process.env.OPENAI_API_KEY = "sk-test-000000000000000000000000"; + process.env.GEMINI_API_KEY = "AIza-test-0000000000000000000000"; +}); +afterEach(() => { + process.env = { ...ENV }; +}); + +describe("getProvider — o default", () => { + it("sem AI_PROVIDER, roda no Gemini (o plano grátis é a base)", () => { + expect(getProvider()).toBe("gemini"); + }); + it("só vai pra OpenAI quando pedido explicitamente", () => { + process.env.AI_PROVIDER = "openai"; + expect(getProvider()).toBe("openai"); + }); +}); + +describe("getFallbackProvider — o resguardo", () => { + it("é LIGADO por padrão: o outro provider assume sem ninguém configurar", () => { + expect(getFallbackProvider("openai")).toBe("gemini"); + expect(getFallbackProvider("gemini")).toBe("openai"); + }); + + it("não cai num provider sem chave — não adianta tentar o que não existe", () => { + delete process.env.GEMINI_API_KEY; + expect(getFallbackProvider("openai")).toBeNull(); + delete process.env.OPENAI_API_KEY; + expect(getFallbackProvider("gemini")).toBeNull(); + }); + + it("chave do Gemini curta demais não conta (mesma régua do invoke)", () => { + process.env.GEMINI_API_KEY = "curta"; + expect(providerConfigurado("gemini")).toBe(false); + expect(getFallbackProvider("openai")).toBeNull(); + }); + + it("respeita a escolha explícita, e nunca cai em si mesmo", () => { + process.env.AI_FALLBACK_PROVIDER = "gemini"; + expect(getFallbackProvider("openai")).toBe("gemini"); + expect(getFallbackProvider("gemini")).toBeNull(); + }); + + it("dá pra desligar de propósito", () => { + process.env.AI_FALLBACK_PROVIDER = "off"; + expect(getFallbackProvider("openai")).toBeNull(); + }); +}); + +describe("resolveModel — override não vaza entre providers", () => { + it("no Gemini, um modelo da OpenAI é ignorado (era o que matava o fallback)", () => { + process.env.GEMINI_MODEL = "gemini-2.5-flash-lite"; + expect(resolveModel("gemini", "gpt-5-nano")).toBe("gemini-2.5-flash-lite"); + }); + + it("na OpenAI, um modelo do Gemini é ignorado", () => { + process.env.OPENAI_MODEL = "gpt-4o-mini"; + expect(resolveModel("openai", "gemini-2.5-flash-lite")).toBe("gpt-4o-mini"); + }); + + it("o override vale quando é do provider certo", () => { + expect(resolveModel("openai", "gpt-5-nano")).toBe("gpt-5-nano"); + expect(resolveModel("gemini", "gemini-1.5-pro")).toBe("gemini-1.5-pro"); + }); + + it("nome que não é claramente do outro provider é respeitado (fine-tune, alias)", () => { + expect(resolveModel("openai", "ft:meu-modelo-123")).toBe("ft:meu-modelo-123"); + expect(resolveModel("gemini", "modelo-experimental")).toBe("modelo-experimental"); + }); + + it("sem override cada provider cai no seu default", () => { + expect(resolveModel("openai")).toBe("gpt-4o-mini"); + expect(resolveModel("gemini")).toBe("gemini-2.0-flash"); + }); + + it("o caminho real do bug: AI_PROVIDER ausente + override da OpenAI ⇒ modelo do Gemini", () => { + process.env.GEMINI_MODEL = "gemini-2.5-flash-lite"; + const provider = getProvider(); // gemini + expect(resolveModel(provider, "gpt-5-nano")).toBe("gemini-2.5-flash-lite"); + }); +}); diff --git a/src/lib/ai-client.ts b/src/lib/ai-client.ts index d1acd9b..8fe0b12 100644 --- a/src/lib/ai-client.ts +++ b/src/lib/ai-client.ts @@ -401,21 +401,64 @@ function getProvider(): AIProvider { return 'gemini'; } +/** Há chave configurada para este provider? (mesma régua dos `invoke*`.) */ +export function providerConfigurado(p: AIProvider): boolean { + return p === 'openai' + ? !!process.env.OPENAI_API_KEY + : (process.env.GEMINI_API_KEY ?? '').length >= 20; +} + +/** + * O resguardo: se o primário falhar (quota estourada, chave morta, 5xx), o + * outro provider assume. + * + * **Por padrão o fallback é LIGADO** — o outro provider, desde que tenha chave. + * Antes só existia com `AI_FALLBACK_PROVIDER` setado à mão, e como ninguém + * setou, um 429 de cobrança da OpenAI derrubava a geração inteira mesmo com o + * Gemini disponível e funcionando. Resiliência não pode depender de alguém + * lembrar de configurar. + * + * `AI_FALLBACK_PROVIDER=off` (ou `none`) desliga explicitamente. + */ function getFallbackProvider(primary: AIProvider): AIProvider | null { const v = (process.env.AI_FALLBACK_PROVIDER ?? '').toLowerCase(); - if (v !== 'openai' && v !== 'gemini') return null; - if (v === primary) return null; - return v as AIProvider; + if (v === 'openai' || v === 'gemini') return v === primary ? null : (v as AIProvider); + if (v === 'off' || v === 'none') return null; + + const outro: AIProvider = primary === 'openai' ? 'gemini' : 'openai'; + return providerConfigurado(outro) ? outro : null; } +/** Nomes que denunciam a família do modelo. Alias/fine-tune desconhecido não casa em nenhuma. */ +const FAMILIA_MODELO: Record = { + openai: /^(gpt-|o1|o3|chatgpt|ft:)/i, + gemini: /^(models\/)?gemini/i, +}; + +/** + * Um override de modelo só vale para o provider a que ele pertence. + * + * Sem isto o fallback era decorativo: o caller passa `model: "gpt-5-nano"`, a + * OpenAI estoura a quota, o cliente cai no Gemini — e chama o Gemini pedindo + * "gpt-5-nano", que não existe. O resguardo morria na porta. Mesma armadilha + * quando `AI_PROVIDER` não está setado: o default do cliente é gemini, e um + * caller que resolvia o modelo por conta própria mandava nome de OpenAI. + * + * Nome que não pertence claramente ao OUTRO provider é respeitado — não cabe + * aqui adivinhar fine-tunes e aliases. + */ function resolveModel(provider: AIProvider, override?: string): string { - if (override) return override; + const outro: AIProvider = provider === 'openai' ? 'gemini' : 'openai'; + if (override && !FAMILIA_MODELO[outro].test(override)) return override; if (provider === 'openai') { return process.env.OPENAI_MODEL || process.env.AI_MODEL || 'gpt-4o-mini'; } return process.env.GEMINI_MODEL || process.env.AI_MODEL || 'gemini-2.0-flash'; } +/** Exportado só para teste — a resolução de provider/modelo é a parte que quebra calada. */ +export const __testables = { getProvider, getFallbackProvider, resolveModel }; + function sleep(ms: number) { return new Promise((r) => setTimeout(r, ms)); } diff --git a/src/lib/dre/cascata.test.ts b/src/lib/dre/cascata.test.ts new file mode 100644 index 0000000..7a34186 --- /dev/null +++ b/src/lib/dre/cascata.test.ts @@ -0,0 +1,171 @@ +/** + * Paridade da cascata TS × motor SQL v3. + * + * `motorSqlV3` abaixo replica a álgebra de `calculate_monthly_dre_v3` + * (db/snapshot/schema.sql:483-666, dump autoritativo de 2026-07-30): + * agrupa por `linha_dre`, sinal '+' soma crédito, '-' soma débito, e a cascata + * SUBTRAI. É o gabarito — o TS tem que bater com ele. + * + * Este arquivo é a rede que faltava: foi a ausência dele que deixou a migration + * `20260713000000_catalogo_pessoas_categoria` tirar a folha de pagamento do + * resultado sem nenhum teste ficar vermelho. + */ +import { describe, it, expect } from "vitest"; +import { CATEGORIAS } from "@/lib/categorization-rules"; +import { + LINHA, + agruparPorLinha, + calcularEscada, + categoriasDaCascata, + categoriasAbaixoDaLinha, + SUBTOTAL_APOS_LINHA, +} from "./cascata"; + +/** Um lançamento como ele chega do extrato. */ +interface Lanc { + cat: string; + credito: number; + debito: number; +} + +/** GABARITO — álgebra do calculate_monthly_dre_v3. */ +function motorSqlV3(lancs: Lanc[]) { + const L: Record = {}; + for (const l of lancs) { + const c = CATEGORIAS.find((x) => x.id === l.cat); + const linha = c?.linhaDre ?? 99; + const sinal = c?.sinal ?? "na"; + // CASE sinal WHEN '+' THEN credito WHEN '-' THEN debito ELSE credito-debito + const v = sinal === "+" ? l.credito : sinal === "-" ? l.debito : l.credito - l.debito; + L[linha] = (L[linha] ?? 0) + v; + } + const g = (n: number) => L[n] ?? 0; + const receitaLiquida = g(10) - g(20); + const lucroBruto = receitaLiquida - g(30); + const resultadoOperacional = lucroBruto - g(40) - g(50); + const resultadoLiquido = resultadoOperacional + g(60) - g(65) - g(70); + return { linhas: L, receitaLiquida, lucroBruto, resultadoOperacional, resultadoLiquido }; +} + +/** Converte pra convenção do TS (líquido com sinal) e roda o primitivo. */ +function motorTs(lancs: Lanc[]) { + const porCat: Record = {}; + for (const l of lancs) porCat[l.cat] = (porCat[l.cat] ?? 0) + (l.credito - l.debito); + return calcularEscada(porCat); +} + +function conferirParidade(lancs: Lanc[]) { + const sql = motorSqlV3(lancs); + const ts = motorTs(lancs); + expect(ts.receitaLiquida).toBeCloseTo(sql.receitaLiquida, 2); + expect(ts.lucroBruto).toBeCloseTo(sql.lucroBruto, 2); + expect(ts.resultadoOperacional).toBeCloseTo(sql.resultadoOperacional, 2); + expect(ts.resultadoLiquido).toBeCloseTo(sql.resultadoLiquido, 2); + return { sql, ts }; +} + +describe("cascata — paridade com o motor SQL v3", () => { + it("extrato completo COM folha de pagamento (o bug que originou este arquivo)", () => { + const { ts } = conferirParidade([ + { cat: "receita_bruta", credito: 200_000, debito: 0 }, + { cat: "deducoes_receita", credito: 0, debito: 18_000 }, + { cat: "custos", credito: 0, debito: 90_000 }, + { cat: "despesas_vendas", credito: 0, debito: 8_000 }, + { cat: "despesas_administrativas", credito: 0, debito: 12_000 }, + { cat: "pessoas", credito: 0, debito: 61_000 }, + { cat: "receita_financeira", credito: 500, debito: 0 }, + { cat: "despesas_financeiras", credito: 0, debito: 4_000 }, + { cat: "tributos_lucro", credito: 0, debito: 3_000 }, + ]); + // 200.000 −18.000 −90.000 −8.000 −73.000 +500 −4.000 −3.000 + expect(ts.resultadoLiquido).toBeCloseTo(4_500, 2); + }); + + it("a folha REDUZ o resultado (regressão direta do bug da migration 20260713)", () => { + const semFolha = motorTs([ + { cat: "receita_bruta", credito: 100_000, debito: 0 }, + { cat: "despesas_administrativas", credito: 0, debito: 10_000 }, + ]); + const comFolha = motorTs([ + { cat: "receita_bruta", credito: 100_000, debito: 0 }, + { cat: "despesas_administrativas", credito: 0, debito: 10_000 }, + { cat: "pessoas", credito: 0, debito: 40_000 }, + ]); + expect(semFolha.resultadoLiquido - comFolha.resultadoLiquido).toBeCloseTo(40_000, 2); + expect(comFolha.resultadoLiquido).toBeCloseTo(50_000, 2); + }); + + it("`pessoas` e `despesas_administrativas` somam na MESMA linha 50", () => { + const linhas = agruparPorLinha({ pessoas: -40_000, despesas_administrativas: -10_000 }); + expect(linhas[LINHA.DESPESAS_OPERACIONAIS]).toBeCloseTo(-50_000, 2); + }); + + it("abaixo da linha (L90/L95/L99) NÃO entra no resultado", () => { + const { ts } = conferirParidade([ + { cat: "receita_bruta", credito: 100_000, debito: 0 }, + { cat: "custos", credito: 0, debito: 30_000 }, + { cat: "movimentacao_patrimonial", credito: 700_000, debito: 0 }, // empréstimo recebido + { cat: "transferencia_interna", credito: 50_000, debito: 50_000 }, + { cat: "nao_classificavel", credito: 0, debito: 900 }, + ]); + expect(ts.resultadoLiquido).toBeCloseTo(70_000, 2); + }); + + it("categoria fora do catálogo cai em L99 (blindagem do LEFT JOIN) e não vira resultado", () => { + const linhas = agruparPorLinha({ categoria_que_nao_existe: -5_000, receita_bruta: 10_000 }); + expect(linhas[LINHA.NAO_CLASSIFICAVEL]).toBeCloseTo(-5_000, 2); + expect(calcularEscada({ categoria_que_nao_existe: -5_000, receita_bruta: 10_000 }).resultadoLiquido) + .toBeCloseTo(10_000, 2); + }); + + it("margens: guarda de Receita Líquida > 0 (igual à do SQL)", () => { + expect(calcularEscada({ custos: -100 }).margemBruta).toBe(0); + expect(calcularEscada({ custos: -100 }).margemLiquida).toBe(0); + const e = calcularEscada({ receita_bruta: 1000, custos: -400 }); + expect(e.margemBruta).toBeCloseTo(60, 2); + }); + + it("todas as 12 categorias do catálogo têm lugar na cascata", () => { + const dentro = categoriasDaCascata(); + const fora = categoriasAbaixoDaLinha(); + expect(dentro.length + fora.length).toBe(CATEGORIAS.length); + // ordem de leitura = ordem da cascata + const linhas = dentro.map((c) => c.linhaDre); + expect(linhas).toEqual([...linhas].sort((a, b) => a - b)); + // toda linha com subtotal existe entre as categorias que entram no resultado + for (const linhaStr of Object.keys(SUBTOTAL_APOS_LINHA)) { + expect(dentro.some((c) => c.linhaDre === Number(linhaStr))).toBe(true); + } + }); + + it("a cascata é dirigida pelo catálogo: categoria nova entra sozinha", () => { + const catalogoFuturo = [ + ...CATEGORIAS, + { id: "marketing", nome: "Marketing", tipo: "despesa" as const, ordem: 99, linhaDre: 40, entraNoResultado: true, sinal: "-" as const }, + ]; + const e = calcularEscada({ receita_bruta: 100_000, marketing: -7_000 }, catalogoFuturo); + expect(e.resultadoOperacional).toBeCloseTo(93_000, 2); + }); +}); + +describe("cascata — divergência CONHECIDA de convenção de sinal (trilha própria)", () => { + // O SQL faz `sinal '-' → SUM(debito)`: um crédito numa linha de despesa + // contribui ZERO e some da cascata inteira (não vai pra L90 nem L99). O TS + // faz crédito−débito. Em regime de caixa o TS é que está certo, mas alinhar + // exige mexer na v3 → invalida snapshot e obriga re-fechamento. + // Estes testes DOCUMENTAM a divergência. Quando a trilha fechar, viram `it`. + it.fails("estorno numa categoria de DESPESA: SQL ignora o crédito", () => { + conferirParidade([ + { cat: "receita_bruta", credito: 100_000, debito: 0 }, + { cat: "custos", credito: 0, debito: 40_000 }, + { cat: "custos", credito: 5_000, debito: 0 }, // devolução de compra + ]); + }); + + it.fails("estorno numa categoria de RECEITA: SQL ignora o débito", () => { + conferirParidade([ + { cat: "receita_bruta", credito: 100_000, debito: 0 }, + { cat: "receita_bruta", credito: 0, debito: 7_000 }, // chargeback + ]); + }); +}); diff --git a/src/lib/dre/cascata.ts b/src/lib/dre/cascata.ts new file mode 100644 index 0000000..78da5c0 --- /dev/null +++ b/src/lib/dre/cascata.ts @@ -0,0 +1,198 @@ +/** + * cascata.ts — a ESCADA da DRE, dirigida pelo catálogo. + * + * Fonte única da regra "o que é resultado e o que não é". Espelha + * `calculate_monthly_dre_v3` (db/snapshot/schema.sql): agrupa por `linhaDre`, + * respeita `entraNoResultado`, e a cascata sai das LINHAS — nunca de uma lista + * de `categoria_id` escrita à mão. + * + * ── POR QUE ESTE ARQUIVO EXISTE ────────────────────────────────────────────── + * A cascata estava escrita à mão em três lugares (motor multi-mensal, análise + * forense e o render do pivot), cada um enumerando ids: + * + * resultadoOperacional = lucroBruto + despVendas + despAdm + * + * Quando a migration `20260713000000_catalogo_pessoas_categoria` criou a + * categoria `pessoas` na **mesma linha 50** de `despesas_administrativas`, o + * motor SQL absorveu sozinho (ele agrupa por `linha_dre`), mas os três motores + * TS não — a folha de pagamento saiu do resultado sem ninguém perceber. A + * migration declarava "ZERO-SHIFT"; era verdade só para quem lia o catálogo. + * Medido na Vertímetal: R$ 465.406,74/mês fora do resultado. + * + * Regra desta casa: **categoria nova no catálogo entra na cascata sozinha.** + * Se você precisou editar este arquivo para adicionar uma categoria, algo está + * errado — o que muda é o catálogo. + * + * ── CONVENÇÃO DE SINAL ─────────────────────────────────────────────────────── + * Aqui os valores chegam **líquidos e com sinal** (crédito − débito), que é a + * convenção dos motores TS: receita chega positiva, despesa chega negativa. + * A escada portanto SOMA tudo. + * + * O SQL v3 usa a convenção oposta (magnitude positiva + subtração). As duas dão + * o mesmo resultado enquanto não houver estorno dentro da categoria — quando há, + * elas divergem, porque o SQL descarta o lado contrário (`sinal '-' → SUM(débito)` + * ignora créditos). Essa divergência é conhecida, está coberta por teste + * (`cascata.test.ts`, bloco "divergência conhecida") e a decisão de unificar é + * uma trilha própria — não mexa nela por aqui. + */ + +import { CATEGORIAS, type Categoria } from "@/lib/categorization-rules"; + +/** Linhas da cascata (fundação Parte III §5). */ +export const LINHA = { + RECEITA_BRUTA: 10, + DEDUCOES: 20, + CUSTOS: 30, + DESPESAS_VENDAS: 40, + /** Op-ex de estrutura — compartilhada por `pessoas` e `despesas_administrativas`. */ + DESPESAS_OPERACIONAIS: 50, + RECEITA_FINANCEIRA: 60, + DESPESAS_FINANCEIRAS: 65, + TRIBUTOS_LUCRO: 70, + MOVIMENTACAO_PATRIMONIAL: 90, + TRANSFERENCIAS: 95, + NAO_CLASSIFICAVEL: 99, +} as const; + +/** Valor consolidado por linha da cascata, na convenção líquida (com sinal). */ +export type LinhasDre = Record; + +export interface EscadaDre { + receitaLiquida: number; + lucroBruto: number; + resultadoOperacional: number; + resultadoFinanceiro: number; + lair: number; + resultadoLiquido: number; + margemBruta: number; + margemLiquida: number; +} + +/** Todas as linhas conhecidas, zeradas. */ +export function linhasZeradas(): LinhasDre { + const out: LinhasDre = {}; + for (const l of Object.values(LINHA)) out[l] = 0; + return out; +} + +/** + * Agrupa valores por categoria nas linhas da cascata. + * + * `valorPorCategoria` traz o líquido de cada categoria (crédito − débito). + * Categoria fora do catálogo cai em **L99** — espelha a blindagem do SQL + * (`COALESCE(c.linha_dre, 99)`), que mantém o dinheiro rastreável abaixo da + * linha em vez de sumir com ele. L99 não entra no resultado. + */ +export function agruparPorLinha( + valorPorCategoria: Record | Map, + catalogo: Categoria[] = CATEGORIAS, +): LinhasDre { + const porId = new Map(catalogo.map((c) => [c.id, c])); + const linhas = linhasZeradas(); + const entradas = valorPorCategoria instanceof Map + ? valorPorCategoria.entries() + : Object.entries(valorPorCategoria); + + for (const [catId, valor] of entradas) { + if (!valor) continue; + const linha = porId.get(catId)?.linhaDre ?? LINHA.NAO_CLASSIFICAVEL; + linhas[linha] = (linhas[linha] ?? 0) + valor; + } + return linhas; +} + +/** + * A escada, a partir das linhas. Soma porque despesa já chega negativa. + * + * Espelha `calculate_monthly_dre_v3` (schema.sql:625-662), inclusive a guarda + * das margens: só calcula com Receita Líquida > 0 — senão devolve 0 em vez de + * um percentual sem sentido. + */ +export function escadaDeLinhas(linhas: LinhasDre): EscadaDre { + const v = (l: number) => linhas[l] ?? 0; + + const receitaLiquida = v(LINHA.RECEITA_BRUTA) + v(LINHA.DEDUCOES); + const lucroBruto = receitaLiquida + v(LINHA.CUSTOS); + const resultadoOperacional = lucroBruto + v(LINHA.DESPESAS_VENDAS) + v(LINHA.DESPESAS_OPERACIONAIS); + const resultadoFinanceiro = v(LINHA.RECEITA_FINANCEIRA) + v(LINHA.DESPESAS_FINANCEIRAS); + const lair = resultadoOperacional + resultadoFinanceiro; + const resultadoLiquido = lair + v(LINHA.TRIBUTOS_LUCRO); + + return { + receitaLiquida, + lucroBruto, + resultadoOperacional, + resultadoFinanceiro, + lair, + resultadoLiquido, + margemBruta: receitaLiquida > 0 ? (lucroBruto / receitaLiquida) * 100 : 0, + margemLiquida: receitaLiquida > 0 ? (resultadoLiquido / receitaLiquida) * 100 : 0, + }; +} + +/** Atalho: do valor por categoria direto pra escada. */ +export function calcularEscada( + valorPorCategoria: Record | Map, + catalogo: Categoria[] = CATEGORIAS, +): EscadaDre { + return escadaDeLinhas(agruparPorLinha(valorPorCategoria, catalogo)); +} + +// ============================================================ +// ORDEM DE LEITURA (consumida pelo render) +// ============================================================ + +/** Onde a escada mostra um subtotal, depois de fechar aquela linha. */ +export interface SubtotalSpec { + chave: keyof Pick; + rotulo: string; + margem?: { chave: keyof Pick; rotulo: string }; + /** Linha-punchline (Resultado Líquido) — a UI destaca. */ + heroi?: boolean; +} + +/** + * Subtotal emitido DEPOIS de desenhar todas as categorias daquela linha. + * Mudar a cascata é mexer aqui — não no componente. + */ +export const SUBTOTAL_APOS_LINHA: Record = { + [LINHA.DEDUCOES]: { chave: "receitaLiquida", rotulo: "Receita Líquida" }, + [LINHA.CUSTOS]: { + chave: "lucroBruto", + rotulo: "Lucro Bruto", + margem: { chave: "margemBruta", rotulo: "Margem bruta" }, + }, + [LINHA.DESPESAS_OPERACIONAIS]: { chave: "resultadoOperacional", rotulo: "Resultado Operacional (EBITDA)" }, + [LINHA.TRIBUTOS_LUCRO]: { + chave: "resultadoLiquido", + rotulo: "Resultado Líquido", + margem: { chave: "margemLiquida", rotulo: "Margem líquida" }, + heroi: true, + }, +}; + +/** + * Categorias que entram no resultado, na ORDEM DE LEITURA da cascata + * (linha, depois `ordem` do catálogo). É esta ordem que faz a tabela fechar de + * cima pra baixo. + */ +export function categoriasDaCascata(catalogo: Categoria[] = CATEGORIAS): Categoria[] { + return catalogo + .filter((c) => c.entraNoResultado) + .slice() + .sort((a, b) => a.linhaDre - b.linhaDre || a.ordem - b.ordem); +} + +/** Categorias abaixo da linha (L90/L95/L99) — informativo, fora do resultado. */ +export function categoriasAbaixoDaLinha(catalogo: Categoria[] = CATEGORIAS): Categoria[] { + return catalogo + .filter((c) => !c.entraNoResultado) + .slice() + .sort((a, b) => a.linhaDre - b.linhaDre || a.ordem - b.ordem); +} + +/** Sinal de exibição da categoria na cascata (`+`/`−`), ou null abaixo da linha. */ +export function sinalDeExibicao(cat: Categoria): "+" | "−" | null { + if (!cat.entraNoResultado) return null; + return cat.sinal === "+" ? "+" : "−"; +} diff --git a/src/lib/extrato-processor.ts b/src/lib/extrato-processor.ts index 9121bd6..4d30a25 100644 --- a/src/lib/extrato-processor.ts +++ b/src/lib/extrato-processor.ts @@ -343,84 +343,56 @@ export function calcularDRE(transacoes: TransacaoCategoriazada[]): DREResult { catData.liquido = catData.credito - catData.debito; } - // Calcular totais por tipo de categoria (nova nomenclatura da fundação) - let receita_bruta = 0; // L10 - let receita_financeira = 0; // L60 - let aporte_socios = 0; - let retirada_socios = 0; - let custos = 0; // L30 (CMV/CPV/CSP) - let despesas_administrativas = 0; // L50 (era despesas_fixas) - let despesas_vendas = 0; // L40 (era despesas_variaveis) - let despesas_financeiras = 0; // L65 - let deducoes_receita = 0; // L20 - let tributos_lucro = 0; // L70 (IRPJ + CSLL) - let movimentacao_patrimonial_liq = 0; // L90 (informativo, abaixo da linha) - - const linhas: DRELinha[] = []; - - // Processar cada categoria. Novos IDs canônicos (Onda 1 da refundação). - for (const [catId, data] of porCategoria) { - if (catId === "receita_bruta") { - receita_bruta = data.credito; - linhas.push({ codigo: "L10", nome: "Receita Bruta", valor: data.credito }); - } - else if (catId === "receita_financeira") { - receita_financeira = data.credito; - linhas.push({ codigo: "L60", nome: "Receita Financeira", valor: data.credito }); - } - else if (catId === "movimentacao_patrimonial") { - // Aportes são créditos, retiradas são débitos; mantemos como - // informativo (linha 90 abaixo da linha, destino BP — fundação §3). - aporte_socios = data.credito; - retirada_socios = data.debito; - movimentacao_patrimonial_liq = data.liquido; - linhas.push({ codigo: "L90", nome: "Empréstimos, Sócios e Bens", valor: data.liquido }); - } - else if (catId === "custos") { - custos = data.debito; - linhas.push({ codigo: "L30", nome: "Custos (CMV/CPV/CSP)", valor: -data.debito }); - } - else if (catId === "despesas_administrativas") { - despesas_administrativas = data.debito; - linhas.push({ codigo: "L50", nome: "Despesas Administrativas", valor: -data.debito }); - } - else if (catId === "despesas_vendas") { - despesas_vendas = data.debito; - linhas.push({ codigo: "L40", nome: "Despesas com Vendas", valor: -data.debito }); - } - else if (catId === "despesas_financeiras") { - despesas_financeiras = data.debito; - linhas.push({ codigo: "L65", nome: "Despesas Financeiras", valor: -data.debito }); - } - else if (catId === "tributos_lucro") { - tributos_lucro = data.debito; - linhas.push({ codigo: "L70", nome: "IRPJ e CSLL", valor: -data.debito }); - } - else if (catId === "deducoes_receita") { - // Estorno → crédito (positivo); cobrado → débito (negativo). - deducoes_receita = data.debito - data.credito; - linhas.push({ codigo: "L20", nome: "Deduções da Receita", valor: data.credito - data.debito }); - } - else if (catId === "transferencia_interna") { - linhas.push({ codigo: "L95", nome: "Transferências Internas", valor: 0 }); - } - else if (catId === "nao_classificavel") { - // A-04.4: NEUTRA — fora do resultado, mas linha visível (fin-04 E1). - linhas.push({ codigo: "L99", nome: "Não Classificável", valor: 0 }); - } - } - - // Cascata (fundação Parte III §5). - const receita_liquida = receita_bruta - deducoes_receita; - const lucro_bruto = receita_liquida - custos; - const resultado_operacional = lucro_bruto - despesas_vendas - despesas_administrativas; - const resultado_financeiro = receita_financeira - despesas_financeiras; - const lair = resultado_operacional + resultado_financeiro; - const resultado_liquido = lair - tributos_lucro; + // Sócios: aporte/retirada seguem informativos (L90, abaixo da linha). + const movPat = porCategoria.get("movimentacao_patrimonial"); + const aporte_socios = movPat?.credito ?? 0; + const retirada_socios = movPat?.debito ?? 0; + const movimentacao_patrimonial_liq = movPat?.liquido ?? 0; + + // Linhas de exibição, uma por CATEGORIA presente, na ordem de leitura da + // cascata. Sai do catálogo — antes era um if/else por id, e por isso + // `pessoas` (nascida na L50 pela migration 20260713000000) não tinha linha + // nenhuma aqui. Transferências e Não Classificável são neutras por design. + const NEUTRAS_ZERADAS = new Set(["transferencia_interna", "nao_classificavel"]); + const ordemLeitura = [...categoriasDaCascata(), ...categoriasAbaixoDaLinha()]; + const linhas: DRELinha[] = ordemLeitura + .filter((cat) => porCategoria.has(cat.id)) + .map((cat) => ({ + codigo: `L${cat.linhaDre}`, + nome: cat.nome, + valor: NEUTRAS_ZERADAS.has(cat.id) ? 0 : (porCategoria.get(cat.id)?.liquido ?? 0), + })); + + // Cascata (fundação Parte III §5) — dirigida pelo CATÁLOGO, não por lista de + // ids. `despesas_administrativas` aqui carrega a L50 INTEIRA (Administrativas + // + Pessoas), mesma semântica que `montarKpisDeV3` dá ao campo homônimo a + // partir da v3. O rename do campo é trilha própria. Ver src/lib/dre/cascata.ts. + const linhasCascata = agruparPorLinha( + Object.fromEntries([...porCategoria].map(([catId, d]) => [catId, d.liquido])), + ); + const escada = escadaDeLinhas(linhasCascata); + + // Magnitudes positivas (convenção da RPC e do payload `linhas_dre`). + const mag = (linha: number) => -(linhasCascata[linha] ?? 0); + const deducoes_receita = mag(LINHA.DEDUCOES); // L20 + const custos = mag(LINHA.CUSTOS); // L30 + const despesas_vendas = mag(LINHA.DESPESAS_VENDAS); // L40 + const despesas_administrativas = mag(LINHA.DESPESAS_OPERACIONAIS); // L50 = Adm + Pessoas + const despesas_financeiras = mag(LINHA.DESPESAS_FINANCEIRAS); // L65 + const tributos_lucro = mag(LINHA.TRIBUTOS_LUCRO); // L70 + const receita_bruta = linhasCascata[LINHA.RECEITA_BRUTA] ?? 0; // L10 + const receita_financeira = linhasCascata[LINHA.RECEITA_FINANCEIRA] ?? 0; // L60 + + const receita_liquida = escada.receitaLiquida; + const lucro_bruto = escada.lucroBruto; + const resultado_operacional = escada.resultadoOperacional; + const resultado_financeiro = escada.resultadoFinanceiro; + const lair = escada.lair; + const resultado_liquido = escada.resultadoLiquido; // Margens (denominador: Receita Líquida — não Receita Bruta). - const margem_bruta = receita_liquida > 0 ? (lucro_bruto / receita_liquida) * 100 : 0; - const margem_liquida = receita_liquida > 0 ? (resultado_liquido / receita_liquida) * 100 : 0; + const margem_bruta = escada.margemBruta; + const margem_liquida = escada.margemLiquida; // Cascata em formato JSONB indexado por linha (espelha RPC v2). const linhas_dre: Record = { @@ -500,6 +472,14 @@ export function gerarTemplateXLSX(): Blob { // ============================================================ import { CATEGORIAS, SUBCATEGORIAS } from "@/lib/categorization-rules"; +import { + LINHA, + agruparPorLinha, + calcularEscada, + categoriasAbaixoDaLinha, + categoriasDaCascata, + escadaDeLinhas, +} from "@/lib/dre/cascata"; /** * Chave de período. Formato depende da granularidade: @@ -806,15 +786,6 @@ export function calcularDREMultiMensal( // 5. Calcular escada de resultados const getCategoriaTotais = (id: string) => totaisPorCategoria[id]?.porPeriodo || inicializarPeriodos(periodos); - // Cascata data-driven a partir dos IDs canônicos (Onda 1 da refundação). - const receitaBruta = getCategoriaTotais("receita_bruta"); - const deducoes = getCategoriaTotais("deducoes_receita"); - const custosTotais = getCategoriaTotais("custos"); - const despVendas = getCategoriaTotais("despesas_vendas"); - const despAdm = getCategoriaTotais("despesas_administrativas"); - const despFin = getCategoriaTotais("despesas_financeiras"); - const recFin = getCategoriaTotais("receita_financeira"); - const tributosLucro = getCategoriaTotais("tributos_lucro"); // movimentacao_patrimonial (destino BP) NÃO entra no resultado — fica // disponível em totaisPorCategoria como informativo de caixa/balanço. const transfInt = getCategoriaTotais("transferencia_interna"); @@ -827,28 +798,24 @@ export function calcularDREMultiMensal( const margemBruta = inicializarPeriodos(periodos); const margemLiquida = inicializarPeriodos(periodos); + // A cascata sai do CATÁLOGO (`linhaDre`/`entraNoResultado`), não de uma lista + // de ids escrita aqui. Antes esta escada enumerava `despesas_vendas` e + // `despesas_administrativas` na mão — quando `pessoas` nasceu na mesma linha + // 50 (migration 20260713000000), a folha saiu do resultado em silêncio. + // Ver src/lib/dre/cascata.ts. for (const periodo of periodos) { - // Receita Líquida = Receita Bruta + Deduções (deduções vêm negativas: crédito − débito). - receitaLiquida[periodo] = receitaBruta[periodo] + deducoes[periodo]; - - // Lucro Bruto = Receita Líquida + Custos (custos vêm negativos: crédito − débito). - lucroBruto[periodo] = receitaLiquida[periodo] + custosTotais[periodo]; - - // Resultado Operacional = LB + Vendas + Adm (despesas vêm negativas). - resultadoOperacional[periodo] = lucroBruto[periodo] + despVendas[periodo] + despAdm[periodo]; - - // Resultado Líquido = RO + Receita Financeira + Despesas Financeiras + IRPJ/CSLL. - // SEM movimentacao_patrimonial — destino BP, fora do resultado (fundação §3). - resultadoLiquido[periodo] = resultadoOperacional[periodo] - + recFin[periodo] - + despFin[periodo] - + tributosLucro[periodo]; - - // Margens (evitar divisão por zero) - if (receitaLiquida[periodo] !== 0) { - margemBruta[periodo] = (lucroBruto[periodo] / receitaLiquida[periodo]) * 100; - margemLiquida[periodo] = (resultadoLiquido[periodo] / receitaLiquida[periodo]) * 100; + const valorPorCategoria: Record = {}; + for (const [catId, tot] of Object.entries(totaisPorCategoria)) { + valorPorCategoria[catId] = tot.porPeriodo[periodo] ?? 0; } + const escada = calcularEscada(valorPorCategoria); + + receitaLiquida[periodo] = escada.receitaLiquida; + lucroBruto[periodo] = escada.lucroBruto; + resultadoOperacional[periodo] = escada.resultadoOperacional; + resultadoLiquido[periodo] = escada.resultadoLiquido; + margemBruta[periodo] = escada.margemBruta; + margemLiquida[periodo] = escada.margemLiquida; } // 6. Conferência de transferências diff --git a/src/lib/plano-voo-v2-service.ts b/src/lib/plano-voo-v2-service.ts index 715af91..4457ce5 100644 --- a/src/lib/plano-voo-v2-service.ts +++ b/src/lib/plano-voo-v2-service.ts @@ -69,13 +69,13 @@ export async function generatePlanoVooV2(projectId: string): Promise