diff --git a/db/ESTRUTURA.md b/db/ESTRUTURA.md index 7ec05bf..e30c6d2 100644 --- a/db/ESTRUTURA.md +++ b/db/ESTRUTURA.md @@ -3,7 +3,7 @@ > ⚠️ **ARQUIVO GERADO. Não edite à mão.** > `npx tsx scripts/db/gerar-estrutura.ts` -Fonte: `db/snapshot/schema.sql` (dump de **2026-08-12**, até a migration **20260812010000**) +Fonte: `db/snapshot/schema.sql` (dump de **2026-08-17**, até a migration **20260817000000**) cruzado com o código vivo em `src/`. A verdade do banco é **a baseline `00000000000000_baseline.sql` + as migrations @@ -14,13 +14,13 @@ posteriores a ela**. Este documento é a leitura humana disso; a leitura de máq | | | |---|---| -| Tabelas | **112** | +| Tabelas | **113** | | Views | **12** | | Funções | **73** | | Triggers | **93** | -| Policies RLS | **360** | -| Tabelas com RLS | **112** de 112 | -| Tabelas referenciadas no código | **88** de 112 | +| Policies RLS | **362** | +| Tabelas com RLS | **113** de 113 | +| Tabelas referenciadas no código | **89** de 113 | ## Sinais que pedem ação @@ -106,7 +106,7 @@ _Identidade, acesso, projeto/cliente e infraestrutura transversal._ | [`client_project_access_deprecated`](#client_project_access_deprecated) | 5 | id | ✅ | 2 | 0 | 2 | **0** | | [`clients`](#clients) | 42 | id | ✅ | 4 | 2 | 4 | 31 | | [`communication_log`](#communication_log) | 14 | id | ✅ | 4 | 0 | 3 | **0** | -| [`companies`](#companies) | 25 | id | ✅ | 6 | 1 | 3 | 23 | +| [`companies`](#companies) | 25 | id | ✅ | 6 | 1 | 3 | 24 | | [`feature_flag_overrides`](#feature_flag_overrides) | 8 | id | ✅ | 2 | 1 | 1 | 2 | | [`file_uploads`](#file_uploads) | 18 | id | ✅ | 4 | 1 | 2 | **0** | | [`import_logs`](#import_logs) | 10 | id | ✅ | 3 | 0 | 2 | 1 | @@ -115,7 +115,7 @@ _Identidade, acesso, projeto/cliente e infraestrutura transversal._ | [`profiles`](#profiles) | 13 | id | ✅ | 5 | 1 | 2 | 77 | | [`project_members`](#project_members) | 6 | id | ✅ | 2 | 0 | 5 | 33 | | [`project_notifications`](#project_notifications) | 12 | id | ✅ | 4 | 0 | 3 | 4 | -| [`projects`](#projects) | 38 | id | ✅ | 9 | 1 | 9 | 109 | +| [`projects`](#projects) | 38 | id | ✅ | 9 | 1 | 9 | 111 | | [`settings`](#settings) | 7 | id | ✅ | 5 | 1 | 2 | 6 | ### Financeiro · ingestão e dado bruto @@ -124,8 +124,8 @@ _O que entra: extrato OFX, documentos, contas bancárias, saldos._ | Tabela | Cols | PK | RLS | Policies | Triggers | Índices | Usos em `src/` | |---|--:|---|:-:|--:|--:|--:|--:| -| [`bank_account_balance_anchors`](#bank_account_balance_anchors) | 8 | id | ✅ | 3 | 2 | 2 | 6 | -| [`bank_accounts`](#bank_accounts) | 14 | id | ✅ | 6 | 2 | 0 | 21 | +| [`bank_account_balance_anchors`](#bank_account_balance_anchors) | 8 | id | ✅ | 3 | 2 | 2 | 7 | +| [`bank_accounts`](#bank_accounts) | 14 | id | ✅ | 6 | 2 | 0 | 22 | | [`bank_balances`](#bank_balances) | 8 | id | ✅ | 3 | 1 | 1 | 3 | | [`client_documents`](#client_documents) | 24 | id | ✅ | 4 | 1 | 5 | 6 | | [`extrato_items`](#extrato_items) | 13 | id | ✅ | 2 | 0 | 3 | 1 | @@ -133,7 +133,7 @@ _O que entra: extrato OFX, documentos, contas bancárias, saldos._ | [`folha_uploads`](#folha_uploads) | 15 | id | ✅ | 4 | 0 | 1 | 6 | | [`ingestion_documents`](#ingestion_documents) | 14 | id | ✅ | 4 | 1 | 2 | 2 | | [`pending_ofx_distributions`](#pending_ofx_distributions) | 17 | id | ✅ | 6 | 1 | 2 | 11 | -| [`transactions`](#transactions) | 32 | id | ✅ | 9 | 5 | 15 | 76 | +| [`transactions`](#transactions) | 32 | id | ✅ | 9 | 5 | 15 | 77 | ### Financeiro · categorização @@ -161,10 +161,11 @@ _O fechamento do mês: snapshots imutáveis e seus detalhamentos._ | [`cockpit_monthly_deltas`](#cockpit_monthly_deltas) | 8 | id | ✅ | 1 | 0 | 0 | 3 | | [`dre_detalhamento`](#dre_detalhamento) | 10 | id | ✅ | 4 | 1 | 1 | 16 | | [`dre_detalhamento_antecipacao_linha`](#dre_detalhamento_antecipacao_linha) | 11 | id | ✅ | 4 | 1 | 2 | 6 | -| [`dre_detalhamento_contrato`](#dre_detalhamento_contrato) | 14 | id | ✅ | 4 | 0 | 2 | 2 | -| [`dre_monthly_snapshots`](#dre_monthly_snapshots) | 16 | id | ✅ | 2 | 2 | 6 | 57 | +| [`dre_detalhamento_contrato`](#dre_detalhamento_contrato) | 14 | id | ✅ | 4 | 0 | 2 | 3 | +| [`dre_monthly_snapshots`](#dre_monthly_snapshots) | 16 | id | ✅ | 2 | 2 | 6 | 60 | | [`dre_reports`](#dre_reports) | 27 | id | ✅ | 4 | 1 | 3 | **0** | | [`financial_snapshots`](#financial_snapshots) | 13 | id | ✅ | 4 | 0 | 3 | 3 | +| [`giro_monthly_snapshots`](#giro_monthly_snapshots) | 48 | id | ✅ | 2 | 0 | 3 | 4 | ### Financeiro · análises derivadas @@ -195,7 +196,7 @@ _Dívida com história, contratos de empréstimo, a pagar e a receber._ | [`endividamento_contratos`](#endividamento_contratos) | 11 | id | ✅ | 4 | 1 | 1 | **0** | | [`endividamento_snapshots`](#endividamento_snapshots) | 33 | id | ✅ | 4 | 0 | 1 | 7 | | [`loan_contract_pmt_overrides`](#loan_contract_pmt_overrides) | 10 | id | ✅ | 4 | 0 | 2 | 5 | -| [`loan_contracts`](#loan_contracts) | 28 | id | ✅ | 4 | 1 | 2 | 16 | +| [`loan_contracts`](#loan_contracts) | 28 | id | ✅ | 4 | 1 | 2 | 17 | | [`recebivel_parcelas`](#recebivel_parcelas) | 16 | id | ✅ | 4 | 1 | 2 | 18 | ### Caixa e projeção @@ -425,7 +426,7 @@ _Tabelas do produto Lite — namespace próprio, não se mistura com o principal > Empresas (CNPJs) pertencentes a um grupo econômico (clients). Todo cliente é grupo: 1 CNPJ → 1 company; N CNPJs → N companies. -**Domínio:** Núcleo da plataforma · **RLS:** ✅ 6 policies · **Usos em `src/`:** 23 +**Domínio:** Núcleo da plataforma · **RLS:** ✅ 6 policies · **Usos em `src/`:** 24 | Coluna | Tipo | Nulo | Padrão | |---|---|:-:|---| @@ -632,7 +633,7 @@ _Tabelas do produto Lite — namespace próprio, não se mistura com o principal ### projects -**Domínio:** Núcleo da plataforma · **RLS:** ✅ 9 policies · **Usos em `src/`:** 109 +**Domínio:** Núcleo da plataforma · **RLS:** ✅ 9 policies · **Usos em `src/`:** 111 | Coluna | Tipo | Nulo | Padrão | |---|---|:-:|---| @@ -701,7 +702,7 @@ _Tabelas do produto Lite — namespace próprio, não se mistura com o principal > fin-01/A-01.6: âncora de saldo () por conta bancária. Fonte de verdade auditável do modelo híbrido âncora+roll-forward. Append-only (imutável); UNIQUE(bank_account_id,as_of,balance_type) torna re-upload idempotente. Cache O(1) derivado fica em bank_accounts.saldo_atual/saldo_data + bank_balances, escritos pelo RPC registrar_ancora_saldo (FASE 1.4). -**Domínio:** Financeiro · ingestão e dado bruto · **RLS:** ✅ 3 policies · **Usos em `src/`:** 6 +**Domínio:** Financeiro · ingestão e dado bruto · **RLS:** ✅ 3 policies · **Usos em `src/`:** 7 | Coluna | Tipo | Nulo | Padrão | |---|---|:-:|---| @@ -720,7 +721,7 @@ _Tabelas do produto Lite — namespace próprio, não se mistura com o principal ### bank_accounts -**Domínio:** Financeiro · ingestão e dado bruto · **RLS:** ✅ 6 policies · **Usos em `src/`:** 21 +**Domínio:** Financeiro · ingestão e dado bruto · **RLS:** ✅ 6 policies · **Usos em `src/`:** 22 | Coluna | Tipo | Nulo | Padrão | |---|---|:-:|---| @@ -943,7 +944,7 @@ _Tabelas do produto Lite — namespace próprio, não se mistura com o principal > Todas as transações acumuladas de todos os extratos -**Domínio:** Financeiro · ingestão e dado bruto · **RLS:** ✅ 9 policies · **Usos em `src/`:** 76 +**Domínio:** Financeiro · ingestão e dado bruto · **RLS:** ✅ 9 policies · **Usos em `src/`:** 77 | Coluna | Tipo | Nulo | Padrão | |---|---|:-:|---| @@ -1302,7 +1303,7 @@ _Tabelas do produto Lite — namespace próprio, não se mistura com o principal > Snapshot mensal da decomposição por contrato (modo por_contrato, PR 5.2). 1 linha por (project_id, mes_referencia, contract_id). Gravado por detalhamento/multi-contrato/route.ts via delete-then-insert. Re-shape em 20260616 (antes era filha de dre_detalhamento via detalhamento_id). -**Domínio:** Financeiro · apuração e DRE · **RLS:** ✅ 4 policies · **Usos em `src/`:** 2 +**Domínio:** Financeiro · apuração e DRE · **RLS:** ✅ 4 policies · **Usos em `src/`:** 3 | Coluna | Tipo | Nulo | Padrão | |---|---|:-:|---| @@ -1327,7 +1328,7 @@ _Tabelas do produto Lite — namespace próprio, não se mistura com o principal > Snapshots mensais imutáveis de DRE/KPIs (ATR OS v3). Cada atualização cria nova version mantendo histórico completo. Base de cross-check HTR vs. dados reais (Camada 4). NÃO confundir com financial_snapshots (módulo de metas / War Room). -**Domínio:** Financeiro · apuração e DRE · **RLS:** ✅ 2 policies · **Usos em `src/`:** 57 +**Domínio:** Financeiro · apuração e DRE · **RLS:** ✅ 2 policies · **Usos em `src/`:** 60 | Coluna | Tipo | Nulo | Padrão | |---|---|:-:|---| @@ -1412,6 +1413,65 @@ _Tabelas do produto Lite — namespace próprio, não se mistura com o principal **Aponta para:** `project_id` → `projects.id` (ON DELETE CASCADE) +### giro_monthly_snapshots + +> Foto mensal imutável do capital de giro (spec capital-de-giro-spec.md §8). Grão projeto+mês+version. Substitui cash_cycle_analyses, que fazia upsert por project_id e destruía o histórico. Fechou o mês, congela; re-fechamento cria version+1. Escrita só por service_role (via after() do fechar-mes). + +**Domínio:** Financeiro · apuração e DRE · **RLS:** ✅ 2 policies · **Usos em `src/`:** 4 + +| Coluna | Tipo | Nulo | Padrão | +|---|---|:-:|---| +| `id` 🔑 | uuid | não | `"gen_random_uuid"()` | +| `project_id` | uuid | não | — | +| `mes_referencia` | text | não | — | +| `version` | integer | não | `1` | +| `frozen_at` | timestamp with time zone | não | `"now"()` | +| `created_by` | uuid | sim | — | +| `nivel_efetivo` | smallint | não | `0` | +| `saldo_inicio_mes` | numeric | sim | — | +| `vale_valor` | numeric | sim | — | +| `vale_dia` | smallint | sim | — | +| `profundidade` | numeric | sim | — | +| `receita_mes` | numeric | sim | — | +| `razao_buraco` | numeric | sim | — | +| `razao_ncg` | numeric | sim | — | +| `custo_giro_desagio` | numeric | sim | — | +| `custo_giro_juros` | numeric | sim | — | +| `cobertura_antecipacao` | numeric | sim | — | +| `cobertura_aporte` | numeric | sim | — | +| `cobertura_emprestimo` | numeric | sim | — | +| `perfil_calendario` | jsonb | não | `'[]'::"jsonb"` | +| `cr_valor` | numeric | sim | — | +| `cr_fonte` | text | sim | — | +| `cp_valor` | numeric | sim | — | +| `cp_fonte` | text | sim | — | +| `estoque_valor` | numeric | sim | — | +| `estoque_fonte` | text | sim | — | +| `informado_em` | timestamp with time zone | sim | — | +| `informado_por` | uuid | sim | — | +| `ncg` | numeric | sim | — | +| `pmr` | numeric | sim | — | +| `pme` | numeric | sim | — | +| `pmp` | numeric | sim | — | +| `ciclo_financeiro` | numeric | sim | — | +| `cod_diario` | numeric | sim | — | +| `compras_mes` | numeric | sim | — | +| `delta_buraco` | numeric | sim | — | +| `buraco_efeito_volume` | numeric | sim | — | +| `buraco_efeito_eficiencia` | numeric | sim | — | +| `delta_ncg` | numeric | sim | — | +| `ncg_efeito_volume` | numeric | sim | — | +| `ncg_efeito_eficiencia` | numeric | sim | — | +| `cobertura_bancaria_pct` | numeric | sim | — | +| `pct_categorizado` | numeric | sim | — | +| `avisos` | jsonb | não | `'[]'::"jsonb"` | +| `invalidated_at` | timestamp with time zone | sim | — | +| `invalidated_reason` | text | sim | — | +| `desatualizado_em` | timestamp with time zone | sim | — | +| `desatualizado_motivo` | jsonb | sim | — | + +**Aponta para:** `project_id` → `projects.id` (ON DELETE CASCADE) + ### breakeven_analyses > Output da Análise #5 (Ponto de Equilíbrio). Calculada por BreakevenService. @@ -1882,7 +1942,7 @@ _Tabelas do produto Lite — namespace próprio, não se mistura com o principal > Contratos de empréstimo/antecipação recorrente do projeto. Persistentes entre meses (não duplicam). Suportam multi-contrato (PR 5 — decomposição por taxa real de cada banco). Fundação §4. -**Domínio:** Endividamento e crédito · **RLS:** ✅ 4 policies · **Usos em `src/`:** 16 +**Domínio:** Endividamento e crédito · **RLS:** ✅ 4 policies · **Usos em `src/`:** 17 | Coluna | Tipo | Nulo | Padrão | |---|---|:-:|---| diff --git a/db/snapshot/SNAPSHOT.md b/db/snapshot/SNAPSHOT.md index 7103133..56a7dbc 100644 --- a/db/snapshot/SNAPSHOT.md +++ b/db/snapshot/SNAPSHOT.md @@ -4,12 +4,12 @@ | Campo | Valor | |---|---| -| **Status** | 🟢 **AUTORITATIVO** — inclui as duas migrations de RLS de 2026-08-12, aplicadas no SQL Editor e conferidas neste dump. | -| Dump gerado em | **2026-08-12** (`supabase db dump --linked`, `--role-only` e `--data-only`, projeto `tdlxqqgechxhkygdmsxq`, CLI v2.84.0) | -| Commit git no dump | `fix/rls-tabelas-orfas` | -| `ultima_migration_incluida` | **`20260812010000`** | -| `ultima_migration_no_repo` | **`20260812010000`** | -| **Veredito** | 🟢 **autoritativo** — `schema.sql` **15.030 linhas**; `roles.sql` **13** (diff vazio); `seed.sql` **25.175**. | +| **Status** | 🟢 **AUTORITATIVO** — inclui `giro_monthly_snapshots` (G2 do capital de giro), aplicada no SQL Editor e conferida neste dump. | +| Dump gerado em | **2026-08-17** (`supabase db dump --linked`, `--role-only` e `--data-only`, projeto `tdlxqqgechxhkygdmsxq`, CLI v2.84.0) | +| Commit git no dump | `main` `a21c084` + working tree (a migration `20260817000000` está aplicada no remoto e **ainda não commitada**) | +| `ultima_migration_incluida` | **`20260817000000`** | +| `ultima_migration_no_repo` | **`20260817000000`** | +| **Veredito** | 🟢 **autoritativo** — `schema.sql` **15.206 linhas**; `roles.sql` **13** (diff vazio); `seed.sql` **25.095**. | ## A regra, depois da baseline de 2026-08-11 @@ -26,6 +26,80 @@ O mapa legível de tudo isso é [`db/ESTRUTURA.md`](../ESTRUTURA.md), **gerado** `npx tsx scripts/db/gerar-estrutura.ts`. Depois de todo re-dump, rode o gerador: o diff do `.md` mostra exatamente o que mudou no banco. +### Validação do re-dump (2026-08-17, `giro_monthly_snapshots` — G2) + +Migration `20260817000000_giro_monthly_snapshots.sql`, aplicada no SQL Editor. Cria a +série mensal do capital de giro (spec `capital-de-giro-spec.md` §8) e **estende** a +função de stale-by-event para marcar também o snapshot de giro. + +| Objeto | Antes | Depois | Δ | +|---|--:|--:|--:| +| **Tabelas** | 112 | **113** | +1 | +| Views | 12 | **12** | 0 | +| Funções | 73 | **73** | 0 | +| Triggers | 92 | **92** | 0 | +| **Policies** | 360 | **362** | +2 | +| **Tabelas com RLS** | 112 | **113 de 113** | +1 | +| **Índices** | 254 | **257** | +3 | + +`roles.sql`: diff vazio. `seed.sql` 25.175 → **25.130**, já **com as 34 linhas do +backfill** (re-dumpado depois dele). + +⚠️ **Nota de processo, porque quase virou perda de dado.** A primeira tentativa de +re-dumpar o `seed.sql` saiu **vazia** — o Docker Desktop auto-pausou no meio — e o +arquivo foi copiado por cima do bom antes de conferir o tamanho. A regra do +`db/snapshot/README.md` ("dumpe para temporário") só protege se o temporário for +**verificado antes de instalar**. `seed.sql` é gitignored, então o git não desfaz. +Recuperado do temporário anterior e, na terceira tentativa, o comando passou a instalar +só com `wc -l > 20000`. **Guarda de tamanho antes de qualquer `cp` sobre dump.** + +**Os deltas batem com a intenção, um a um.** `Funções +0` é o número que interessa: +`marcar_snapshot_desatualizado()` foi **substituída**, não duplicada — era exatamente o +risco de criar uma segunda função varrendo `transactions` de novo. + +**Prova de que o que está rodando é o que foi escrito.** A lei de método (anti-padrão 7) +exige provar a fidelidade de `CREATE OR REPLACE` em função `SECURITY DEFINER`. Duas +provas, ambas mecânicas: + +1. `npx tsx scripts/db/provar-fidelidade-trigger.ts` → corpo idêntico à baseline em 81 + linhas, única diferença = o bloco marcado de 31 linhas. *(Reprovou na primeira + execução, por uma linha em branco a mais.)* +2. Corpo da função **extraído deste dump** × corpo da migration → idênticos, 112 linhas. + É a diferença entre "a migration diz" e "o banco faz". + +O diff estrutural do `schema.sql` contra o anterior mostra **apenas** objetos `giro_*`: +nenhuma tabela, view, policy ou índice alheio foi tocado. + +#### Backfill + check ao vivo — e o bug que só o dado real mostrou + +Backfill: **34 meses** em 2 projetos (Vertímetal 31, Di Forni 3). Check ao vivo +recalculou `vale`, `vale_dia` e `profundidade` por um caminho **independente** do +serviço, e conferiu `razao_buraco`, o fecho da decomposição e `receita_mes × DRE.L10`: +**zero divergência**. + +**Mas um zero merecia conferência, e não era zero.** `custo_giro_juros` saiu `0` em +todos os 34 meses. A causa: a spec §5.4 mandava filtrar contrato por `finalidade`, e +**`finalidade` está NULL em 100% dos contratos do banco** (3 de 3) — é pergunta de +intenção que ninguém volta para responder. A Vertímetal tem dois contratos +`modalidade='capital_giro'` pagando **R$ 272.804 de juros em 12 meses**: + +``` +custo do giro, jul/2026 (12m) medido real + deságio (L65) R$ 201.390 R$ 201.390 + juros de giro R$ 0 R$ 272.804 + ───────────────────────────────────────────────────── + total R$ 201.390 R$ 474.194 ← 58% pra MENOS +``` + +Erro na **manchete permanente da tela**, e na direção que ninguém percebe. Corrigido: +`ehContratoDeGiro` passou a aceitar `finalidade` **OU** `modalidade` +(§5.4.1 da spec), com teste de regressão. Os 34 meses foram refeitos com +`--refazer` e o check ao vivo repassou com `juros = 272.804`. + +**Este é o argumento inteiro da Fase 3 do método em um caso:** 87 testes verdes, schema +correto, dump conferido — e o número principal da tela saindo pela metade. Só o dado +real pega. + ### Validação do re-dump (2026-08-12, RLS das tabelas órfãs) Migrations `20260812000000` (RLS nas 5 tabelas + `search_path` em 39 funções) e diff --git a/db/snapshot/schema.sql b/db/snapshot/schema.sql index 87ddffe..15cd593 100644 --- a/db/snapshot/schema.sql +++ b/db/snapshot/schema.sql @@ -2213,7 +2213,7 @@ ALTER FUNCTION "public"."lite_get_company_id"() OWNER TO "postgres"; CREATE OR REPLACE FUNCTION "public"."marcar_snapshot_desatualizado"() RETURNS "trigger" LANGUAGE "plpgsql" SECURITY DEFINER - SET "search_path" TO 'public', 'pg_temp' + SET "search_path" TO 'public' AS $$ DECLARE v_meses jsonb; -- [{project_id, mes, linhas}] @@ -2294,6 +2294,37 @@ BEGIN WHERE s.id = a.id AND s.desatualizado_em IS NULL; + -- ▼▼▼ ÚNICA DIFERENÇA PRETENDIDA vs. a baseline ▼▼▼ + -- O giro sai das MESMAS transações do DRE: se o mês mudou para um, mudou + -- para o outro. Mesma semântica (só marca, nunca apaga nem invalida) e + -- mesma regra do "primeiro aviso manda". + WITH tocados AS ( + SELECT (m->>'project_id')::uuid AS project_id, + m->>'mes' AS mes, + (m->>'linhas')::int AS linhas + FROM jsonb_array_elements(v_meses) m + ), + ativos_giro AS ( + SELECT DISTINCT ON (g.project_id, g.mes_referencia) + g.id, t.linhas + FROM giro_monthly_snapshots g + JOIN tocados t + ON t.project_id = g.project_id AND t.mes = g.mes_referencia + WHERE g.invalidated_at IS NULL + ORDER BY g.project_id, g.mes_referencia, g.version DESC + ) + UPDATE giro_monthly_snapshots g + SET desatualizado_em = now(), + desatualizado_motivo = jsonb_build_object( + 'op', TG_OP, + 'colunas', v_cols, + 'linhas', a.linhas, + 'em', to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') + ) + FROM ativos_giro a + WHERE g.id = a.id + AND g.desatualizado_em IS NULL; + -- ▲▲▲ fim da diferença ▲▲▲ RETURN NULL; -- AFTER STATEMENT ignora o retorno END; $$; @@ -2302,7 +2333,7 @@ $$; ALTER FUNCTION "public"."marcar_snapshot_desatualizado"() OWNER TO "postgres"; -COMMENT ON FUNCTION "public"."marcar_snapshot_desatualizado"() IS 'Stale-by-event do DRE: marca desatualizado_em no snapshot ATIVO dos meses cujas transações mudaram depois do fechamento. STATEMENT-level (custo O(meses), não O(linhas)) porque transactions é tabela quente. Só marca; nunca apaga nem invalida.'; +COMMENT ON FUNCTION "public"."marcar_snapshot_desatualizado"() IS 'Stale-by-event do DRE E DO GIRO: marca desatualizado_em no snapshot ATIVO dos meses cujas transações mudaram depois do fechamento. STATEMENT-level (custo O(meses), não O(linhas)) porque transactions é tabela quente — uma varredura serve as duas tabelas. Só marca; nunca apaga nem invalida.'; @@ -6006,6 +6037,110 @@ COMMENT ON COLUMN "public"."folha_uploads"."validacao_status" IS 'pendente = nã +CREATE TABLE IF NOT EXISTS "public"."giro_monthly_snapshots" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "project_id" "uuid" NOT NULL, + "mes_referencia" "text" NOT NULL, + "version" integer DEFAULT 1 NOT NULL, + "frozen_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "created_by" "uuid", + "nivel_efetivo" smallint DEFAULT 0 NOT NULL, + "saldo_inicio_mes" numeric, + "vale_valor" numeric, + "vale_dia" smallint, + "profundidade" numeric, + "receita_mes" numeric, + "razao_buraco" numeric, + "razao_ncg" numeric, + "custo_giro_desagio" numeric, + "custo_giro_juros" numeric, + "cobertura_antecipacao" numeric, + "cobertura_aporte" numeric, + "cobertura_emprestimo" numeric, + "perfil_calendario" "jsonb" DEFAULT '[]'::"jsonb" NOT NULL, + "cr_valor" numeric, + "cr_fonte" "text", + "cp_valor" numeric, + "cp_fonte" "text", + "estoque_valor" numeric, + "estoque_fonte" "text", + "informado_em" timestamp with time zone, + "informado_por" "uuid", + "ncg" numeric, + "pmr" numeric, + "pme" numeric, + "pmp" numeric, + "ciclo_financeiro" numeric, + "cod_diario" numeric, + "compras_mes" numeric, + "delta_buraco" numeric, + "buraco_efeito_volume" numeric, + "buraco_efeito_eficiencia" numeric, + "delta_ncg" numeric, + "ncg_efeito_volume" numeric, + "ncg_efeito_eficiencia" numeric, + "cobertura_bancaria_pct" numeric, + "pct_categorizado" numeric, + "avisos" "jsonb" DEFAULT '[]'::"jsonb" NOT NULL, + "invalidated_at" timestamp with time zone, + "invalidated_reason" "text", + "desatualizado_em" timestamp with time zone, + "desatualizado_motivo" "jsonb", + CONSTRAINT "giro_monthly_snapshots_cp_fonte_chk" CHECK ((("cp_fonte" IS NULL) OR ("cp_fonte" = ANY (ARRAY['medido'::"text", 'informado'::"text"])))), + CONSTRAINT "giro_monthly_snapshots_cp_par_chk" CHECK ((("cp_valor" IS NULL) = ("cp_fonte" IS NULL))), + CONSTRAINT "giro_monthly_snapshots_cr_fonte_chk" CHECK ((("cr_fonte" IS NULL) OR ("cr_fonte" = ANY (ARRAY['medido'::"text", 'informado'::"text"])))), + CONSTRAINT "giro_monthly_snapshots_cr_par_chk" CHECK ((("cr_valor" IS NULL) = ("cr_fonte" IS NULL))), + CONSTRAINT "giro_monthly_snapshots_estoque_fonte_chk" CHECK ((("estoque_fonte" IS NULL) OR ("estoque_fonte" = ANY (ARRAY['medido'::"text", 'informado'::"text"])))), + CONSTRAINT "giro_monthly_snapshots_estoque_par_chk" CHECK ((("estoque_valor" IS NULL) = ("estoque_fonte" IS NULL))), + CONSTRAINT "giro_monthly_snapshots_mes_format_chk" CHECK (("mes_referencia" ~ '^[0-9]{4}-(0[1-9]|1[0-2])$'::"text")), + CONSTRAINT "giro_monthly_snapshots_nivel_chk" CHECK ((("nivel_efetivo" >= 0) AND ("nivel_efetivo" <= 2))), + CONSTRAINT "giro_monthly_snapshots_vale_dia_chk" CHECK ((("vale_dia" IS NULL) OR (("vale_dia" >= 1) AND ("vale_dia" <= 31)))) +); + + +ALTER TABLE "public"."giro_monthly_snapshots" OWNER TO "postgres"; + + +COMMENT ON TABLE "public"."giro_monthly_snapshots" IS 'Foto mensal imutável do capital de giro (spec capital-de-giro-spec.md §8). Grão projeto+mês+version. Substitui cash_cycle_analyses, que fazia upsert por project_id e destruía o histórico. Fechou o mês, congela; re-fechamento cria version+1. Escrita só por service_role (via after() do fechar-mes).'; + + + +COMMENT ON COLUMN "public"."giro_monthly_snapshots"."nivel_efetivo" IS '0 = só extrato · 1 = + os 3 saldos informados no fechamento · 2 = + pernas medidas (carteira/aging/estoque). É o MAIOR nível com dado no mês, e pode variar ao longo da série — por isso cada perna carrega a própria fonte.'; + + + +COMMENT ON COLUMN "public"."giro_monthly_snapshots"."profundidade" IS 'Quanto o caixa afundou DENTRO do mês contra o saldo de abertura, medido no fluxo operacional puro (sem antecipação/aporte/empréstimo e sem juros/deságio). Nunca negativo: mês que só subiu tem buraco zero. NÃO confundir com ncg (§4.1) — este é oscilação, aquele é estoque de capital travado.'; + + + +COMMENT ON COLUMN "public"."giro_monthly_snapshots"."razao_buraco" IS 'profundidade ÷ receita_mes. Razão CRUA — nunca exibida como tal: a tela mostra sempre × 30, em dias de faturamento (§5.3). Guardar crua evita arredondamento acumulado na série.'; + + + +COMMENT ON COLUMN "public"."giro_monthly_snapshots"."razao_ncg" IS 'ncg ÷ receita_mes. Razão CRUA, mesma regra de exibição. Série SEPARADA de razao_buraco — são grandezas distintas (§4.1) e nunca se plotam na mesma linha.'; + + + +COMMENT ON COLUMN "public"."giro_monthly_snapshots"."perfil_calendario" IS 'jsonb [{dia, entrada_pct, saida_pct}] × 31 — o perfil do mês TÍPICO (média dos meses da janela, cada mês normalizado pelo próprio total). Alimenta o calendário de descasamento (§5.5). Percentuais 0..100.'; + + + +COMMENT ON COLUMN "public"."giro_monthly_snapshots"."ncg" IS 'Capital preso = cr_valor + estoque_valor − cp_valor. NULL quando faltam pernas. É estoque de capital travado o ano inteiro, já financiado — não é o buraco do mês (§4.1).'; + + + +COMMENT ON COLUMN "public"."giro_monthly_snapshots"."cobertura_bancaria_pct" IS '% das contas bancárias do projeto com dado no mês. Abaixo de 100 o vale NÃO é exibido (§16): cliente com 3 bancos que subiu 1 vê um vale falso, e falso PRA MENOS — o erro que não se percebe.'; + + + +COMMENT ON COLUMN "public"."giro_monthly_snapshots"."avisos" IS 'jsonb [] — o que a tela precisa dizer em voz alta sobre este mês (cobertura incompleta, saldo informado envelhecido, divergência informado × medido). Nunca resolve em silêncio (§20).'; + + + +COMMENT ON COLUMN "public"."giro_monthly_snapshots"."desatualizado_em" IS 'Stale-by-event: transações deste mês mudaram DEPOIS do fechamento. O snapshot continua sendo registro válido e CONTINUA SENDO EXIBIDO (era verdade quando congelou) — a UI só acende o aviso. Não filtre leitura por esta coluna; para isso existe invalidated_at.'; + + + CREATE TABLE IF NOT EXISTS "public"."health_scores" ( "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "project_id" "uuid" NOT NULL, @@ -8400,6 +8535,11 @@ ALTER TABLE ONLY "public"."folha_uploads" +ALTER TABLE ONLY "public"."giro_monthly_snapshots" + ADD CONSTRAINT "giro_monthly_snapshots_pkey" PRIMARY KEY ("id"); + + + ALTER TABLE ONLY "public"."health_scores" ADD CONSTRAINT "health_scores_pkey" PRIMARY KEY ("id"); @@ -8755,6 +8895,10 @@ ALTER TABLE ONLY "public"."lite_transactions" +CREATE UNIQUE INDEX "giro_snapshots_projeto_mes_versao_uk" ON "public"."giro_monthly_snapshots" USING "btree" ("project_id", "mes_referencia", "version"); + + + CREATE INDEX "idx_activity_log_created_at" ON "public"."activity_log" USING "btree" ("created_at"); @@ -9219,6 +9363,14 @@ CREATE INDEX "idx_folha_uploads_project_mes" ON "public"."folha_uploads" USING " +CREATE INDEX "idx_giro_snapshots_desatualizados" ON "public"."giro_monthly_snapshots" USING "btree" ("project_id", "mes_referencia") WHERE ("desatualizado_em" IS NOT NULL); + + + +CREATE INDEX "idx_giro_snapshots_serie" ON "public"."giro_monthly_snapshots" USING "btree" ("project_id", "mes_referencia" DESC, "version" DESC) WHERE ("invalidated_at" IS NULL); + + + CREATE INDEX "idx_health_scores_is_demo" ON "public"."health_scores" USING "btree" ("is_demo") WHERE ("is_demo" = true); @@ -10608,6 +10760,11 @@ ALTER TABLE ONLY "public"."folha_uploads" +ALTER TABLE ONLY "public"."giro_monthly_snapshots" + ADD CONSTRAINT "giro_monthly_snapshots_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE CASCADE; + + + ALTER TABLE ONLY "public"."health_scores" ADD CONSTRAINT "health_scores_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE CASCADE; @@ -12667,6 +12824,19 @@ CREATE POLICY "folha_uploads_update" ON "public"."folha_uploads" FOR UPDATE USIN +ALTER TABLE "public"."giro_monthly_snapshots" ENABLE ROW LEVEL SECURITY; + + +CREATE POLICY "giro_monthly_snapshots select by project members" ON "public"."giro_monthly_snapshots" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 + FROM "public"."project_members" "pm" + WHERE (("pm"."project_id" = "giro_monthly_snapshots"."project_id") AND ("pm"."user_id" = "auth"."uid"())))))); + + + +CREATE POLICY "giro_monthly_snapshots write service_role only" ON "public"."giro_monthly_snapshots" USING (("auth"."role"() = 'service_role'::"text")) WITH CHECK (("auth"."role"() = 'service_role'::"text")); + + + ALTER TABLE "public"."health_scores" ENABLE ROW LEVEL SECURITY; @@ -14615,6 +14785,12 @@ GRANT ALL ON TABLE "public"."folha_uploads" TO "service_role"; +GRANT ALL ON TABLE "public"."giro_monthly_snapshots" TO "anon"; +GRANT ALL ON TABLE "public"."giro_monthly_snapshots" TO "authenticated"; +GRANT ALL ON TABLE "public"."giro_monthly_snapshots" TO "service_role"; + + + GRANT ALL ON TABLE "public"."health_scores" TO "anon"; GRANT ALL ON TABLE "public"."health_scores" TO "authenticated"; GRANT ALL ON TABLE "public"."health_scores" TO "service_role"; diff --git a/docs/atros-v3/capital-de-giro-spec.md b/docs/atros-v3/capital-de-giro-spec.md new file mode 100644 index 0000000..8aa21dd --- /dev/null +++ b/docs/atros-v3/capital-de-giro-spec.md @@ -0,0 +1,1566 @@ +# Capital de Giro — especificação canônica (fonte única viva) + +**Criado:** 2026-08-12 · **Revisado:** 2026-08-17 · **Status:** SPEC APROVADA, não implementada. +**Duas correções de rumo em 2026-08-13** (Lucas): (1) o gráfico de barras empilhadas é a +**espinha** da tela — tudo deriva dele (§13.2); (2) o informado × o medido **se confrontam +explicitamente**, nunca se resolvem em silêncio (§20). +**Revisão de uso em 2026-08-17** (Lucas), a partir da pergunta *"o dono abre isso e responde +o quê?"*: (3) a régua deixa de ser razão/centavos e passa a ser **dias de faturamento +parados** (§5.3); (4) a cadeia **crescer → tenho? → falta → de onde tiro** vira **bloco +próprio** (§10), porque estava quebrada em três lugares que não se conversavam; (5) a +fronteira com o Caixa vira **regra executável** (§4.3); (6) **precisão em milhar cheio, +zero centavos** (§13.5). +**Rota alvo:** `/projetos/[id]/financeiro/giro` · **Aposenta:** `/financeiro/ciclo`, +`src/lib/cash-cycle-analyzer.ts` (como fonte), `src/components/ciclo/*` (exceto o +formato do simulador, que é repontado). +**Guarda-chuva:** `financeiro-mapa-completude.md` · **Irmã:** `caixa-projecao-spec.md` +(o Caixa responde *"tenho caixa pra quanto tempo?"*; o Giro responde *"por que meu +caixa é esse?"*) · **Lei de produto:** `norte/constituicao.md` · **Meta/pacing:** +`modelo-meta-pacing.md`. + +> Tudo aqui foi verificado no **código vivo** (Grep/Read) e na baseline +> `supabase/migrations/00000000000000_baseline.sql` em 2026-08-12 — não em afirmação +> de doc. Onde é hipótese ou decisão de produto, está marcado. + +--- + +## 1. Por que esta página existe + +A pergunta do dono é **"o que trava o meu dinheiro?"**. Hoje a tela responde com três +cards vazios e a frase *"Ainda não calculado — abra o Ciclo de caixa para gerar"* +([`PainelCasaTab.tsx:359`](../../src/components/central-dados/PainelCasaTab.tsx)). +É uma tela morta. + +A causa não é preguiça de implementação. A tela foi construída para calcular um +**índice de balanço** (NCG = contas a receber + estoque − fornecedores) a partir de +uma fonte de **fluxo** (o extrato bancário). Extrato registra o que passou pelo +banco; contas a receber, estoque e contas a pagar são exatamente o que **ainda não +passou**. Quando a fonte não tem a resposta, ou se muda a pergunta ou se fabrica — +e o sistema fabricou (§3). + +Esta spec muda a pergunta. A tela passa a responder, com fidelidade total à fonte +obrigatória: + +> **Quanto dinheiro eu preciso ter parado pra atravessar o mês — e quanto me custa +> não ter?** + +E, conforme o cliente alimenta mais, a mesma pergunta ganha nitidez até virar a NCG +de balanço clássica. **A pergunta nunca muda; a precisão sobe.** + +--- + +## 2. Princípios reitores (inegociáveis) + +1. **O extrato é a fonte obrigatória; todo o resto é enriquecimento.** A tela tem que + entregar valor com OFX categorizado + DRE fechada, e mais nada. Cliente que nunca + subir planilha nem usar o gate **continua tendo tela**. +2. **A escada nunca muda a forma da tela, só a nitidez** (§4). Nada de seção vazia + pedindo upload, nada de slider desabilitado com tooltip. Ou a seção existe cheia, + ou não existe. +3. **Medir o buraco antes do socorro.** A necessidade de giro é medida no **fluxo + operacional puro**, excluindo antecipação/aporte/empréstimo. Medir depois da + cobertura é medir o buraco já tapado — falha *pra menos*, a que não se percebe. +4. **Dia é explicação; R$ é manchete.** PMR/PMP/PME são vocabulário derivado. A + manchete é sempre reais. Onde não há saldo, não há dia — e a tela não finge que há. +5. **Insumo manual é decisão ou saldo, nunca índice.** Pedir "PMR em dias" é pedir a + resposta. Pedir "contas a receber em 31/jul" é pedir o insumo. (É por isso que + a tela atual nunca foi preenchida.) +6. **Anti-falsa-precisão (I8).** Nenhum número sem procedência declarada. Constante + hardcoded apresentada como medição está proibida — é o pecado original desta tela. +7. **Série, não foto.** Capital de giro só significa alguma coisa ao longo do tempo. + O grão é **projeto + mês**, imutável, versionado. +8. **A tela é mensal, e o resto do mês ela empurra.** Giro é aberta **uma vez por mês, + logo depois do fechamento** — e entre fechamentos não é visitada, é ela que provoca + (§12). O semanal é Tesouraria/Caixa. Consequência de desenho, não observação solta: + o herói tem que servir a **sétima** visita, não só a primeira (§13.2). O custo do + giro quase não se move mês a mês; o que muda — e o que faz valer abrir (I5) — é a + **variação decomposta** do mês que acabou de fechar. +9. **A régua é em dias, e é uma só.** Nenhum indicador da tela é apresentado como razão, + índice ou centavos por real (§5.3). E existe **um único número em "dias de + faturamento"**; prazos médios clássicos aparecem nomeados pelo que medem, nunca + como se somassem à régua (§5.3.1). + +--- + +## 3. O que se aposenta, e por quê + +### 3.1 O motor legado — `cash-cycle-analyzer.ts` + +| O que faz | Onde | Veredito | +|---|---|---| +| PMR/PMP por **regex na descrição do OFX** mapeando para prazos **constantes** (`STONE`→1d, `PAGTO BOLETO`→30d) | [`:35-95`](../../src/lib/cash-cycle-analyzer.ts) | ⛔ **Fere I8.** Não é medição, é tabela de suposições | +| "Confiança alta" = quantas linhas casaram o regex, não acerto | [`:648-654`](../../src/lib/cash-cycle-analyzer.ts) | ⛔ Selo verde em número inventado | +| Base do NCG = **só a L50** (despesas administrativas) do último mês fechado | [`:656-679`](../../src/lib/cash-cycle-analyzer.ts) | ❌ Ignora CMV e custo variável → NCG subestimada; e refém da sazonalidade de 1 mês | +| Upsert `onConflict: 'project_id'` — **uma linha por projeto, sobrescrita pra sempre** | [`:619`](../../src/lib/cash-cycle-analyzer.ts) | ❌ **O sistema nunca teve histórico de giro.** É a causa raiz de "não dá pra medir com recorrência" | +| `recomputarAnalisesRicas` chama sem PME → grava `pme_valor: 0, pme_source: 'nenhum'` | [`recompute-analises-ricas.ts:31`](../../src/lib/recompute-analises-ricas.ts) | ❌ **Todo upload de OFX apaga o PME que o dono digitou** | + +**Decisão:** `analisarCicloCompleto` e a tabela `cash_cycle_analyses` saem de +circulação. O método puro `detectarPMP` **sobrevive** apenas como fallback rotulado +"estimado" enquanto não houver saldo de fornecedores (§6.4) — nunca como manchete. + +**Leitores a repontar antes de qualquer DROP** (F2 do método — repoint ANTES de +deletar, um leitor por vez): + +| Leitor | Arquivo | +|---|---| +| Painel/giro | [`PainelCasaTab.tsx:327`](../../src/components/central-dados/PainelCasaTab.tsx) | +| Cockpit | [`CockpitPacingView.tsx:1256`](../../src/components/cockpit/CockpitPacingView.tsx) | +| Caixa & Projeção (PMP) | [`caixa-projecao-service.ts:156`](../../src/lib/treasury/caixa-projecao-service.ts) | +| Contexto da IA | [`context-compiler-service.ts:355`](../../src/lib/context-compiler-service.ts) | +| Gaps financeiros | [`financial-gaps-extractor.ts:370`](../../src/lib/financial-gaps-extractor.ts) | +| Designer de testes | [`ai-test-designer-service.ts:71`](../../src/lib/ai-test-designer-service.ts) | + +### 3.2 A tela legada — `/financeiro/ciclo` + +Pede ao dono digitar **PMR, PMP e PME em dias** +([`ciclo/page.tsx:188-223`](../../src/app/projetos/%5Bid%5D/financeiro/ciclo/page.tsx)) +— três índices que nenhum dono conhece de cabeça. Além disso: + +- O `NCGCard` recebe `ncg` calculado dos valores **detectados** e `cicloFinanceiro` + dos valores **manuais** → o card mostra um NCG que não bate com os dias ao lado + ([`:299-305`](../../src/app/projetos/%5Bid%5D/financeiro/ciclo/page.tsx)). +- "Salvar" grava em `cash_flow_params`; o motor grava em `cash_cycle_analyses`. + **Duas tabelas, duas verdades**, e nada lê a primeira pro NCG. +- O `PMEInputCard` importa CSV de estoque, calcula no browser e **não persiste em + lugar nenhum** ([`PMEInputCard.tsx:71-92`](../../src/components/ciclo/PMEInputCard.tsx)) + — segunda porta de ingestão de estoque, paralela a `importarEstoque`, que não + alimenta nada. Fere R8 na cláusula literal ("dado some sem alimentar nada visível"). + +**Decisão:** rota aposentada → `redirect` para `/financeiro/giro`. `cash_flow_params` +some junto (nenhum outro leitor). O **formato** do `CicloSimulator` (sliders → impacto +em R$) é bom e é reaproveitado — repontado para a base nova (§11). + +### 3.3 O que **não** se aposenta (já está certo e é reusado) + +| Peça | Arquivo | Papel na tela nova | +|---|---|---| +| `pmrDaCarteira()` — PMR real, ponderado por valor, aprendendo com o realizado | [`diagnostico-caixa.ts:54-73`](../../src/lib/treasury/diagnostico-caixa.ts) | PMR do Nível 2 | +| `montarCicloNcg` / `classificarDiagnostico` (2×2) | [`diagnostico-caixa.ts`](../../src/lib/treasury/diagnostico-caixa.ts) | Veredito | +| Série diária reconstruída (âncora + lançamentos), pico e vale | [`saldo-diario.ts`](../../src/lib/treasury/saldo-diario.ts) | Base do Nível 0 | +| `estimarCoberturaRecorrente` — antecipação × aporte × empréstimo | [`annual-projection-service.ts:98-130`](../../src/lib/treasury/annual-projection-service.ts) | "Como você banca hoje" | +| Deságio isolado em L65 (`custo_antecipacao`) | [`categorization-rules.ts:318`](../../src/lib/categorization-rules.ts) | Custo do giro | +| Finalidade "capital de giro" dos contratos | [`loan-contracts-finalidades.ts`](../../src/lib/loan-contracts-finalidades.ts) | Custo do giro (juros) | +| `agregarAging` de contas a pagar | [`contas-pagar-aging.ts`](../../src/lib/treasury/contas-pagar-aging.ts) | Fornecedores, Nível 2 | +| `analisarEstoque` Tier 0 (valor, ABC, margem) | [`estoque-analysis.ts`](../../src/lib/estoque/estoque-analysis.ts) | Estoque, Nível 2 | +| `simularSemana` (atrasar saída / entrada extra → Δ R$ e Δ fôlego) | [`treasury-whatif.ts`](../../src/lib/treasury/treasury-whatif.ts) | Simulador do Nível 0 | +| Projeção anual 12m com sazonalidade | [`annual-projection.ts`](../../src/lib/treasury/annual-projection.ts) | Projeção (§10) | +| `after()` no fechar-mes | [`fechar-mes/route.ts:370-379`](../../src/app/api/projetos/%5Bid%5D/dre/fechar-mes/route.ts) | Gancho do snapshot mensal | + +**Diagnóstico de fundo:** não falta dado nem matemática. Falta **fonte única**. Existem +dois motores de ciclo — o bom (`diagnostico-caixa`, rodando dentro do Caixa & Projeção) +e o ruim (`cash-cycle-analyzer`, plugado na tela morta). É o mesmo padrão já resolvido +em `investigacao_motores_dre`. + +--- + +## 4. A escada de três níveis — a regra que amarra tudo + +### 4.1 Duas grandezas, não uma — a correção que o desenho exige + +**Atenção, porque é fácil errar aqui e a tela inteira depende disso:** o *buraco do +mês* e a *NCG de balanço* **não são o mesmo número com precisões diferentes**. São +grandezas distintas, e a tela mente se fingir que uma vira a outra. + +| | **O buraco do mês** (N0) | **O capital preso** (N1+) | +|---|---|---| +| O que é | **Oscilação** — quanto o caixa afunda dentro do mês | **Estoque** — quanto de dinheiro fica travado o ano inteiro | +| Pergunta | "quanto preciso ter pra atravessar o mês?" | "quanto do meu dinheiro está preso na operação?" | +| Fórmula | `saldo início − vale operacional` | `CR + Estoque − Fornecedores` | +| Ordem de grandeza | fração de um mês de operação | pode passar de um mês inteiro de faturamento | +| Já está financiado? | não — é o aperto recorrente | sim, por patrimônio, dívida ou antecipação | + +Uma empresa pode ter R$ 830 mil de capital preso (já bancado por prazo de fornecedor e +antecipação) e mesmo assim afundar só R$ 118 mil dentro do mês. Os dois números são +verdadeiros e medem coisas diferentes. + +### 4.2 O que realmente atravessa os três níveis + +Não é o *nível* do capital de giro. É: + +1. **O custo** — R$/ano de deságio + juros de giro. Idêntico em N0, N1 e N2, porque a + conta chega igual independentemente de quanto o cliente alimentou o sistema. **É a + manchete permanente da tela.** +2. **A direção** — está piorando ou melhorando, pela régua em **dias de faturamento + parados** (§5.3) e pela decomposição volume × eficiência (§9), que tem **a mesma + fórmula nos três níveis**, mudando só o numerador. +3. **A conta do crescimento** — *crescer X% vai pedir quanto, eu tenho, e se não tenho de + onde tiro* (§10). Existe nos três níveis, porque só precisa de **uma grandeza + a + receita projetada**: no N0 ela roda sobre o buraco do mês, no N1+ sobre o capital + preso. É a razão de o N0 não ser só uma porta. + +``` +Nível 0 Seu giro custa R$ 58,7 mil por ano — 52% do seu lucro. [só extrato] + Todo mês seu caixa afunda R$ 118 mil entre o dia 5 e o 18. + Crescer 30% afunda R$ 35 mil a mais — e falta em out/2026. +Nível 1 ...e R$ 820 mil do seu dinheiro estão presos: 29 dias de [+3 números seus] + faturamento. Crescer 30% pede R$ 246 mil a mais, e falta em abr/27. +Nível 2 ...dos quais R$ 604 mil são 2 clientes, um 41 dias atrasado. [+gate] +``` + +Cada nível responde ao **"por quê?"** que o anterior deixou no ar — é assim que o +próximo se vende sozinho, sem cartaz de upload (R8 por desenho, não por banner). + +| | Nível 0 | Nível 1 | Nível 2 | +|---|---|---|---| +| **Insumo** | OFX categorizado + DRE fechada | + 3 saldos digitados no fechamento | + carteira viva, planilha de CP, planilha de estoque | +| **Esforço/mês** | zero | ~30 segundos | o ritual que já existe | +| **Cobertura esperada** | 100% dos clientes | maioria | quem quer precisão | +| **Manchete (constante)** | custo do giro R$/ano | idem | idem | +| **Grandeza de nível** | buraco do mês | **+** capital preso | **+** capital preso | +| **Dias (PMR/PME/PMP)** | ❌ não existem | ✅ derivados dos saldos | ✅ medidos, ponderados por valor | +| **Ação possível** | calendário, volume | + as 3 alavancas | + nome e sobrenome | + +No Nível 1 em diante a tela mostra **as duas** grandezas, lado a lado e nomeadas — +nunca uma substituindo a outra. + +**Precedência por perna, não por nível.** Cada uma das três pernas (receber, estocar, +pagar) escolhe sua fonte independentemente: **medido (N2) > informado (N1) > empírico +(N0)**. Um cliente com gate de recebíveis mas sem planilha de estoque tem CR medido e +estoque informado — e **cada perna carrega o próprio rótulo de procedência na tela**. + +### 4.3 A fronteira com o Caixa — regra executável + +O Giro tem gravidade natural para o fluxo de caixa: o vale do mês (§5.2), o calendário +(§5.5) e a projeção (§10.1) todos falam de dinheiro entrando e saindo. Sem uma regra +dura, esta tela vira uma segunda tela de caixa — e aí passam a existir dois lugares +respondendo *"quando eu aperto?"*, com números que vão divergir. + +| | **Caixa & Projeção** | **Capital de Giro** | +|---|---|---| +| Eixo | **tempo** | **estrutura** | +| Pergunta | "tenho caixa até quando? o que vence?" | "por que meu caixa é esse, e ele aguenta meu plano?" | +| Natureza | agudo, semanal | crônico, mensal | +| Olha para | mês corrente e futuro, com data | meses **fechados** | + +> **A regra, aplicável a qualquer bloco futuro desta tela:** +> **O Giro nunca mostra data futura mais fina que o mês.** +> **O Caixa nunca mostra série de meses fechados.** + +Pela régua, o que o Giro **mantém** de fluxo é só a pincelada estrutural: + +- o **perfil médio do mês** (dia 1..31, §5.5) — não é previsão, é **padrão recorrente**; +- o **mês** em que a necessidade cruza o caixa livre (§10.1) — isso é **conclusão de + giro**, não previsão de caixa. + +E o que **sai**, com link e sem número: saldo em D+7, o que vence na semana, quanto +entra amanhã. O link é para **agir**, nunca para "abra a outra tela para gerar o +número" — isso continuaria ferindo I3. + +--- + +## 5. Nível 0 — só extrato categorizado + DRE + +Nada aqui exige input. Tudo sai de transações categorizadas + snapshots de DRE. + +### 5.1 A série operacional (a fundação) + +Reconstrói a série diária de saldo (`saldo-diario.ts`) **excluindo** toda transação +com `categoria_id = 'movimentacao_patrimonial'` — antecipação, aporte, empréstimo, +amortização, imobilizado, distribuição. + +> **Por quê.** Se o dono tapa o buraco com uma antecipação no dia 12, a série real +> mostra um vale raso: o buraco medido *depois* do socorro. A série operacional mostra +> a necessidade **antes** de qualquer socorro. A diferença entre as duas séries **é** a +> cobertura — que já é decomposta em antecipação × aporte × empréstimo. + +Juros e deságio (despesas financeiras, L65) também ficam **fora** da série operacional: +são custo do socorro, não da operação. Entram separados, em §5.4. + +### 5.2 O buraco do mês — a necessidade de liquidez recorrente + +> **Não confundir com NCG de balanço** (§4.1). Isto mede a *oscilação* dentro do mês, +> não o capital permanentemente travado. A tela nomeia os dois de forma diferente e +> nunca apresenta um como aproximação do outro. + +Para cada mês fechado M: + +``` +saldoInicio(M) = saldo operacional de fechamento do último dia de M−1 +vale(M) = min( saldoOperacionalFim(d) ) para d ∈ M +profundidade(M) = saldoInicio(M) − vale(M) ← quanto o caixa afundou +diaDoVale(M) = dia do mês em que o mínimo ocorreu +``` + +**Buraco do mês (N0) = mediana das profundidades dos últimos 6 meses fechados.** + +- Mediana, não média: um mês com compra de máquina não pode virar a régua. +- Banda min–max dos 6 meses é exibida junto (é a volatilidade real da necessidade). +- **Mínimo de 3 meses fechados.** Abaixo disso a seção não aparece — não existe "vale + recorrente" com 2 pontos. + +### 5.3 A régua — dias de faturamento parados + +``` +razao = grandeza ÷ receita mensal ← só existe no cálculo, nunca na tela +diasParados = razao × 30 ← é isto que o dono lê +``` + +> **Você tem 34 dias de faturamento parados na operação.** + +**A palavra "razão" sai do produto, e centavos por real também.** A régua é sempre +**dias**. Cinco motivos, e o primeiro sozinho já decide: + +1. **É a única unidade normalizada que o dono já fala.** Ele negocia prazo a vida + inteira ("meu cliente paga 30/60, meu fornecedor me dá 28"). Não precisa aprender + nada. "Razão de giro" e "centavos por real de faturamento" são vocabulário de + analista — ferem R1 na cláusula literal. +2. **Conecta na alavanca sem tradução.** *34 dias → receber 5 dias antes → 29 dias → + libera R$ 140 mil.* A régua e a ação passam a ter a mesma unidade (§11). +3. **Não se mexe quando a empresa só cresce** — que era o objetivo inteiro de + normalizar. Cresceu 30% e continuou em 34 dias? Não piorou. +4. **Atravessa a escada.** N0: *"o buraco do mês vale 4 dias de faturamento"*. N1+: + *"o capital preso vale 34 dias"*. Rótulos diferentes, grandezas diferentes (§4.1), + **mesma unidade** — e ordens de grandeza tão distintas que ninguém confunde as duas. +5. **É o mesmo número, ×30.** Zero cálculo novo, zero coluna nova. + +A escala em que a PME vive é o que mata as alternativas: um dono que fatura R$ 600 mil +por mês não segura *"R$ 0,98 travado por real"* — é abstrato demais — e *"R$ 98.400 a +cada R$ 100 mil faturados"* lê como quase 1:1, que é ruído e não sinal. **A única +sobrevivência do "por R$ 100 mil" é dentro da frase de crescimento** (§13.3.1), onde +ele está fazendo trabalho de multiplicação e é bom nisso. + +As duas grandezas (§4.1) continuam sendo **duas séries distintas**, cada uma com seu +rótulo — nunca plotadas na mesma linha como se fossem continuação uma da outra. O que +elas passam a compartilhar é apenas a **unidade** da régua. + +### 5.3.1 Uma régua só — a armadilha dos dois "dias" + +A régua em dias vai conviver, no N1+, com PMR/PME/PMP, que **também** são dias e +**não somam com ela** (denominadores diferentes: receita × CMV × compras). É a mesma +armadilha de "duas definições na mesma tela" que o §9 já matou uma vez, e ela reaparece +aqui vestida de outra roupa. A regra que fecha: + +> **Existe um único número em "dias de faturamento" — o tamanho do financiamento.** +> Ele decompõe por perna **sempre contra a receita**, e fecha exato: +> +> ``` +> dias de receber + dias de estoque − dias de fornecedor = dias parados ✓ +> ``` +> +> **Os prazos médios clássicos só aparecem nomeados pelo que medem**, nunca ao lado da +> régua como se fossem parcelas dela: *"seu estoque dura 47 dias de venda"*, *"você +> paga em 28 dias"*. São **duração operacional**, não tamanho de financiamento. + +Nota: `dias de receber` (CR ÷ receita × 30) e o PMR clássico são **o mesmo número** — +a coincidência é do denominador, não do conceito. As divergências reais são só em +estoque e fornecedor. Isso não autoriza apresentar os três como se fossem a mesma +família. + +### 5.4 O custo do giro — a manchete + +``` +custoGiro(12m) = Σ deságio de antecipação (subcategoria custo_antecipacao → L65) + + Σ juros de contratos DE GIRO (finalidade OU modalidade — ver 5.4.1) +``` + +Ambos já isolados: o deságio pela decomposição sintética da categorização +([`categorization-rules.ts:318`](../../src/lib/categorization-rules.ts)), os juros por +`dre_detalhamento_contrato.juros`. Lê-se dos `dre_monthly_snapshots` dos 12 meses +fechados — exato, não estimado. + +#### 5.4.1 O que torna um contrato "de giro" — corrigido no check ao vivo (2026-08-17) + +**Esta seção mandava filtrar só por `finalidade`, e isso media menos da metade do +custo real.** No banco de produção `finalidade` está **NULL em 100% dos contratos** — +é uma pergunta de intenção, feita depois do fato, que ninguém volta para responder. Na +Vertímetal: + +| | 12 meses até jul/2026 | +|---|--:| +| Deságio (L65) | R$ 201.390 | +| Juros de giro, medidos só por `finalidade` | **R$ 0** | +| Juros de giro reais (2 contratos `modalidade='capital_giro'`) | **R$ 272.804** | +| **Custo do giro: medido × real** | **R$ 201.390 × R$ 474.194** | + +Erro *pra menos* de 58% na **manchete permanente da tela** — a direção que ninguém +percebe, porque um número menor não provoca conferência. + +**A regra passa a ser um OU, não um E:** + +| Sinal | O que é | Vale | +|---|---|---| +| `finalidade ∈ {capital_giro_operacao, cobrir_aperto_caixa}` | por que ele tomou | ✅ | +| `modalidade ∈ {capital_giro, cheque_especial}` | o que o produto é | ✅ | + +Exigir os dois zera na prática; exigir só a finalidade foi o bug. `modalidade` vem +preenchida porque sai do cadastro do contrato. + +**`desconto_duplicatas` e `antecipacao_cartao` ficam de FORA de propósito:** o custo +delas já chega pelo deságio do L65. Contar as duas pontas dobraria o mesmo dinheiro — e +**inflar a manchete é pior que perder um pedaço**, porque o dono confere e para de +confiar no número. + +Travado em `MODALIDADES_DE_GIRO` ([`giro/custo.ts`](../../src/lib/giro/custo.ts)) com +teste de regressão. + +Apresentado sempre em três leituras, porque é o que dá dimensão: +**R$/ano · % do faturamento · % do lucro.** + +### 5.5 O calendário de descasamento + +Para cada dia do mês (1..31), a média dos últimos 6 meses de entrada e saída +operacional, normalizada como % do total do mês. Produz o perfil: + +> *"78% da sua saída acontece antes do dia 15. 70% da entrada, depois do dia 20."* + +Sobreposto: os compromissos fixos conhecidos (folha, impostos, aluguel) pelas +categorias, com o dia típico de cada um. **É a seção mais acionável do Nível 0** — +dela saem ações reais sem nenhum dado externo (mudar data de vencimento, parcelar +imposto, pedir dilação em quem concentra saída no pior dia). + +### 5.6 Cobertura — como você banca o buraco hoje (retrovisor) + +> **Rótulo obrigatório: este bloco é retrovisor.** Ele diz como o dono **já** banca o +> buraco — dinheiro que já entrou, deságio que já foi pago. O prospectivo — *"o que +> falta pro meu plano e de onde eu tiro"* — é outro bloco (§10.4). Os dois usam as +> mesmas três fontes e por isso se confundem com facilidade; a tela nomeia um como +> **"como você banca hoje"** e o outro como **"de onde tirar"**, e eles nunca aparecem +> no mesmo bloco. + +`estimarCoberturaRecorrente` já devolve a média mensal decomposta em **antecipação × +aporte × empréstimo**, separada por `subcategoria_id`/`socio_natureza` +([`annual-projection-service.ts:118-129`](../../src/lib/treasury/annual-projection-service.ts)). +Consumido direto, com a leitura de significado que a spec do Caixa já trava: +antecipação = venda já feita puxada com deságio (esgota o futuro); aporte = falta +faturamento; empréstimo = dívida. + +### 5.7 O que **não** existe no Nível 0 + +**PMR, PME, PMP e ciclo financeiro não aparecem.** Dias médios derivam de saldos; sem +saldo, não há dia. A tela fala em **reais e dias do mês** (dia 5, dia 20), não em +prazos médios. Isso é degradação honesta e, de quebra, é melhor UX — "do dia 5 ao dia +20" um dono entende; "PMR de 34 dias" não. + +--- + +## 6. Nível 1 — três números no fechamento + +### 6.1 As perguntas + +Feitas **no fechamento do mês**, no mesmo fluxo onde o dono já responde o gate. Três +campos, valor único, todos opcionais e independentes: + +| Pergunta (texto na tela) | Campo | Compra | +|---|---|---| +| "Contas a receber em 31/[último dia do mês]" | `cr_informado` | Contas a receber + PMR | +| "Quanto você deve a fornecedores hoje?" | `cp_informado` | Fornecedores + PMP | +| "Quanto você tem de estoque hoje, a preço de compra?" | `estoque_informado` | Estoque + PME | + +São **saldos que o dono sabe de cabeça ou olha em um minuto**, sem precisar de sistema +nenhum. Compare com o que a tela pedia antes — PMR, PMP e PME em dias. Era pedir a +resposta em vez do insumo, e por isso ninguém preencheu. + +Empresa de serviço sem estoque responde só duas. `estoque = 0` é resposta válida e +correta, e deve ser oferecida como um clique ("não trabalho com estoque"), gravada +como zero explícito — diferente de nulo. + +### 6.2 A tradução instantânea — o controle de sanidade + +Ao lado de cada campo, enquanto digita, o sistema **traduz para dias**: + +> `R$ 280.000` → *"equivale a **34 dias** do seu faturamento. Confere?"* + +Não acusa, traduz. O dono vê na hora se errou uma casa decimal. É o jeito honesto de +validar um número que o sistema não pode conferir sozinho, e é o que mantém I8 de pé +num campo manual. + +**Checagem cruzada adicional** (aviso, nunca bloqueio): se `cr_informado` implica um +PMR fora de 3× a faixa histórica observada nos créditos do extrato, ou se o valor +ficou idêntico por 3 meses seguidos enquanto a receita se moveu, a tela levanta a mão +— *"esse número não mudou desde maio. Ainda está certo?"*. É I6 aplicado a dado +manual: o erro não é digitar à mão, é envelhecer esquecido. + +### 6.3 As contas + +O Nível 1 **acrescenta** uma grandeza nova (o capital preso); não substitui o buraco +do mês do §5.2. As duas convivem na tela, nomeadas. + +``` +NCG = CR + Estoque − CP ← capital preso (grandeza NOVA no N1) + +── A RÉGUA (§5.3.1) — tudo contra a receita, fecha exato ────────────── +diasReceber = CR ÷ receita mensal × 30 +diasEstoque = Estoque ÷ receita mensal × 30 +diasPagar = CP ÷ receita mensal × 30 +diasParados = diasReceber + diasEstoque − diasPagar ✓ sem resíduo + +── DURAÇÃO OPERACIONAL — outra família, nomeada pelo que mede ───────── +PMR = CR ÷ receita mensal × 30 (= diasReceber, mesmo número) +PME = Estoque ÷ CMV mensal × 30 (CMV = L30 do snapshot) +compras(M) = CMV(M) + ( Estoque(M) − Estoque(M−1) ) +PMP = CP ÷ compras mensal × 30 +cicloFinanceiro = PMR + PME − PMP +CODdiário = saída operacional do DFC ÷ dias da janela +``` + +**As duas famílias nunca aparecem lado a lado como se somassem** (§5.3.1). A régua é a +manchete; PME/PMP/ciclo financeiro só entram no bloco da perna, com o nome do que +medem (*"seu estoque dura 47 dias de venda"*), ou no drill do N2. + +`compras` merece nota: é a fórmula exata, e ela fica disponível a partir do **segundo** +mês com estoque informado (precisa do Δ). No primeiro mês, `compras ≈ CMV`, rotulado +como aproximação na tela. + +### 6.4 Fallback do PMP + +Sem `cp_informado` e sem planilha de contas a pagar, o PMP fica indisponível. O +`detectarPMP` legado pode preencher **só** com rótulo explícito "estimado por padrão +de pagamento" e **nunca** compõe a manchete de NCG — entra apenas no simulador como +ponto de partida editável. Se essa distinção não puder ser garantida na UI, o PMP +simplesmente não aparece. Preferimos a lacuna à falsa precisão. + +--- + +## 7. Nível 2 — gate e planilhas + +Não muda a manchete. Troca *"contas a receber R$ 280k"* por *"e R$ 190k disso é de +dois clientes, um deles 40 dias atrasado"*. **Vira a ação de genérica em específica.** + +| Perna | Fonte medida | Motor pronto | +|---|---|---| +| Receber | `recebivel_parcelas` status `esperada` (carteira viva, declarada no gate) | `pmrDaCarteira()` — ponderado por valor, aprende com o realizado | +| Pagar | `contas_pagar` em aberto (planilha de apoio na Apuração) | `agregarAging` — vencido / 7d / 30d / total | +| Estocar | `estoque_produtos` do mês mais recente | `analisarEstoque` — valor imobilizado, ABC, margem por SKU | + +Quando a perna é medida, o número informado do Nível 1 **não é sobrescrito nem +apagado** — fica como registro, e a divergência entre informado e medido é mostrada +uma vez ("você informou R$ 280k; a carteira soma R$ 264k"). Divergência recorrente é +sinal de gate incompleto, e vale como aviso. + +**PMP medido** exige uma coluna nova: a planilha de contas a pagar hoje tem +`fornecedor`, `valor`, `vencimento` +([`payable-sheet.ts:22-29`](../../src/lib/treasury/payable-sheet.ts)) — só a data em +que vence. Sem saber **quando a compra aconteceu**, PMP de balanço não é medível. Daí: + +> **Uma coluna opcional nova na planilha que já existe: `emissao`.** +> Com ela: `PMP = média ponderada(vencimento − emissao)`, e o realizado sai de graça +> de `paid_at − emissao` quando a conta baixa. Sem ela, cai no §6.3 (CP ÷ compras). + +É o **único campo novo de ingestão** desta spec inteira. + +--- + +## 8. A série mensal — snapshot de giro + +### 8.1 Por que uma tabela nova + +`cash_cycle_analyses` faz upsert por `project_id`: uma linha por projeto, sobrescrita +para sempre. **O sistema nunca teve histórico de capital de giro** — sempre e apenas a +última foto, com a anterior destruída. É por isso que "medir com recorrência" é +impossível hoje, e é um problema estrutural, não de cálculo. + +### 8.2 `giro_monthly_snapshots` + +Grão **projeto + mês**, imutável, versionado — o mesmo padrão de +`dre_monthly_snapshots` (`version`, `invalidated_at`, `desatualizado_em`, +`desatualizado_motivo`, `frozen_at`, `created_by`). + +``` +project_id, mes_referencia, version, frozen_at, created_by +nivel_efetivo -- 0|1|2, o maior nível com dado no mês + +-- Nível 0 (sempre preenchido) +saldo_inicio_mes, vale_valor, vale_dia, profundidade +receita_mes +razao_buraco -- profundidade ÷ receita_mes +razao_ncg -- ncg ÷ receita_mes (null enquanto faltam pernas) + -- Razões CRUAS. NUNCA renderizadas como tal: a + -- tela mostra sempre × 30, em dias (§5.3). + -- Guardar crua e derivar na leitura evita + -- arredondamento acumulado na série. +custo_giro_desagio, custo_giro_juros +cobertura_antecipacao, cobertura_aporte, cobertura_emprestimo +perfil_calendario -- jsonb: 31 pares {entrada_pct, saida_pct} + +-- Pernas (cada uma com a própria procedência: 'medido'|'informado'|null) +cr_valor, cr_fonte +cp_valor, cp_fonte +estoque_valor, estoque_fonte +informado_em, informado_por + +-- Derivados (null quando a perna que os sustenta falta) +ncg, pmr, pme, pmp, ciclo_financeiro, cod_diario, compras_mes + +-- Decomposição vs. mês anterior — uma por grandeza, pelo mesmo motivo das razões +delta_buraco, buraco_efeito_volume, buraco_efeito_eficiencia +delta_ncg, ncg_efeito_volume, ncg_efeito_eficiencia + +-- Honestidade +cobertura_bancaria_pct, pct_categorizado, avisos -- jsonb +``` + +### 8.2.1 Duas razões, não uma — correção obrigada pelo G2 (2026-08-17) + +A primeira versão desta seção previa **um único `razao_giro`**, e um único trio de +decomposição. Escrevendo a migration ficou claro que isso quebra o §4.1: no mês em que +o cliente começa a informar os três saldos, a mesma coluna passaria a guardar outra +grandeza, e a série misturaria **buraco do mês** com **capital preso** — exatamente a +confusão que o §4.1 existe para impedir, agora persistida no banco e impossível de +desfazer depois. + +São colunas separadas, e a tela nunca as plota na mesma linha. O custo é seis colunas +numéricas a mais numa tabela de foto mensal; o benefício é que a série de um cliente +que sobe de nível continua legível. + +**`efeito_ciclo` virou `*_efeito_eficiencia`** para casar com o nome da fórmula no §9 — +a spec usava os dois nomes para a mesma coisa, e nome duplo em coluna é como nasce o +leitor que lê a errada. + +### 8.3 Quando é tirada + +No **fechamento do mês**, dentro da rota que já existe. A sequência atual +([`fechar-mes/route.ts:363-379`](../../src/app/api/projetos/%5Bid%5D/dre/fechar-mes/route.ts)) +ganha um passo: + +``` +POST /dre/fechar-mes + → grava o snapshot da DRE (imutável, versionado) + → after() onMonthClosed ← motor de Iniciativas (já lê meta + pacing) + → after() reconciliarCarteira ← baixa as parcelas que caíram + → after() snapshotGiro ← [NOVO] a foto do giro do mês +``` + +Ordem importa: `snapshotGiro` roda **depois** de `reconciliarCarteira`, para que a +carteira do mês já esteja baixada quando o CR medido for lido. + +Fechou o mês, a foto é congelada. Nunca mais se recalcula sozinha, nunca é apagada. +**É isso que transforma R2 de "recalcula quando alguém abre a tela" em "recompõe por +evento".** Re-fechamento do mês cria `version + 1`, sem apagar a anterior. + +### 8.4 Invalidação + +Reusa os gatilhos que já existem para o DRE (`p2_snapshot_desatualizado`): transação +recategorizada, catálogo alterado, empresa migrada de conta bancária → marca +`desatualizado_em` e a tela mostra "este mês mudou desde que foi fechado". + +--- + +## 9. Decomposição volume × ciclo + +A NCG sobe por dois motivos com significados opostos, e a tela **tem** que dizer qual +foi: + +- **Cresceu porque vendeu mais** → notícia boa com fatura anexa +- **Cresceu com a mesma venda** → piorou: cliente pagando devagar, fornecedor + apertando, estoque encalhando + +A decomposição sequencial fecha exata, sem resíduo — e é **uma fórmula só, idêntica +nos três níveis**. Muda apenas o numerador `X`: o buraco do mês (N0) ou o capital +preso (N1+). + +``` +razao(M) = X(M) ÷ receita(M) +efeito_volume = razao(M−1) × ( receita(M) − receita(M−1) ) +efeito_eficiencia = ( razao(M) − razao(M−1) ) × receita(M) + soma = ΔX ✓ exato +``` + +Duas escolhas deliberadas: + +- **O termo cruzado cai no efeito eficiência.** Faz a piora parecer maior, nunca menor. + Na dúvida, erra pro lado que o dono precisa ver. +- **Usa só grandezas que a tela já exibe** (o próprio X e a receita). Nada de `COD` ou + `ciclo × COD` na decomposição: esses são estimadores *diferentes* do capital preso e + não fecham contra o `CR + Estoque − CP` exibido. Duas definições de NCG na mesma tela + é como nasce a divergência que a gente passa meses caçando depois. + +**A razão continua sendo a base de cálculo; os dias são o que se exibe** (§5.3). A +tradução é sempre a mesma e vale nos três níveis: `Δdias = Δrazao × 30`. + +Texto na tela: + +> *"Sua necessidade de giro subiu R$ 41 mil este mês. **R$ 29 mil é porque você cresceu +> 18%** — isso era esperado. **R$ 12 mil é porque seu ciclo esticou 4 dias.** Esse +> pedaço não era."* + +**Esta é a frase que vai no herói** (§13.2, princípio 8). O custo do giro quase não se +move de um mês para o outro; a decomposição, sim — e é ela que responde a pergunta com +que o dono chega na tela no dia seguinte ao fechamento: *"mudou o quê, e o problema é +meu?"*. + +--- + +## 10. Crescer custa caixa — você tem? (a cadeia) + +### 10.0 O buraco que este capítulo fecha + +Fizemos o cruzamento das perguntas reais do dono contra a spec (2026-08-17). As três +que ele faz com mais frequência formam **uma cadeia só**: + +``` +crescer X% → vai pedir quanto? → eu tenho? → se não, de onde tiro e a que preço? +``` + +A spec anterior respondia as três — mas **em três lugares que não se conversavam**: um +slider dentro do simulador (a alavanca "Crescer X%"), uma frase solta na ponte com a +meta, e um bloco retrovisor de cobertura (§5.6). O dono nunca via a conta **fechar**. + +**Decisão:** a cadeia vira **um bloco próprio**, com os três degraus na ordem, e a +alavanca "Crescer X%" sai do simulador (§11.1) porque ela nunca foi uma alavanca — +ela é **a pergunta**. + +Vale nos três níveis (§4.2): precisa só de uma grandeza + a receita projetada. + +### 10.1 A projeção, e o confronto que faltava + +`razao (mediana 6m) × receita projetada mês a mês` — a projeção de receita 12m com +sazonalidade já existe (`montarProjecaoAnualParaProjeto`). Projeta-se a grandeza do +nível vigente (§4.1): no N0 o buraco do mês, no N1+ o capital preso. **Nunca as duas +na mesma curva.** + +O que **não existia** e é o degrau 2 da cadeia: o confronto contra o caixa. Ele é +computável hoje, sem motor novo — [`annual-projection.ts:82-130`](../../src/lib/treasury/annual-projection.ts) +já devolve, mês a mês: + +| Campo | Serve para | +|---|---| +| `saldoAcumulado` (12 pontos) | o caixa livre projetado, mês a mês | +| `saldoAcumuladoSemAporte` | o mesmo cenário sem o socorro — a leitura honesta | +| `primeiroAperto` | o mês em que o saldo cruza zero | +| `operacionalMensalMedio` | a geração que banca o crescimento | + +O confronto é ponto a ponto: **em que mês a necessidade projetada passa do caixa livre +projetado**. Isso devolve **valor e mês**, não só valor — e é a única data futura que +esta tela tem direito de mostrar (§4.3), porque é conclusão de giro, não previsão de +caixa. + +### 10.2 Os três degraus + +``` +CRESCER CUSTA CAIXA — VOCÊ TEM? +Sua meta: faturar R$ 1,05 mi/mês em 12 meses (+30%) + +1 · vai pedir + R$ 250 mil de dinheiro parado +2 · você tem R$ 90 mil livres hoje + R$ 9 mil/mês de geração = R$ 198 mil em 12m +3 · falta R$ 52 mil — e falta a partir de out/2026, no mês 7 +``` + +``` +necessidade na meta = razao × receita-alvo(12m) (razão do §5.3, mediana 6m) +Δ = necessidade na meta − necessidade hoje ← degrau 1 +capacidade = caixa livre hoje + geração operacional × 12 ← degrau 2 +gap = Δ − capacidade ← degrau 3 +mesDoGap = 1º mês em que necessidade(m) > saldoAcumulado(m) +``` + +A meta vem do Plano de Voo (`plano_voo_snapshots`, régua em `modelo-meta-pacing.md`). +**O giro não vira a 5ª métrica com meta** — ele entra como a **restrição de caixa da +meta que já existe**. É o Plano de Voo perguntando *"você tem caixa pra crescer o que +prometeu?"*, pergunta que hoje ninguém faz. Fecha R5. + +**Sem meta travada no Plano de Voo**, o bloco não some: usa o crescimento **implícito +no próprio histórico** (a tendência de receita dos 12m), rotulado como tal — *"no seu +ritmo atual você cresce 11% ao ano"*. Bloco vazio pedindo que o dono vá travar meta em +outra tela seria I3. + +### 10.3 O teto de crescimento autofinanciável + +``` +teto (%/mês) ≈ geração operacional mensal ÷ grandeza de giro +``` + +Uma divisão, os dois termos já calculados. Acima do teto, crescer exige dinheiro de +fora — e é exatamente aí que entra o §10.4. + +**Premissa que a tela declara em voz alta:** tudo isso assume **ciclo constante**. Se o +dono cresce dando prazo maior pra ganhar a venda, leva os dois golpes juntos — mais +volume e mais dias. É o que acontece na prática, e a tela avisa. + +### 10.4 De onde tirar — as fontes, ordenadas por preço + +O degrau 3 termina em um número que dói. Sem esta seção, a tela para no diagnóstico — +e R4 não fecha. A saída é uma lista **ordenada pelo que cada real custa**, sempre nessa +ordem, porque a ordem *é* o conselho: + +``` +DE ONDE TIRAR — do mais barato pro mais caro + + De dentro (não custa nada) + receber 5 dias antes ................ libera R$ 140 mil + pagar 7 dias depois ................. libera R$ 95 mil + + De fora (você já paga por isso) + antecipação de cartão ............... 3,1% a.m. → R$ 1.6 mil/mês pelos R$ 52 mil + contrato Banco X (capital de giro) .. 2,1% a.m. → R$ 1.1 mil/mês +``` + +**De dentro** são as alavancas do §11, avaliadas no ponto onde já estão — nenhuma +matemática nova, só o mesmo `Δ caixa liberado` reordenado ao lado das fontes externas. + +**De fora** é preço real, não taxa de tabela. As duas fontes e a procedência de cada: + +| Fonte | De onde sai o preço | Verificado | +|---|---|---| +| Antecipação | deságio do L65 (`custo_antecipacao`) ÷ volume antecipado da janela | [`categorization-rules.ts:318`](../../src/lib/categorization-rules.ts) | +| Contrato de giro | `loan_contracts.taxa_mensal`, filtrado por `modalidade ∈ {capital_giro, cheque_especial, desconto_duplicatas, antecipacao_cartao}` | baseline, tabela `loan_contracts` | + +#### 10.4.1 O que a tela NÃO pode dizer (verificado no schema) + +**`loan_contracts` não tem limite contratado nem limite disponível.** Conferido na +baseline: a tabela tem `saldo_devedor_inicial`, `taxa_mensal`, `valor_credito_tomado`, +`prazo_meses` — **nenhuma coluna de limite**. Existe `modalidade = 'cheque_especial'`, +mas sem o valor da linha. + +Logo: + +> A tela diz **quanto custa** cada fonte. **Nunca diz "você ainda pode tomar R$ X"** — +> isso seria fabricar (I8), e é exatamente o tipo de número que o dono usaria pra tomar +> decisão real. + +Se um dia isso virar insumo, é **um campo no cadastro do contrato** (limite e limite +usado), não uma pergunta na tela do giro — giro muda todo mês, contrato não. + +#### 10.4.2 A regra de ordem + +De dentro **sempre antes** de de fora, mesmo quando de dentro libera menos. O motivo é +de produto, não de matemática: a alavanca interna é a única que **melhora a régua** +(§5.3) — tomar dinheiro de fora banca o mesmo giro ruim por mais tempo, e aparece no +mês seguinte como custo maior sem melhora de dias. A tela diz isso em uma linha: + +> *"Dinheiro de fora tapa o buraco; ele não diminui os 34 dias. Só as duas primeiras +> linhas fazem isso."* + +--- + +## 11. Simulador + +Formato reaproveitado do [`CicloSimulator`](../../src/components/ciclo/Simulator.tsx) +(sliders → impacto em R$), repontado para a base nova. Toda alavanca devolve a +resposta na **mesma moeda**: R$ liberado agora + R$/ano de custo financeiro que deixa +de existir. + +### 11.1 As três alavancas (Nível 1+) + +| Alavanca | Δ caixa liberado | Δ na régua | +|---|---|---| +| Receber N dias antes | `N × (receita mensal ÷ 30)` | `− N dias` | +| Pagar N dias depois | `N × (compras mensal ÷ 30)` | `− N × (compras ÷ receita)` | +| Girar estoque N dias mais rápido | `N × (CMV mensal ÷ 30)` | `− N × (CMV ÷ receita)` | + +**Toda alavanca devolve três coisas na mesma linha: R$ liberado, R$/ano de custo que +deixa de existir, e quantos dias saem da régua.** A terceira coluna é o que amarra o +simulador na manchete — sem ela o dono vê R$ 140 mil liberados e não sabe se isso mexeu +no número que ele acompanha (§5.3, motivo 2). + +**Eram quatro. "Crescer X%" saiu** e virou o §10: ela nunca foi uma alavanca — ela é +**a pergunta**, e como slider ficava escondida atrás de um `+30%` que ninguém arrasta. +O valor que ela trazia (a meta tem preço em caixa) fica maior no bloco próprio, onde a +conta **fecha** contra o caixa que ele tem. + +``` +custo financeiro evitado (a.a.) = Δ caixa liberado × taxa efetiva +taxa efetiva = custo do giro (12m) ÷ necessidade média de giro do período +``` + +A taxa é a que ele **efetivamente paga**, derivada, não uma taxa de tabela — e é +rotulada como tal. É a mesma taxa que precifica as fontes de dentro no §10.4. + +### 11.2 O simulador do Nível 0 — só calendário + +Sem saldos não há as três alavancas de ciclo. Sobra o **calendário**, que é onde o N0 +tem material de sobra: + +- *"E se eu mudar o vencimento dos impostos do dia 20 pro dia 30?"* +- *"E se eu mudar o vencimento dos boletos de fornecedor do dia 8 pro dia 22?"* + +**"E se eu vender X% a mais" saiu daqui também** (era a alavanca de volume do N0). Pelo +mesmo motivo do §11.1: crescimento não é alavanca, é a pergunta — e no N0 ela roda no +bloco 4 sobre o buraco do mês (§4.2). Manter as duas seria a mesma pergunta respondida +em dois lugares com números diferentes. + +A matemática está pronta e testada em +[`treasury-whatif.ts`](../../src/lib/treasury/treasury-whatif.ts): atrasa saída, injeta +entrada, recalcula a curva, devolve Δ em R$ e em dias de fôlego. + +**Duas alavancas de calendário não somam.** Mexer nas duas redesenha a curva do mês +inteiro, e os alívios se sobrepõem. O fecho do bloco mostra **a queda máxima +recalculada** (*"de R$ 118 mil para R$ 31 mil"*), nunca a soma dos dois deltas — que +daria R$ 20 mil e estaria errado *pra menos*, na direção que o dono não percebe. + +### 11.3 Duas regras travadas + +1. **Não simular o que não foi medido.** Se o estoque nunca entrou, o slider de estoque + **não aparece** — não fica cinza com tooltip. Slider desabilitado é a tela dizendo + "você está incompleto"; slider ausente é a tela funcionando com o que tem. +2. **Alavanca travada não morre no slider.** Quando o dono trava — *"vou negociar +10 + dias com meus 3 maiores fornecedores"* — vira **compromisso com prazo**, e no + fechamento do mês seguinte o sistema mede se aconteceu. A alavanca simulada vira + meta medida: R7 (a decisão dele entra no modelo) → R4/R6 (o HTR cobra e mede). + +--- + +## 12. Sinal proativo → Iniciativa (R6) + +`onMonthClosed` já lê meta + pacing e gera Iniciativas +([`on-month-closed.ts`](../../src/lib/iniciativas/on-month-closed.ts)). Ganha quatro +gatilhos novos, todos sobre o snapshot recém-congelado: + +| Gatilho | Condição | Leitura | +|---|---|---| +| **Ciclo deteriorou** | `*_efeito_eficiencia > 0` e > 15% da grandeza | piora não explicada por crescimento | +| **Dias parados subindo** | `razao_ncg` (ou `razao_buraco` no N0) cresce 3 meses seguidos | deterioração estrutural — narrado em dias, nunca em razão | +| **A meta não cabe no caixa** | `gap > 0` no horizonte de 12m (§10.2) | *"crescer o que você prometeu falta R$ X a partir de out"* | +| **Custo relevante** | custo do giro > 30% do lucro do período | o giro está comendo o resultado | + +O terceiro é o mais valioso e o mais novo: ele nasce do cruzamento meta × giro × caixa +e **chega sem o dono abrir nada** — é R6 fazendo o trabalho que a tela mensal não faz +(princípio 8). A Iniciativa que ele gera já vem com a lista do §10.4 anexada, ordenada +por preço. + +A ação-raiz mora no HTR, não na tela (R4). A tela cumpre a função de **informação**; +o sinal é empurrado por ela sem precisar que o dono a abra. + +--- + +## 13. Anatomia da tela + +### 13.1 As cinco perguntas do dono — e nada além + +O destinatário é um dono de PME, não um analista. Ele abre a tela pra responder +**cinco perguntas, nesta ordem**, e cada bloco existe porque responde uma delas: + +1. **Quanto isso está me custando?** → o motivo de se importar +2. **Por que falta dinheiro no meio do mês?** → o mecanismo +3. **Onde está o meu dinheiro?** → o diagnóstico +4. **Eu aguento crescer?** → a consequência do plano +5. **O que eu faço?** → a saída + +Cinco perguntas ⟹ **cinco blocos + herói + captura**. Qualquer bloco que não responda +uma dessas cinco não entra. A primeira versão desta anatomia tinha nove blocos e +~3.000px — era um painel de analista, não a tela de um dono. + +**A quarta é nova (2026-08-17)** e é a que o dono faz com mais frequência no uso real. +Ela existia diluída em três lugares (§10.0) e por isso a tela terminava em diagnóstico. +Ela também é a única que **fica melhor no Nível 0 do que se imaginava**: roda sobre o +buraco do mês, sem precisar de nenhum saldo digitado. + +### 13.2 A anatomia — uma espinha, e leituras dela + +**Decisão de produto travada pelo Lucas em 2026-08-13:** o gráfico de barras +empilhadas **é a tela**. Ele não é um bloco entre outros — é a espinha, e todo o +resto (composição, variação, meta, simulação) é **uma leitura dele**. + +| # | Bloco | N0 | N1+ | Papel | +|---|---|:--:|:--:|---| +| 0 | **Herói** | ✅ | ✅ | o custo (R$/ano, % do lucro) **+ a variação decomposta do mês** | +| 1 | **Capital de giro, mês a mês** — a espinha | — | ✅ | o gráfico + os 2 números + a variação | +| 2 | **Do que ele é feito** | — | ✅ | as 3 pernas com fonte + quem financia | +| 3 | **Por que o capital de giro existe** (entradas × saídas no mês) | ✅ | ✅ | **no N0 é a espinha**; no N1+ explica a causa | +| 4 | **Crescer custa caixa — você tem?** | ✅ | ✅ | os 3 degraus + de onde tirar (§10) | +| 5 | **O que muda esse número** | ✅ | ✅ | alavancas — no N1+ redesenham a coluna *simulado* | +| 6 | **O que isso te custa** | ✅ | ✅ | a consequência detalhada: deságio × juros | +| 7 | **Os três números** | ✅ | ✅ | captura; some quando as 3 pernas são medidas | + +**Duas mudanças de 2026-08-17:** + +**A meta saiu do bloco 1.** A espinha estava carregando "gráfico + 2 números + variação ++ meta" — quatro coisas, sendo que a última é uma cadeia de três degraus com lista de +fontes anexada (§10). Amontoada ali, virava uma frase que ninguém lia. Ela vira o +**bloco 4**, no lugar onde o dono já entendeu o diagnóstico e está pronto pra pergunta +do plano. + +**O herói ganha a variação.** O custo do giro sozinho serve a primeira visita e vira +papel de parede na sétima (princípio 8). O herói passa a ser duas linhas: + +``` +Seu giro custa R$ 58,7 mil por ano — 52% do seu lucro. +Subiu R$ 41 mil este mês: R$ 29 mil porque você cresceu 18%, R$ 12 mil porque +o ciclo esticou 4 dias. +``` + +**No Nível 0 a espinha não existe** — sem as pernas não há gráfico de capital de giro. +Mas o N0 **deixou de ser só uma porta**: com o bloco 4 rodando sobre o buraco do mês, +ele responde a pergunta do crescimento sem nenhum dado digitado. A tela do N0 passa a +ser: herói → bloco 3 (o mecanismo) → bloco 4 (o plano cabe?) → bloco 5 (calendário) → +bloco 6 (o custo) → bloco 7 (captura). + +**E o bloco 4 no N0 é o melhor argumento de ingestão que a tela tem** (R8 por desenho). +Rodando sobre o buraco do mês, a resposta costuma ser *"cabe"* — e é verdade, para a +grandeza que o extrato enxerga. A tela então diz, na mesma respiração, por que isso não +é o quadro inteiro: + +> **Cuidado com o alívio.** O extrato só enxerga a oscilação dentro do mês. O dinheiro +> que fica preso o ano inteiro — o cliente que ainda não pagou, a mercadoria na +> prateleira — nunca passa pelo banco, então não está nesta conta. Três números no +> fechamento mostram o outro lado, e nesta empresa ele é **7 vezes maior**. + +Não é banner de upload: é o próprio resultado do N0 abrindo o buraco que o N1 fecha. + +### 13.3 A simulação vive DENTRO do gráfico + +As alavancas não produzem um número solto num card à parte: elas **desenham uma coluna +a mais** no fim da série, tracejada e rotulada `simulado`, separada do histórico por um +divisor. O dono vê o cenário no mesmo eixo, na mesma escala, ao lado do que é fato. + +Regra de encoding que sustenta a honestidade: **fato é preenchido, cenário é +contorno.** A coluna simulada nunca é sólida — ela não aconteceu. + +### 13.3.1 A régua é em dias — e onde o "por R$ 100 mil" sobrevive + +Histórico desta seção, porque ela já errou duas vezes e o registro evita a terceira: + +| Versão | Régua | Por que caiu | +|---|---|---| +| 2026-08-12 | centavos por real de faturamento | abstrato demais na escala de uma PME | +| 2026-08-13 (D11) | R$ a cada R$ 100 mil faturados | razão vestida de dinheiro; "R$ 98.400 por R$ 100 mil" lê como quase 1:1 — ruído, não sinal | +| **2026-08-17 (vigente)** | **dias de faturamento parados** | é a unidade que o dono já usa pra negociar (§5.3) | + +Os dois números do fecho da espinha, lado a lado, e nenhum outro com esse peso: + +| Número | O que é | +|---|---| +| **Capital de giro do mês** (R$ 820 mil) | o estado — quanto está imobilizado | +| **Dias de faturamento parados** (29 dias) | a régua — é este que se acompanha | + +**O "por R$ 100 mil" sobrevive em exatamente um lugar:** dentro da frase de crescimento +do bloco 4, onde ele está fazendo trabalho de **multiplicação**, e ali é bom: + +> *"Cada R$ 100 mil a mais de venda pede R$ 98 mil a mais de capital parado."* + +Como régua de acompanhamento, não. Como fator de conversão numa frase de crescimento, +sim. **A distinção é essa, e nenhuma outra ocorrência é permitida na tela.** + +### 13.4 O gráfico da fórmula — a propriedade geométrica + +A NCG é uma subtração. Em vez de **escrever** `CR + Estoque − Fornecedores`, o gráfico +**desenha** a subtração — um par de barras por mês: + +``` + barra da esquerda (empilhada) barra da direita + ┌──────────────────┐ + │ Estoque │ ← menor em cima ┌─────────────────┐ + ├──────────────────┤ │ Contas a pagar │ + │ Contas a receber │ ← maior na base └─────────────────┘ + └──────────────────┘ └─────────────────┘ + └──── a diferença de altura ENTRE as duas = o que falta ────┘ +``` + +**A propriedade que faz esse gráfico funcionar:** com as duas barras na mesma escala e +na mesma linha de base, a distância vertical entre os topos **é literalmente a NCG**. +Não é analogia nem aproximação — é a mesma subtração, em pixels. O dono entende a +fórmula sem que ninguém escreva uma fórmula. + +**Mas a propriedade não se vê sozinha — e o mockup provou isso.** Na v4 o valor do giro +era um rótulo solto acima da pilha, e ali ele lê como *"a pilha da esquerda vale R$ 820 +mil"*, que é **falso** (a pilha vale CR + estoque = R$ 1,27 mi). O rótulo tinha que +marcar a **distância**, não o topo. Daí, obrigatório no mês em destaque e na coluna +simulada: + +> **Um colchete vertical à esquerda do par, ligando o topo da pilha ao topo de contas a +> pagar, e o rótulo `giro R$ 820 mil` acima da coluna.** O colchete desenha a +> subtração; a palavra "giro" mata a ambiguidade. Sem os dois, o número parece ser a +> altura da pilha. + +*(Conferido na geometria do mockup v5: jul/2026, topo da pilha y=55,6 · topo de +fornecedores y=180,9 · distância 125,3px = R$ 820 mil na escala de 1,4 mi ÷ 214px.)* + +**Duas linhas de rótulo sob cada mês, e as duas são obrigatórias:** + +| Linha | Valor | Por quê | +|---|---|---| +| `falta` | a NCG em R$, em milhar cheio | é o número que dói | +| `dias` | a régua — `NCG ÷ receita × 30`, inteiro | **a defesa contra o falso alarme** | + +A segunda linha existe porque a primeira, sozinha, engana: em R$ a barra sobe só +porque a empresa cresceu, e o dono lê "piorei" quando não piorou. Os dias normalizam. +**Absoluto e normalizado no mesmo eixo é o que impede as duas leituras erradas +simétricas** — "sobe logo piorei" e "cresci logo tudo bem". + +A leitura de série que isso habilita é a que o dono repete em voz alta: *"fev 30 dias, +mar 31, abr 32, mai 33, jun 34, jul 34 — estiquei 4 dias em cinco meses."* Com centavos +ou com "R$ por R$ 100 mil", essa frase não existe. + +Isso substitui o gráfico separado da régua no N1+ (no N0 ele permanece como série de +dias, porque lá não existem as três pernas para desenhar a subtração). Dois gráficos +grandes no mesmo bloco seria voltar à poluição que o §13.1 acabou de matar. + +### 13.5 Regras de render + +**Precisão — a régua de arredondamento (2026-08-17).** Metade da sensação de "planilha +de analista" vem daqui. O mockup mostrava `R$ 74.007` e `R$ 98.384` numa tabela: sete +dígitos de precisão num número que, no Nível 1, **foi digitado de cabeça pelo dono**. +É falsa precisão (I8) na forma mais fácil de cometer. + +| Grandeza | Formato | Exemplo | +|---|---|---| +| Valor ≥ R$ 10 mil | **milhar cheio** | `R$ 820 mil` · `R$ 45 mil` | +| Valor entre R$ 1 mil e R$ 10 mil | milhar com uma casa | `R$ 9,4 mil` | +| Valor < R$ 1 mil | unidade | `R$ 945` | +| A régua | **dias inteiros** | `29 dias` | +| Percentual | inteiro, ou uma casa só se < 10% | `52%` · `3,1%` | +| Taxa mensal | uma casa | `2,1% a.m.` | + +O corte é em **R$ 10 mil**, não em R$ 100 mil, e a diferença importa: `R$ 45,1 mil` num +número que é **projeção** é exatamente a casa decimal que faz a tela parecer planilha de +analista. `R$ 45 mil` é o que o dono repete em voz alta. + +- **Centavos não existem nesta tela.** Em nenhum bloco, nenhum tooltip, nenhuma tabela, + nenhuma exportação visível. Nem como unidade (o "por R$ 1,00" morreu, §13.3.1), nem + como precisão. +- **Uma exceção, e só uma: o valor exato que o próprio dono digitou.** Os três campos de + captura (§6.1) e o **eco deles no confronto** (§20.3) mostram o número na unidade — + *"você informou R$ 357.100 e eu acompanho R$ 604.000"*. Arredondar aqui quebraria a + reconciliação, que é a única coisa que o dono pode conferir contra a planilha dele. + Fora desses dois lugares, milhar cheio. +- **O arredondamento é de exibição, nunca de armazenamento.** `giro_monthly_snapshots` + guarda o número cru (§8.2); arredondar na gravação faz a série acumular erro e a + decomposição do §9 parar de fechar exata. +- **Os inteiros exibidos têm que fechar entre si.** Se a tela mostra `34 + 11 − 16` nas + pernas, o total exibido é `29` — não `30` porque o cru deu 29,51. Arredondar cada + parcela e deixar a soma bater é responsabilidade da montagem do DTO, não do + componente. Um dono que soma as três linhas e não chega no total perde a confiança na + tela inteira, e com razão. + +- **Bloco sem dado suficiente não renderiza.** Nunca skeleton permanente, nunca "—", + nunca cartaz de upload no lugar de conteúdo. +- **Todo gráfico é de barras**, com título em linguagem de dono, eixo rotulado, + legenda explícita e valor legível na barra. Sem sparkline, sem área sem eixo, sem + linha que só o autor entende. +- **A fonte do dado é texto legível, não chip minúsculo.** Uma linha abaixo do título + de cada bloco, na língua do dono: *"Calculado do seu extrato bancário, média dos + últimos 6 meses"* · *"R$ 604 mil vêm das vendas que você declarou no fechamento; + o resto você informou em 31/jul"*. +- **Um número grande por bloco.** O resto é apoio. +- O convite de ingestão aparece **dentro** do bloco que ficaria mais nítido com ele + (R8), como linha de rodapé — nunca como bloco vazio. + +--- + +## 14. Modelo de dados — resumo das mudanças + +| Objeto | Ação | +|---|---| +| `giro_monthly_snapshots` | **CRIAR** (§8.2). RLS no padrão `client_project_access` — o cliente lê o próprio projeto | +| `contas_pagar.emissao` | **ADICIONAR** coluna `date NULL` + a coluna opcional na planilha (§7) | +| `cash_cycle_analyses` | **APOSENTAR** após repontar os 6 leitores (§3.1). DROP em sessão separada | +| `cash_flow_params` | **APOSENTAR** junto com `/financeiro/ciclo` — sem outro leitor | +| `estoque_produtos` | intocada — passa a alimentar PME de verdade | +| `recebivel_parcelas` | intocada — passa a alimentar CR e PMR na tela do giro | + +Nenhuma migration destrutiva na mesma sessão do repoint (lei de método, F2). + +--- + +## 15. Régua R1–R9 — como esta spec fecha cada dimensão + +| Dim | Como fecha | +|-----|-----------| +| **R1** | Manchete em R$ e dias; **zero razão, zero centavos, zero índice** (§5.3). Nenhum campo pede índice — só saldo | +| **R2** | Snapshot por evento de fechamento, imutável e versionado; frescor visível; invalidação por gatilho | +| **R3** | Decomposição volume × ciclo; drill de cada perna até nome do cliente/SKU/fornecedor | +| **R4** | A tela **termina em número, data e caminho** (§10.4), não em slider; a ação-raiz vai pro HTR pelo §12 | +| **R5** | A cadeia crescer→tenho→falta→de onde (§10) — o giro como restrição de caixa da meta, com **mês** do estouro | +| **R6** | Quatro gatilhos no `onMonthClosed` (§12), incluindo "a meta não cabe no caixa" — o alerta nasce sozinho | +| **R7** | Os 3 saldos (o que ele sabe) + as alavancas travadas (o que ele decide) entram no modelo | +| **R8** | O convite de ingestão nasce do "por quê?" que o nível anterior deixou aberto, não de banner | +| **R9** | Zero ritual novo. Pega carona no fechamento que já existe; os 3 campos custam ~30s/mês. **Cadência mensal declarada** (princípio 8) — o resto do mês a tela empurra em vez de esperar visita | + +**Invariantes:** a spec **repara** I6 (recompõe por evento, dado manual com carimbo de +mês e provocação de revisão), I8 (nenhuma constante hardcoded vira medição; toda perna +declara procedência) e I3 (nada de "abra a outra tela para gerar"). + +--- + +## 16. Honestidade e degradação (I8) + +| Situação | Comportamento | +|---|---| +| < 3 meses fechados | Bloco 2 não aparece. Tela mostra veredito parcial + calendário | +| Cobertura bancária incompleta | **O vale não é exibido.** Cliente com 3 bancos que subiu 1 vê um vale falso — e falso **pra menos**, que é o erro que não se percebe. A seção mostra o que falta subir, no lugar do número | +| % categorizado < 90% | Números exibidos com selo de confiança rebaixado; o custo do giro (que depende de subcategoria) não é exibido abaixo de 95% | +| Saldo informado envelhecido | "Esse número não muda desde maio. Ainda está certo?" | +| PMP sem `emissao` nem CP | PMP ausente. Nunca a constante do regex como manchete | +| Divergência informado × medido | Mostrada uma vez, como aviso, sem sobrescrever | + +--- + +## 17. Fases de implementação + +Sequência da casa (Fundação → Telas → Cérebro), com o repoint antes de qualquer deleção. + +| Fase | Entrega | Fecha | +|---|---|---| +| **G1 — Fundação N0** | Núcleo puro: série operacional, vale/profundidade, régua em dias, calendário, custo do giro, decomposição. Tudo testado, sem I/O | a matemática | +| **G2 — Snapshot mensal** | Tabela + `snapshotGiro` no `after()` do fechar-mes + backfill dos meses já fechados | R2, a série | +| **G3 — Tela N0** | `GiroView` no Nível 0: herói (custo + variação), bloco 3, **bloco 4**, bloco 6, captura. `/financeiro/giro` deixa de ser seção do Painel e vira view própria | a tela morta morre | +| **G4 — Nível 1** | Os 3 campos no fechamento + tradução instantânea + contas de balanço + as 3 alavancas + a espinha | R7, os dias | +| **G5 — Nível 2** | Pernas medidas (carteira/aging/estoque) + confronto (§20) + coluna `emissao` | precisão | +| **G6 — Cérebro** | 4 gatilhos no `onMonthClosed` + a cadeia do §10 ligada à meta travada do Plano de Voo | R5, R6 | +| **G7 — Aposentadoria** | Repoint dos 6 leitores → `/ciclo` redireciona → DROP de `cash_cycle_analyses` e `cash_flow_params` em sessão separada, com re-dump | a dívida | + +**Grupo (N>1 empresas):** o consolidado soma as pernas por empresa; o intercompany já +cai em L90 e não contamina. Fica **diferido** para depois de G5 — não entra no escopo +desta trilha, e a tela em modo grupo mostra o seletor de empresa até lá. + +--- + +## 18. Bugs que esta trilha fecha + +Todos confirmados no vivo em 2026-08-12: + +1. Recompute apaga o PME digitado ([`recompute-analises-ricas.ts:31`](../../src/lib/recompute-analises-ricas.ts)) +2. Duas tabelas, duas verdades (`cash_flow_params` × `cash_cycle_analyses`) +3. NCG que não bate com os dias ao lado ([`ciclo/page.tsx:299-305`](../../src/app/projetos/%5Bid%5D/financeiro/ciclo/page.tsx)) +4. Base do NCG usa só L50, ignora CMV ([`cash-cycle-analyzer.ts:656-679`](../../src/lib/cash-cycle-analyzer.ts)) +5. `PMEInputCard` importa CSV que não persiste ([`PMEInputCard.tsx:71-92`](../../src/components/ciclo/PMEInputCard.tsx)) +6. PME hardcoded em 0 no motor bom ([`caixa-projecao-service.ts:209`](../../src/lib/treasury/caixa-projecao-service.ts)) +7. Sem histórico de giro — upsert por `project_id` ([`cash-cycle-analyzer.ts:619`](../../src/lib/cash-cycle-analyzer.ts)) + +--- + +## 19. Confiança da carteira — por que o gate nunca é 100% + +O gate **não é um razão de recebíveis e nunca vai ser**. A venda entra na carteira +por um crédito que caiu, não por uma venda que aconteceu. O gerador de cronograma +diz isso na cara ([`recebivel-cronograma.ts:5-10`](../../src/lib/dre-detalhamento/recebivel-cronograma.ts)): + +> *"o lançamento marcado como `receita_a_prazo` é uma RECEITA QUE JÁ CAIU — +> tratamos ele como a parcela 1 (conciliada), o gatilho da venda."* + +### 19.1 Os dois cegos estruturais + +**Cego 1 — venda sem nada recebido é invisível.** Vendeu em julho com boleto único +para setembro? Não existe na carteira. E quando setembro chega, não é mais recebível +— é caixa. Esse tipo de venda **nunca** aparece como recebível em momento nenhum. +Para quem vende com boleto único, a carteira enxerga **zero**. + +**Cego 2 — o PMR sai sistematicamente curto.** A data da venda é aproximada pela +primeira parcela, então o intervalo venda→1º recebimento nunca é medido: + +``` +venda 30/60/90 PMR da carteira = (0+30+60)/3 = 30 dias + PMR real = (30+60+90)/3 = 60 dias + erro = o lag até a 1ª parcela — SEMPRE pra menos +``` + +**Consequência:** a carteira é um **piso**, e piso é a pior direção de erro — mostra +a empresa mais saudável do que é. A tela nunca a chama de "contas a receber"; chama +de *"o que eu já acompanho"*. + +### 19.2 O selo de confiança (calculável hoje) + +Os insumos já existem: `receitaAprazo` e `receitaTotal` saem da mesma janela em +[`caixa-projecao-service.ts:104-118`](../../src/lib/treasury/caixa-projecao-service.ts). + +| Sinal | Leitura ao dono | +|---|---| +| `receita a prazo declarada ÷ receita total` | "acompanho 12% da sua venda; os outros 88% não sei se foram à vista ou não foram declarados" | +| parcelas `esperada` vencidas sem conciliar | carteira virando fantasma | +| meses desde o último fechamento com gate respondido | frescor | + +--- + +## 20. Confronto — o informado × o medido + +### 20.0 O buraco que este capítulo fecha + +A primeira versão desta spec dizia "o digitado é o total, o gate é a parte rastreada" e +**resolvia em silêncio**. Não dá. Quando o dono digita R$ 800 mil e o gate acompanha +R$ 604 mil, há pelo menos três leituras possíveis e **só ele sabe qual é**: + +| Leitura | Total real | +|---|---| +| (a) 800 é o total; 604 está dentro dele | R$ 800 mil | +| (b) 800 é "o resto", ele esqueceu que o gate já conta 604 | R$ 1.404 mil | +| (c) 800 é a verdade da planilha dele; os 604 do gate estão errados/velhos | R$ 800 mil, e o gate precisa de revisão | + +Assumir (a) por padrão é fabricar certeza — I8. **A tela pergunta.** + +### 20.1 A hierarquia: eles não disputam o mesmo cargo + +A regra que dissolve 90% do conflito: + +> **O total é dele. O detalhe é do sistema.** + +| Papel | Quem ganha | Por quê | +|---|---|---| +| **Quanto é, no total** | o número informado | só o dono enxerga o que nunca passou pelo banco | +| **De quem é, com data e vencimento** | a carteira / a planilha | o número digitado não tem nome nem data | +| **Aging, drill-down, PMR ponderado** | a carteira | idem | + +Eles só disputam quando o informado é **menor** que o rastreado — aí para tudo (§20.2, faixa 3). + +### 20.2 As quatro faixas + +`T` = total informado · `G` = rastreado pelo sistema · `B` = banda plausível +(derivada do histórico do próprio campo × variação da receita, e do PMR implícito). + +| Faixa | Condição | O que a tela faz | +|---|---|---| +| **1 · normal** | `T ≥ G` e `T ∈ B` | aceita. Mostra o split: "R$ 604 mil com nome e vencimento · R$ 357 mil sem detalhe" | +| **2 · graduado** | `T ≈ G` (±2%) | "o seu número bate com o que eu acompanho — o gate está pegando toda a sua venda a prazo." A partir do mês seguinte o campo vem **pré-preenchido com G**, só para confirmar | +| **3 · contradição** | `T < G` | **para e pergunta.** O rastreado é um piso: não pode haver menos no total do que em parcelas em aberto | +| **4 · provável subtração** | `T ∉ B`, mas `T + G ∈ B` | **para e pergunta.** É a assinatura de quem respondeu "o resto" em vez do total | + +### 20.3 As duas perguntas de confronto + +**Faixa 4 — provável subtração.** Não acusa; oferece as duas contas prontas: + +> Você informou **R$ 357.100** e eu já acompanho **R$ 604.000** em parcelas declaradas. +> · ( ) O total é **R$ 961.100** — os R$ 357 mil são além do que você acompanha +> · ( ) O total é **R$ 357.100** — e o que você acompanha está errado + +**Faixa 3 — contradição.** As causas são poucas e ele sabe qual é. A pergunta já roteia +para o conserto: + +> Eu acompanho **R$ 604.000** em parcelas em aberto, mas você diz que te devem +> **R$ 400.000**. Uma dessas é verdade: +> · ( ) Tem parcela que já foi paga e não deu baixa → *abre a reconciliação* +> · ( ) Tem cliente que não vai pagar → *marca inadimplente, sai da carteira* +> · ( ) Me confundi; o certo é R$ 604.000 → *aceita G, campo passa a vir pré-preenchido* +> · ( ) Meu número está certo → *aceita T e marca a carteira para revisão* + +**Nada é resolvido em silêncio, e nenhuma das opções é "ignorar".** Cada uma leva a um +estado diferente do banco — e é isso que faz a qualidade do dado subir com o uso. + +### 20.4 A qualidade fica na cara, não no rodapé + +O confronto não pode viver só no momento da digitação — senão em duas semanas ninguém +lembra que metade do número é palpite. **A procedência entra no próprio gráfico:** + +- a parte **rastreada** de cada perna é preenchida sólida; +- a parte **informada sem detalhe** é hachurada (45°, mesma cor, mesma altura). + +O dono não precisa ler nenhum rótulo para ver quanto da sua tela é fato e quanto é +memória — a hachura conta. E ela **encolhe conforme ele usa o gate**, o que transforma +a qualidade do dado em progresso visível em vez de cobrança. + +Complemento textual, no bloco de composição, uma coluna só: + +``` +Contas a receber R$ 961.100 63% rastreado +Estoque R$ 318.000 informado por você +Contas a pagar − R$ 444.800 100% rastreado +``` + +### 20.5 A provocação entre meses (I6) + +O confronto não é só contra o gate — é contra o **próprio passado**. Se o faturamento +subiu 20% e o saldo informado veio idêntico ao do mês anterior, a tela levanta a mão: + +> As contas a receber vieram iguais a junho (R$ 961.100), mas o seu faturamento subiu +> 20% no mês. Confere? + +É o mesmo mecanismo do I6: o erro nunca é digitar à mão, é o dado envelhecer sem +ninguém perceber. + +### 20.6 A regra é por perna, pela natureza da fonte + +| Perna | Fonte medida | Completa por natureza? | Pergunta o total? | +|---|---|---|---| +| Receber | carteira (dirigida por crédito que caiu) | **Não, e nunca será** (§19) | **Sempre** | +| Pagar | planilha de contas a pagar | depende do que ele subiu | Sim, com a soma da planilha como padrão | +| Estocar | planilha de estoque | **Sim** — é uma contagem | **Não.** A pergunta some | + +--- + +## 21. Riscos de uso do cliente (e o que a tela faz com cada um) + +Dois são graves porque erram **sempre na mesma direção** — não se anulam no tempo. + +**G1 — A data-âncora.** Ele fecha julho no dia 15 de agosto e responde "quanto me +devem **hoje**". O saldo é de 15/ago carimbado em julho: **erro sistemático de meio +mês, todo mês.** Contamina PMR e NCG. → A pergunta é sempre *"quanto te deviam em +**31/jul**?"*. Ancora no fechamento, nunca em "hoje". + +**G2 — Título antecipado não é mais recebível.** Se ele antecipou R$ 190 mil, o +cliente ainda deve, mas o dinheiro já entrou e quem recebe é o banco. Incluir = +dupla contagem com o caixa. É o erro mais provável, porque para o dono "o cliente me +deve" continua sendo verdade. → O sistema **já detecta a antecipação**: a pergunta +vem com *"você antecipou R$ 190 mil este mês — não inclua esses títulos, o dinheiro +deles já está no seu caixa."* + +| Risco | O que a tela faz | +|---|---| +| Erro de casa decimal | tradução instantânea pra dias + banda dura: acima de 6× a receita mensal exige confirmação | +| Estoque a preço de venda | sanity check contra o CMV — estoque > 6 meses de CMV é quase certo erro | +| Gate abandonado no mês 2 | parcela vencida há +N dias sem conciliar **sai da manchete** → "vencido sem baixa, R$ X — revisar" | +| Ele para de fechar mês | congela na última foto e **para de projetar** — não extrapola sobre dado velho | +| Cobertura bancária incompleta | não exibe o vale (§16) — o erro seria *pra menos* | + +--- + +## 22. Grupo — grão, ritual e o furo que sobra + +### 22.1 O grão já existe + +`projects.tipo` é `individual` (um CNPJ, com `company_id`) ou `consolidado`. **Cada +empresa tem projeto próprio** — e portanto carteira, contas a pagar, estoque e +snapshots de DRE próprios. Nada precisa ser reparticionado: `project_id` já é o grão +de empresa. + +### 22.2 O ritual não pode multiplicar por N + +Pergunta **por empresa** (recebível pertence a um CNPJ), mas: + +- empresa **sem receita no mês** não é perguntada; +- do 2º mês em diante os campos vêm **pré-preenchidos com a resposta anterior**. + +Um grupo de 4 empresas não vira 12 digitações/mês — vira 3 confirmações. R9 de pé. + +### 22.3 Soma dos buracos × buraco do grupo + +Somar os vales **superestima**: se a A afunda no dia 5 e a B no dia 22, o grupo nunca +tem os dois ao mesmo tempo. Mas dinheiro não anda livre entre CNPJs. Mostrar os dois, +nomeados: + +``` +Soma dos buracos R$ 213 mil se cada empresa se virar sozinha +Buraco do grupo R$ 140 mil se o caixa circular entre as empresas + ────────── +Vale circular R$ 73 mil ← decisão de gestão, não número +``` + +Essa diferença só existe na visão de grupo e é acionável. + +### 22.4 O furo que sobra (sem solução limpa) + +O consolidado elimina intercompany **na consulta**, pela contraparte +([`consolidated-dre-service.ts:14-27`](../../src/lib/consolidated-dre-service.ts)). +Mas **número digitado é escalar opaco**: se dentro dos R$ 962 mil houver R$ 180 mil +da empresa irmã, não há o que eliminar — o consolidado infla dos dois lados. + +**Mitigação:** uma 4ª pergunta **condicional**, disparada só quando o extrato já +mostrou fluxo intercompany operacional entre as duas — *"desses R$ 962 mil, quanto é +da [Empresa B]?"*. Pergunta ganha por evidência; não aparece pra quem não tem +intercompany. + +**Alerta de qualidade:** a conferência de par rodada em 2026-08-06 achou **16 de 17 +lançamentos eliminados sem contrapartida na irmã (R$ 99.900 saindo do L90 contra +nada)**. A qualidade do intercompany já é frágil com dado medido; com dado digitado +será pior. Logo: a NCG consolidada nasce **rotulada**, e se a conferência de par +falhar no mês, a tela mostra a **soma bruta com aviso** em vez de uma eliminação em +que ninguém confia. + +--- + +## 23. Controles da tela + +Uma linha só, acima de tudo, escopando **todos** os blocos — nunca controle dentro de +card, nunca controle por gráfico (se um gráfico precisa do seu próprio recorte, é +outra tela). + +| Controle | Valores | O que muda | +|---|---|---| +| **Mês** | meses **fechados**, mais recente primeiro | o mês de referência: pernas da composição, coluna destacada na espinha, base da simulação, mês da confirmação | +| **Janela** | 6 · 12 meses | a série da espinha e a média do perfil semanal | +| **Empresa** | consolidado + cada CNPJ | o escopo. **Só aparece com N > 1** (§22) | + +Regras: + +- **Mês aberto não entra na lista.** Só mês fechado tem foto (§8). O mês corrente + aparece no Caixa & Projeção, não aqui — esta tela é sobre o que já é fato. +- **Trocar o mês NÃO recalcula nada** — relê o snapshot daquele mês (§24.2). O dono vê + o que era verdade quando o mês fechou, não uma reconstrução retroativa. É o que + permite comparar dois meses honestamente. +- **Janela de 12m é a que captura sazonalidade**; 6m é a que responde "como estou + agora". O default é 6; a tela sugere 12 quando a sazonalidade detectada é forte. +- **Enquanto recarrega, o gráfico segura o render anterior em opacidade reduzida** — + sem skeleton, sem pulo de layout, sem flash. + +--- + +## 24. Bastidores — o que faz o dado aparecer + +### 24.1 O contrato + +A tela consome **um DTO**, montado por um serviço só. Sem componente buscando dado por +conta própria — foi assim que a tela velha acabou com dois motores discordando. + +```ts +interface GiroViewDTO { + escopo: { projectId; empresa: string | null; mesRef: string; janela: 6 | 12 }; + frescor: { fechadoEm; bancosCobertos: [n, total]; pctCategorizado; + desatualizado: { em; motivo } | null }; + + custo: { desagio12m; jurosGiro12m; pctReceita; pctLucro }; // herói, linha 1 + variacao: { total; efeitoVolume; efeitoEficiencia; deltaDias }; // herói, linha 2 + semanal: { semanas: [{ rotulo; entrada; saida }]; quedaMaxima; diaDaQueda; + compromissos: [{ nome; dia; valor }] }; // bloco 3 + pernas: { cr; estoque; cp; fonte: Record; + rastreado: { cr: number }; + dias: { receber; estoque; pagar } } | null; // bloco 2 (N1+) + serie: [{ mes; valor; dias; receita; cr?; estoque?; cp?; + rastreadoCr?; falha? }]; // a espinha + alavancas:[{ id; rotulo; deltaCaixa; economiaAno; deltaDias }]; // bloco 5 + + // ── bloco 4 — a cadeia do §10 ─────────────────────────────────────── + crescimento: { + origem: 'meta_travada' | 'tendencia_historica'; // §10.2 + crescimentoAlvo: number; // % em 12m + receitaAlvo: number; + // degrau 1 + capitalAdicional: number; + // degrau 2 + caixaLivreHoje: number; + geracaoMensal: number; + capacidade12m: number; + curvaConfronto: [{ mes; necessidade; saldoProjetado }]; // annual-projection + // degrau 3 + gap: number; // ≤ 0 ⟹ cabe no caixa + mesDoGap: string | null; // 'YYYY-MM' — a ÚNICA data futura (§4.3) + tetoAutofinanciavel: number; // %/mês (§10.3) + // §10.4 — ordenadas por preço, de dentro sempre antes + fontes: [{ tipo: 'interna' | 'externa'; rotulo; + liberaOuLimita: number; // R$ que a fonte resolve + taxaMensal: number | null; // null ⟹ interna, não custa + custoMensalDoGap: number | null; + procedencia: 'desagio_l65' | 'loan_contract' | 'alavanca' }]; + } | null; +} +``` + +`pernas: null` ⟹ Nível 0 (o bloco 2 não renderiza). Alavanca indisponível **não vem no +array** (§11.3: não se simula o que não foi medido). `crescimento` só é `null` quando +não há nem meta travada nem 12 meses de histórico para tendência. + +**Três campos que não existem e não devem ser inventados** (§10.4.1): limite +contratado, limite disponível e "quanto você ainda pode tomar". `loan_contracts` não +tem essas colunas. Se aparecerem no DTO, alguém fabricou. + +**A régua nunca trafega como razão.** `serie[].dias` e `pernas.dias` já vêm em dias, +derivados de `razao_giro × 30` na montagem do DTO — para que nenhum componente precise +decidir a unidade por conta própria, que é como o "por R$ 1,00" sobreviveu escondido no +§13.4 por quatro dias depois da D11 tê-lo aposentado. + +### 24.2 Quem calcula o quê, e quando + +| Peça | Calculada | Onde vive | +|---|---|---| +| custo, semanal, serie, pernas, variacao | **no fechamento do mês** | `giro_monthly_snapshots` (§8) | +| meta | na leitura | `plano_voo_snapshots` + a razão do snapshot | +| alavancas | na leitura, no browser | não persiste (núcleo puro) | +| frescor | na leitura | cobertura bancária + `desatualizado_em` | + +**A regra que sustenta a série:** o que é histórico é **congelado**; o que é derivado +do presente (meta, simulação) é **vivo**. Misturar os dois é como nasce o número que +muda sozinho e ninguém explica. + +Trocar de mês no seletor = **N leituras de linha, zero recomputo**. Trocar a janela = +a mesma coisa com N diferente. Latência esperada: uma query para a janela (≤ 12 linhas) ++ uma para a meta. + +### 24.3 O que quebra, e o que a tela faz + +| Situação | Comportamento | +|---|---| +| **Mês sem fechamento** no meio da janela | **buraco na série** — coluna ausente com o mês rotulado. Nunca interpolar: inventar um mês é inventar uma tendência | +| **Mês re-fechado** (`version + 1`) | a tela lê a última versão e marca o mês: "refeito em 12/ago" | +| **Mês marcado `desatualizado_em`** | selo no seletor + faixa no topo: "este mês mudou desde que foi fechado". O número **continua exibido** — ele era verdade quando congelou | +| **Snapshot ausente para meses antigos** (antes do backfill) | série começa onde há dado; a tela diz desde quando mede | +| **Cobertura bancária incompleta** | o bloco 1 **não renderiza o número da queda máxima** (§16) — erraria *pra menos* | +| **Falha na leitura** | o bloco degrada sozinho; um bloco quebrado não derruba a tela | + +### 24.4 O backfill + +`giro_monthly_snapshots` nasce vazia. Sem backfill não existe série — e sem série o +a espinha e a decomposição não existem, que é metade do valor da tela. + +O backfill roda uma vez, sobre os meses **já fechados**, reusando o mesmo núcleo puro +do fechamento. Duas honestidades obrigatórias: + +1. **Meses antigos podem render menos** (sem carteira, sem planilha) — cada mês grava a + própria `fonte` por perna, então a série mostra a procedência mudando ao longo do + tempo em vez de fingir uniformidade. +2. **O backfill não reescreve DRE nem carteira.** Ele só lê e deriva. Se o insumo + daquele mês não existe, o mês entra com a perna nula — não com zero. diff --git a/docs/atros-v3/handoff-2026-08-13-capital-de-giro.md b/docs/atros-v3/handoff-2026-08-13-capital-de-giro.md new file mode 100644 index 0000000..561e915 --- /dev/null +++ b/docs/atros-v3/handoff-2026-08-13-capital-de-giro.md @@ -0,0 +1,465 @@ +# Handoff 2026-08-13 — Capital de Giro: da spec ao G2 em produção + +**Estado (2026-08-17):** **G1, G2 e G3 entregues.** Núcleo puro + série mensal no banco +(backfill de 34 meses, check ao vivo feito) + a tela do Nível 0 no ar. Falta o smoke +visual — a rota é autenticada e ninguém abriu ainda. +**Canônico:** [`capital-de-giro-spec.md`](./capital-de-giro-spec.md) — tudo verificado +no código vivo (`arquivo:linha`) e na baseline, não em afirmação de doc. +**Mockup:** `_local/mockups/capital-de-giro-mockup.html` (gitignored) — v5, régua em +dias, bloco da conta do crescimento, 3 níveis alternáveis. Gerado por +`_local/mockups/_gen/`, não editado à mão. + +> **Revisão de uso — 2026-08-17.** O Lucas levantou a pergunta que faltava: *"o dono +> abre isso no dia a dia e responde o quê?"*. Saíram **4 decisões novas (D15–D18)** e +> a spec foi atualizada em 11 seções (§2.1). Na mesma data entraram o **G1** (§4.2) e o +> **G2** (§4.3). + +**G1** (§4.2) · **G2** (§4.3) · **G3** (§4.4). Dois bugs achados pelo dado real, não +pelos testes: o custo do giro saindo pela metade (§4.3) e um descasamento fabricado +(§4.4). Próximo passo: **smoke visual do G3**, depois **G4** — os 3 números no +fechamento. + +--- + +## 1. O diagnóstico que originou tudo + +`/financeiro/giro` é uma tela morta: três cards vazios e *"Ainda não calculado — abra +o Ciclo de caixa para gerar"*. + +**A causa não é preguiça.** A tela foi construída para calcular um índice de **balanço** +(contas a receber + estoque − fornecedores) a partir de uma fonte de **fluxo** (extrato). +Extrato é o que passou pelo banco; essas três coisas são exatamente o que **ainda não +passou**. Sem resposta na fonte, ou se muda a pergunta ou se fabrica — e o sistema +fabricou: PMR/PMP saem de **regex na descrição do OFX mapeando para prazos constantes** +(`STONE`→1d, `PAGTO BOLETO`→30d), com selo de "confiança alta" que mede quantas linhas +casaram o regex, não acerto. + +**7 bugs confirmados no vivo** estão catalogados em §18 da spec. Os dois piores: + +- `cash_cycle_analyses` faz upsert `onConflict: 'project_id'` — **uma linha por projeto, + sobrescrita pra sempre**. O sistema nunca teve histórico de capital de giro. +- `recomputarAnalisesRicas` chama sem PME → **todo upload de OFX apaga o PME que o dono + digitou**. + +--- + +## 2. As decisões travadas (e por que) + +| # | Decisão | Onde | +|---|---|---| +| D1 | A tela muda a pergunta: *"quanto preciso ter parado pra atravessar o mês e quanto me custa não ter?"* | §1 | +| D2 | Escada de 3 níveis; **N0 (só extrato) entrega valor sozinho** | §4 | +| D3 | **Vale intra-mês ≠ NCG de balanço** — grandezas distintas, nunca uma como aproximação da outra | §4.1 | +| D4 | O que atravessa os 3 níveis é o **custo** e a **direção**, não o nível | §4.2 | +| D5 | Medir o buraco **no fluxo operacional puro** (sem antecipação/aporte/empréstimo) — medir depois do socorro erra *pra menos* | §5.1 | +| D6 | Snapshot mensal imutável (`giro_monthly_snapshots`), grão projeto+mês | §8 | +| D7 | Decomposição **volume × eficiência**, fórmula única nos 3 níveis, sem resíduo | §9 | +| D8 | Giro não vira 5ª métrica com meta — entra como **restrição de caixa da meta** que já existe | §10.2 | +| D9 | **A barra empilhada é a espinha da tela**; composição, variação, meta e simulação são leituras dela | §13.2 | +| D10 | A simulação **desenha uma coluna a mais no gráfico**. Fato é preenchido, cenário é contorno | §13.3 | +| D11 | Normalização em **R$ a cada R$ 100 mil faturados**, não em centavos por real | §13.3.1 | +| D12 | A carteira do gate é **piso, nunca razão** — dois cegos estruturais | §19 | +| D13 | **O total é do dono; o detalhe é do sistema.** Confronto explícito em 4 faixas | §20 | +| D14 | Procedência **dentro do gráfico**: rastreado sólido, informado hachurado | §20.4 | + +## 2.1 As decisões da revisão de uso (2026-08-17) + +Origem: o cruzamento das **perguntas reais do dono** contra a spec. Das 10 perguntas +que um dono de PME faz sobre giro, a spec respondia 7 bem, 2 pela metade e **deixava 2 +sem resposta nenhuma** — e as três frouxas formavam uma cadeia só. + +| # | Decisão | Onde | +|---|---|---| +| D15 | A régua deixa de ser razão/centavos/"R$ por R$ 100 mil" e vira **dias de faturamento parados**. "Razão de giro" sai do vocabulário do produto | §5.3 | +| D16 | A cadeia **crescer → vai pedir quanto → eu tenho → de onde tiro** vira **bloco próprio** (bloco 4). "Crescer X%" sai do simulador: nunca foi alavanca, é a pergunta | §10 | +| D17 | A fronteira com o Caixa vira **regra executável**: *o Giro nunca mostra data futura mais fina que o mês; o Caixa nunca mostra série de meses fechados* | §4.3 | +| D18 | **Precisão em milhar cheio, zero centavos** em toda a tela; arredondamento só na exibição, nunca na gravação | §13.5 | + +**O que a revisão achou de concreto:** + +1. **A spec estava se contradizendo em três lugares** sobre a régua: §5.3 dizia + centavos por real, §13.4 dizia `por R$ 1,00`, a D11/§13.3.1 dizia R$ por R$ 100 mil, + e o mockup tinha implementado a terceira. Drift de quatro dias dentro do próprio doc. +2. **"Eu tenho caixa pra crescer?" não existia.** O confronto necessidade × caixa livre + é computável hoje sem motor novo — [`annual-projection.ts:82-130`](../../src/lib/treasury/annual-projection.ts) + já devolve `saldoAcumulado` mês a mês, `primeiroAperto` e `operacionalMensalMedio`. +3. **"De onde tiro?" não existia como saída.** O §5.6 é retrovisor (como ele banca + hoje). O prospectivo é o §10.4 — fontes ordenadas por preço, de dentro sempre antes + de fora, com taxa real (`loan_contracts.taxa_mensal` + deságio do L65). +4. **Limite de crédito não existe no schema.** Conferido na baseline: `loan_contracts` + tem `saldo_devedor_inicial`, `taxa_mensal`, `valor_credito_tomado`, `prazo_meses` — + **nenhuma coluna de limite**. A tela diz *quanto custa*, nunca *quanto você ainda + pode tomar*. Registrado em §10.4.1 pra ninguém "completar" isso depois. +5. **Cadência declarada:** giro é **mensal**, aberto depois do fechamento — o semanal é + Tesouraria. Consequência de desenho: o herói ganha a **variação decomposta**, porque + o custo do giro sozinho serve a 1ª visita e vira papel de parede na 7ª (I5). + +**Armadilha nova que a régua em dias abre, e a trava:** dias de faturamento (contra +receita) vão conviver com PMR/PME/PMP (contra receita/CMV/compras), que **não somam** +com ela. Regra em §5.3.1: **um único número em "dias de faturamento"**, que decompõe +por perna sempre contra a receita e fecha exato; os prazos médios clássicos só aparecem +nomeados pelo que medem (*"seu estoque dura 47 dias de venda"*). + +### Duas correções de rumo minhas, registradas + +1. **Eu tinha juntado vale e NCG como "o mesmo número com precisões diferentes".** + Errado: o vale é oscilação dentro do mês (não financiada); a NCG é estoque de capital + travado o ano inteiro (já financiado). §4.1. +2. **Eu removi a barra empilhada por conflito de cor.** O Lucas reverteu, com razão — o + problema era de execução, não de valor. Ela voltou como espinha (§13.2), e o conflito + se resolveu tirando a barra horizontal redundante. + +--- + +## 3. O que já existe e vai ser reusado (não reescrever) + +| Peça | Arquivo | +|---|---| +| PMR real da carteira, ponderado, aprendendo com o realizado | `treasury/diagnostico-caixa.ts:54-73` | +| Série diária reconstruída (âncora + lançamentos), pico e vale | `treasury/saldo-diario.ts` | +| Cobertura decomposta: antecipação × aporte × empréstimo | `treasury/annual-projection-service.ts:98-130` | +| Deságio isolado em L65 (`custo_antecipacao`) | `categorization-rules.ts:318` | +| Aging de contas a pagar | `treasury/contas-pagar-aging.ts` | +| Estoque Tier 0 (valor, ABC, margem) | `estoque/estoque-analysis.ts` | +| Simulador "e se" sobre a curva | `treasury/treasury-whatif.ts` | +| Gancho `after()` no fechamento | `api/projetos/[id]/dre/fechar-mes/route.ts:370-379` | +| Motor de Iniciativas no fechamento (já lê meta + pacing) | `iniciativas/on-month-closed.ts` | + +**O grão de empresa já existe:** `projects.tipo` é `individual` (com `company_id`) ou +`consolidado`. Carteira, contas a pagar, estoque e snapshots já são por empresa. + +--- + +## 4. O que precisa ser criado + +| Objeto | Ação | +|---|---| +| `giro_monthly_snapshots` | **CRIAR** (§8.2) — RLS padrão `client_project_access` | +| `contas_pagar.emissao` | **ADICIONAR** `date NULL` + coluna opcional na planilha | +| `cash_cycle_analyses` | **APOSENTAR** após repontar os **6 leitores** listados em §3.1 | +| `cash_flow_params` | **APOSENTAR** junto com `/financeiro/ciclo` | + +**É a única coluna nova de ingestão de toda a spec.** Nada vai para o cadastro da +empresa — giro muda todo mês, cadastro não. + +--- + +## 4.1 O mockup v5 (2026-08-17) + +`_local/mockups/capital-de-giro-mockup.html`, regerado. É **gerado por script**, não +editado à mão — `_local/mockups/_gen/` tem o pipeline (`python gen_giro_v5.py && +python build_giro_v5.py`), e é assim que a geometria dos SVGs fecha com os números. + +O que mudou: herói novo (custo + variação decomposta) · régua em **dias** embaixo de +cada mês · **bloco 4** (os 3 degraus + curva de sobra/falta + fontes por preço), com +versão própria para N0 e N1+ · colchete desenhando a subtração na espinha · milhar +cheio em toda parte. + +**Três defeitos que só o render revelou** — e que estão consertados na spec, não só no +mockup: + +1. **O rótulo do giro ancorado no topo da pilha lê como "a pilha vale isso"** — e a + pilha vale CR + estoque, não a NCG. Virou colchete + a palavra "giro" (§13.4). +2. **`R$ 45,1 mil` num número que é projeção** é a casa decimal que faz a tela parecer + planilha. O corte do milhar cheio desceu de R$ 100 mil para **R$ 10 mil** (§13.5). +3. **Os inteiros exibidos não fechavam entre si** (34 + 11 − 16 dava 29, o total cru + dava 30). Virou regra: quem arredonda é a montagem do DTO, e a soma tem que bater + (§13.5). + +Verificado ao vivo com Playwright nos 3 níveis: zero erro de console, sem overflow +horizontal, e os toggles de `` conferidos por `getComputedStyle().display` — nunca +pela propriedade `.hidden`, que **não reflete em SVGElement**. + +## 4.2 G1 — FUNDAÇÃO N0 ENTREGUE (2026-08-17) + +Núcleo puro, **zero I/O**, em `src/lib/giro/` — 4 arquivos + 4 suítes, **67 testes +verdes**, `tsc --noEmit` limpo, `eslint src/lib/giro` exit 0. Suíte completa da casa: +**1809/1809** (nada quebrado). + +| Arquivo | Cobre | Peças | +|---|---|---| +| `serie-operacional.ts` | §5.1 · §5.2 · §5.5 | `filtrarOperacional`, `profundidadeDoMes`, `buracoRecorrente`, `perfilCalendario`, `acumuladoAte` | +| `regua.ts` | §5.3 · §5.3.1 · §9 | `razaoGiro`, `diasParados`, `reguaEmDias`, `decompor` | +| `custo.ts` | §5.4 · §11.1 | `custoDoGiro`, `ehContratoDeGiro`, `taxaEfetivaAnual` | +| `crescimento.ts` | §10 · §10.4 · §11 | `cadeiaDoCrescimento`, `ordenarFontes`, `precificarGap`, `alavancas` | + +**Nenhum arquivo importa Supabase, `Date.now()` ou `@/lib/cash-now`.** `DiaOperacional` +é estruturalmente compatível com `PontoDiario` de `saldo-diario.ts` — a série entra sem +conversão, mas sem acoplar o núcleo àquele módulo. + +### Achados do F0 que mudaram o desenho + +1. **A finalidade de giro são DUAS, não uma.** A spec §5.4 fala no singular; o vivo + (`loan-contracts-finalidades.ts:11-12`) tem `capital_giro_operacao` **e** + `cobrir_aperto_caixa` — uma é o crônico, a outra o agudo, e as duas são dívida + tomada por causa do giro. As duas contam; `comprar_ativo`/`expansao`/ + `refinanciar_divida`/`emergencia` **não** (inflariam a manchete). Travado em + `FINALIDADES_DE_GIRO` com teste. +2. **`% do lucro` é `null` quando o lucro é ≤ 0.** "O giro custa −180% do seu lucro" + não significa nada; número sem significado com ar de medição é I8. A empresa no + prejuízo vê R$/ano e % da receita, e o % do lucro simplesmente não aparece. +3. **`FonteExterna` não tem campo de "quanto libera"** — §10.4.1 virou tipo, não + comentário. `loan_contracts` não tem limite; sem o campo no tipo, ninguém completa + isso depois sem mexer na spec antes. +4. **A série operacional exclui L65 além de L90.** Juros e deságio são custo do + socorro; deixá-los dentro contamina o buraco na direção errada. + +### Decisões pequenas que estão em teste (para não se perderem) + +- Vale com empate fica no dia **mais cedo** — é quando o aperto começa, que é o que dá + ação. +- Mês que só subiu tem buraco **zero**, nunca negativo. +- `perfilCalendario` normaliza **cada mês pelo próprio total** antes da média (senão o + mês grande domina), e mês sem entrada não entra como zero naquele lado. +- `decompor` joga o **termo cruzado na eficiência** — erra pro lado que o dono precisa + ver. + +## 4.3 G2 — SNAPSHOT MENSAL: CÓDIGO PRONTO, **MIGRATION NÃO APLICADA** (2026-08-17) + +| Artefato | O quê | +|---|---| +| `supabase/migrations/20260817000000_giro_monthly_snapshots.sql` | tabela + RLS + índices + extensão do trigger de stale-by-event | +| `src/lib/giro/giro-snapshot-service.ts` | `snapshotGiro(sb, projectId, mesRef)` — busca, chama o núcleo, grava | +| `src/lib/giro/giro-snapshot-service.test.ts` | 16 testes sobre as duas peças de maior risco | +| `scripts/db/backfill-giro-snapshots.ts` | `--project ` · `--all` · `--dry-run` | +| `scripts/db/provar-fidelidade-trigger.ts` | prova mecânica do anti-padrão 7 | +| `fechar-mes/route.ts:381-392` | 3º `after()`, depois da reconciliação (§8.3) | + +**83 testes no módulo · suíte da casa 1825/1825 · `tsc --noEmit` limpo · eslint exit 0.** + +### ✅ G2 FECHADO em 2026-08-17 + +1. ✅ Migration aplicada no SQL Editor (Lucas). +2. ✅ Re-dump: `schema.sql` 15.030 → **15.206**. Deltas batem um a um com a intenção — + +1 tabela, +2 policies, +3 índices, **+0 funções** (a função foi `REPLACE`, não + duplicada). Diff estrutural mostra **só** objetos `giro_*`. +3. ✅ `db/ESTRUTURA.md` regerado — 113 tabelas. `giro_monthly_snapshots` entrou no + domínio *Financeiro · apuração e DRE* (nasce no mesmo evento e tem o mesmo grão). +4. ✅ Backfill: **34 meses** (Vertímetal 31, Di Forni 3). +5. ✅ Check ao vivo com **zero divergência** — mas achou um bug de 58%, abaixo. + +**Duas provas de fidelidade da função `SECURITY DEFINER`**, e a segunda é a que importa: +`provar-fidelidade-trigger.ts` (migration × baseline) **e** o corpo extraído do +**dump** × o corpo da migration → idênticos, 112 linhas. É a diferença entre "a +migration diz" e "o banco faz". + +### 🔴 O bug que só o dado real mostrou — `custo_giro_juros = 0` + +O check ao vivo passou em tudo. Mas `custo_giro_juros` saiu **zero nos 34 meses**, e +zero merece conferência. A spec §5.4 mandava filtrar contrato por `finalidade` — e +**`finalidade` está NULL em 100% dos contratos do banco** (3 de 3). É uma pergunta de +intenção, feita depois do fato, que ninguém volta para responder. + +``` +custo do giro, jul/2026 (12m) medido real + deságio (L65) R$ 201.390 R$ 201.390 + juros de giro R$ 0 R$ 272.804 ← 2 contratos capital_giro + ───────────────────────────────────────────────────── + total R$ 201.390 R$ 474.194 ← 58% pra MENOS +``` + +Na **manchete permanente da tela**, e *pra menos* — a direção que ninguém percebe, +porque número menor não provoca conferência. + +**Correção:** `ehContratoDeGiro` passou a ser **OU**, não E — `finalidade` +(`capital_giro_operacao`, `cobrir_aperto_caixa`) **ou** `modalidade` (`capital_giro`, +`cheque_especial`). `desconto_duplicatas` e `antecipacao_cartao` ficam de fora de +propósito: o custo delas já chega pelo deságio, e contar as duas pontas dobraria o +mesmo dinheiro. Spec §5.4.1, teste de regressão em `custo.test.ts`, 34 meses refeitos +com a flag nova `--refazer`. + +**É a Fase 3 do método inteira em um caso:** 87 testes verdes, schema correto, dump +conferido — e o número principal saindo pela metade. + +### Pendências pequenas deste bloco + +- ✅ **`seed.sql` re-dumpado** depois do backfill (25.130 linhas, com as 34 do giro). + Uma tentativa intermediária saiu vazia (Docker auto-pausou) e foi instalada sem + conferir o tamanho — `seed.sql` é gitignored, o git não desfaz. Recuperado; o comando + passou a exigir `wc -l > 20000` antes de instalar. **Guarda de tamanho antes de + qualquer `cp` sobre dump.** +- **`finalidade` NULL em todos os contratos** é uma lacuna de produto, não só do giro: + o Endividamento pergunta e ninguém responde. Vale uma frente própria. + +### A correção que o G2 obrigou na spec + +**§8.2 previa um único `razao_giro` e um único trio de decomposição — e isso quebrava +o §4.1.** No mês em que o cliente começa a informar os três saldos, a mesma coluna +passaria a guardar outra grandeza, e a série misturaria *buraco do mês* com *capital +preso*: a confusão que o §4.1 existe para impedir, agora persistida no banco e +impossível de desfazer depois. Viraram `razao_buraco` + `razao_ncg` e dois trios de +decomposição (§8.2.1). E `efeito_ciclo` virou `*_efeito_eficiencia`, porque a spec +usava dois nomes para a mesma coisa. + +### O anti-padrão 7, provado e não prometido + +`marcar_snapshot_desatualizado()` é `SECURITY DEFINER` de ~85 linhas, e o G2 precisa que +ela marque também o snapshot de giro. A lei de método manda **provar** que a única +diferença é a pretendida. `scripts/db/provar-fidelidade-trigger.ts` extrai o corpo das +duas fontes, remove o bloco marcado com `▼▼▼`/`▲▲▲` e exige igualdade caractere a +caractere: + +``` +✓ FIDELIDADE PROVADA + Corpo idêntico à baseline: 81 linhas. + Única diferença: o bloco marcado, de 31 linhas +``` + +Na primeira execução ele **reprovou** — uma linha em branco a mais. É exatamente o tipo +de divergência silenciosa que justifica o anti-padrão existir. + +### Três decisões de implementação que não estavam na spec + +1. **A ancoragem é POR MÊS.** O saldo de abertura vem da série REAL (com socorro) no + último dia de M−1; dentro do mês a caminhada é só operacional. Reconstruir a série + inteira sem movpat a partir de uma âncora distante faria o nível derivar mês a mês. + De quebra o nível continua sendo dinheiro de verdade — dá para ler *"sem a + antecipação do dia 12, teria ficado negativo"*. +2. **Paginação obrigatória no fetch de `transactions`.** O PostgREST trunca em 1000 + linhas **sem avisar**; 12 meses de extrato passam disso com folga, e o vale sairia + medido sobre metade do movimento — errado *pra menos*. +3. **O deságio não sai de `transactions`.** Ele é sintético (nasce em + `dre-detalhamento/decomposicao.ts`) e só fica persistido dentro do `dre_detalhado` + do snapshot de DRE. `extrairDesagio()` lê de lá, e tem teste contra a forma real do + jsonb — era a suposição mais frágil do serviço. + +## 4.4 G3 — A TELA DO NÍVEL 0 (2026-08-17) + +A tela morta morreu. `/financeiro/giro` deixou de ser seção do Painel lendo o motor +legado e virou **view própria alimentada por um DTO só** (§24.1). + +| Artefato | O quê | +|---|---| +| `src/lib/giro/giro-view-service.ts` | `montarGiroView()` — monta o DTO inteiro | +| `src/lib/giro/formato.ts` | a régua do §13.5 num lugar só, com teste | +| `src/app/api/projetos/[id]/giro/route.ts` | `GET ?mes&janela` → o DTO | +| `src/components/giro/GiroView.tsx` | herói · bloco 3 · bloco 4 · bloco 6 · captura | +| `src/components/giro/GiroCharts.tsx` | perfil do mês + folga divergente | +| `PainelCasaTab.tsx` | `secao === "giro"` agora renderiza a `GiroView` | + +**121 testes no módulo · suíte 1863/1863 · `tsc` limpo · `eslint src/lib/giro +src/components/giro` exit 0.** No `PainelCasaTab` sobram **2 erros de token, os mesmos +2 do HEAD** — débito pré-existente, não tocado nesta sessão. + +**Paleta validada** (`scripts/validate_palette.js --mode dark`): entrada `#0D9488` × +saída `#DC2626` → CVD deutan ΔE **13,1** (alvo ≥ 8), visão normal ΔE **31,4** (piso 15), +contraste ≥ 3:1, todos os 6 checks PASS. O par verde/rosa do DS reprova (ΔE 4,6) e não +é usado. + +### O que saiu da tela, e por quê + +- `PainelAnaliseSections mostrar="ciclo"` — lia `cash_cycle_analyses`, com PMR/PMP de + **regex e prazo constante** (§3.1). **Repontado, não deletado:** é um dos 6 leitores + cuja aposentadoria é o G7, em sessão própria. Misturar deleção com feature é o + anti-padrão 6. +- Link para `/ciclo` — a tela que pede PMR/PME/PMP **em dias**, três índices que nenhum + dono sabe de cabeça (§3.2). +- `ContasPagarResumoCard` e `EstoqueSection` — voltam no **G5**, quando as pernas viram + MEDIDAS e entram na composição, em vez de ficarem soltas como cards vizinhos. + +### 🔴 O check contra dado real derrubou uma métrica minha + +O bloco 3 media *"em que dia a saída passa de 50%"*, e o texto assumia que **saída vem +antes de entrada** — o aperto clássico. Rodado contra as duas empresas reais: + +``` +Vertímetal sai dia 19 · entra dia 17 → RECEBE 2 dias antes +Di Forni sai dia 20 · entra dia 18 → RECEBE 2 dias antes +``` + +**As duas recebem antes de pagar.** A tela teria dito *"é esse descasamento que precisa +ser financiado todo mês"* sobre um calendário que trabalha **a favor** delas — +fabricando um problema inexistente na tela cuja razão de existir é não fabricar nada. + +Trocado por `descasamentoDoMes`: **centro de massa** de cada lado, com **sinal**. +Simétrico, funciona nos dois sentidos, e o sinal escolhe a história que a tela conta. +Teste de regressão com o caso real. + +### Achados do smoke visual (Lucas, 2026-08-17) — 5 bugs corrigidos + +O primeiro render em produção derrubou mais coisa que os testes. + +**1. O bloco 4 nunca apareceu — e o motivo era erro de produto meu.** Eu tinha escrito +"sem meta e sem crescimento, não há pergunta". Na Vertímetal: + +``` +receita 12m recentes : R$ 19.321 mil tendência: −0,8% → NULL +plano de voo v5 : meta_12m existe, objetivo == baseline → 0,0% +``` + +A constituição define o ICP como **empresa estagnada ou em prejuízo, que não cresce**. +Um bloco que só aparece para quem cresce **nunca apareceria para o cliente típico** — e +a pergunta é mais útil para quem está parado, porque é o "o que eu preciso para sair +daqui". Agora o bloco tem **dois modos**: `plano` (a cadeia completa) e `teto`, que +inverte a pergunta para *"com o caixa que você gera, dá pra crescer quanto?"* (§10.3). + +**2. A frase da decomposição não fechava a conta.** *"caiu R$ 51 mil: R$ 21 mil ... e +R$ 72 mil"* — 21 + 72 = 93. Eu tinha aplicado `Math.abs` nos três números e escondido os +sinais. O dono faz a conta de cabeça e ela falha, **na tela cuja premissa é não fabricar +nada**. Reescrita com os sinais visíveis: o faturamento *somou* 21, o descasamento +*tirou* 72, no líquido *caiu* 51. + +**3. `teto × 12` estava errado.** Anualizar taxa mensal é composição, não multiplicação: +14% ao mês são 380% ao ano, não 166%. E acima de 5%/mês o número anualizado vira cifra +que ninguém acredita — cifra inacreditável queima a confiança na tela inteira. Acima do +limiar a resposta certa não é um número maior, é a leitura: *"o capital de giro não é o +que limita o seu crescimento hoje"*. + +**4. Gráfico esticando 2,4×.** viewBox de 660px solto num container de ~1570px escala +tudo junto — rótulo de 11px vira 26px, barra passa de 400px de altura. `max-w-[860px]` +segura em ~1,3×, onde os tamanhos declarados ainda valem. + +**5. Skeleton colado e campos falsos.** `SkeletonGroup` é só semântica (role/aria-busy), +não tem layout — sem `flex+gap` os blocos viravam uma laje só. E os três itens da +captura tinham borda e fundo de input: pareciam campos clicáveis e não são (a captura +acontece no fechamento). Viraram lista marcada, que não promete interação. + +**Pendente: o visual.** Hierarquia, fonte, cores e disposição ficaram como estão — o +Lucas vai desenhar e eu aplico. O que se sabe hoje: a casa usa `font-mono` só até +`text-xl`, e em `clamp(30–42px)` ele vira fonte de terminal; o mockup usava Rajdhani, +que o app não carrega; e a composição do mockup foi desenhada para `max-width: 1000px`, +não para a largura real da tela. + +### O que este bloco AINDA NÃO verificou + +1. **O bloco 4 (crescimento) não foi conferido contra dado real.** Ele depende de + `montarProjecaoAnualParaProjeto`, que chama `cookies()` e só roda dentro de um + request — o script de check não alcança. A lógica tem 23 testes, mas a integração é + fé até alguém abrir a tela. +2. **Nenhum screenshot.** A rota é autenticada e não há credencial de teste aqui. + Fidelidade de UI exige olhar, e ninguém olhou. + +Fica um `seguro()` em volta das duas chamadas do bloco 4: `.catch()` na promise **não +basta**, porque `cookies()` estoura de forma síncrona e o throw passa por cima — §24.3, +um bloco quebrado degrada sozinho e nunca leva a tela junto. + +## 5. Pendências conhecidas + +1. **Sliders do mockup são estáticos** — mexer não redesenha a coluna simulada. Ligar o + cálculo ao vivo é opcional (o conceito já está claro). +2. **Intercompany em número digitado não tem solução limpa** (§22.4). Mitigação: 4ª + pergunta condicional, só quando o extrato já mostrou fluxo entre as irmãs. E a + conferência de par de 2026-08-06 achou **16 de 17 eliminações sem contrapartida** — + a NCG consolidada nasce rotulada. +3. **Consolidado de grupo diferido** para depois da G5. +4. **Backfill é pré-requisito da série** — `giro_monthly_snapshots` nasce vazia (§24.4). + +--- + +## 6. Achados de acessibilidade (valem para o resto do sistema) + +Rodando o validador de paleta do skill `dataviz`: + +- **O par verde `#34D399` + rosa `#FB7185` do DS-v3 Glass reprova com ΔE 4,6 em + deuteranopia.** É o par usado em entrada/saída no sistema inteiro, não só aqui. +- As cores de série do DS estão **acima da banda de luminosidade do modo escuro** + (L 0,68–0,84 contra 0,48–0,67) — por isso vibram sobre o fundo. +- Paleta validada adotada nesta tela: contas a receber `#0D9488` · estoque `#D97706` · + contas a pagar `#6366F1` · saídas `#DC2626`. + +**Vale abrir uma frente própria** para revisar a paleta de gráficos do DS — o problema +não é desta tela. + +Gotcha técnico registrado em memória: **`el.hidden = true` não funciona em ``** +(SVGElement não reflete a propriedade no atributo). Use `setAttribute`/`removeAttribute`, +e teste visibilidade por `getComputedStyle().display`, nunca pela propriedade. diff --git a/scripts/db/backfill-giro-snapshots.ts b/scripts/db/backfill-giro-snapshots.ts new file mode 100644 index 0000000..c9419a1 --- /dev/null +++ b/scripts/db/backfill-giro-snapshots.ts @@ -0,0 +1,188 @@ +/** + * Backfill de `giro_monthly_snapshots` sobre os meses JÁ FECHADOS. + * + * A tabela nasce vazia (spec §24.4). Sem backfill não existe série — e sem série + * não existe a espinha nem a decomposição, que é metade do valor da tela. + * + * Reusa o MESMO núcleo do fechamento (`snapshotGiro`), então backfill e fechamento + * não podem divergir: se divergirem, é bug num só lugar. + * + * Duas honestidades (§24.4): + * 1. Mês antigo pode render menos (sem carteira, sem planilha). Cada mês grava a + * própria procedência por perna — a série mostra a fonte mudando ao longo do + * tempo em vez de fingir uniformidade. + * 2. O backfill NÃO reescreve DRE nem carteira. Só lê e deriva. Insumo que não + * existe entra como perna NULA, nunca como zero. + * + * Ordem cronológica é obrigatória: a decomposição do mês M lê o snapshot de M−1. + * + * npx tsx scripts/db/backfill-giro-snapshots.ts --project + * npx tsx scripts/db/backfill-giro-snapshots.ts --all + * npx tsx scripts/db/backfill-giro-snapshots.ts --all --dry-run + * npx tsx scripts/db/backfill-giro-snapshots.ts --all --refazer + * + * `--refazer` APAGA os snapshots existentes antes de gravar de novo. Existe para o + * caso em que o backfill rodou com um cálculo errado — foi o que aconteceu em + * 2026-08-17, quando `custo_giro_juros` saiu zerado em 34 meses porque o filtro de + * contrato olhava só `finalidade` (NULL em 100% dos contratos). Sem ele, a + * idempotência do script preservaria o número errado para sempre. + * + * ⚠️ Só use em snapshot de backfill. Mês fechado de verdade tem versionamento + * (`version + 1`) justamente para NÃO apagar o que já foi congelado. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { createClient } from "@supabase/supabase-js"; +import { snapshotGiro } from "../../src/lib/giro/giro-snapshot-service"; + +/** + * Carrega `.env.local` — o `tsx` não faz isso sozinho (quem carrega é o Next). + * + * Não usa `import.meta.dirname`: sob o tsx ele vem `undefined`, o `join` estoura e + * um `catch` silencioso transforma isso em "faltam as variáveis" com o arquivo ali + * do lado. Resolve pelo cwd (o script é documentado para rodar da raiz) e, se não + * achar, avisa em vez de falhar calado. + */ +function carregarEnvLocal(): void { + const caminho = join(process.cwd(), ".env.local"); + let txt: string; + try { + txt = readFileSync(caminho, "utf8"); + } catch { + console.warn(`(sem ${caminho} — usando só o que já está no ambiente)`); + return; + } + // Split por /\r?\n/, não por "\n": o arquivo é CRLF, e em regex JS o `\r` é + // TERMINADOR DE LINHA — `(.*)$` para antes dele e o `$` não fecha, então + // NENHUMA variável casaria. Falha silenciosa e difícil de enxergar. + for (const linha of txt.split(/\r?\n/)) { + const m = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(linha); + if (!m) continue; + const chave = m[1]; + if (process.env[chave]) continue; // ambiente real ganha do arquivo + process.env[chave] = m[2].trim().replace(/^["']|["']$/g, ""); + } +} +carregarEnvLocal(); + +const URL = process.env.NEXT_PUBLIC_SUPABASE_URL; +const KEY = process.env.SUPABASE_SERVICE_ROLE_KEY; + +if (!URL || !KEY) { + console.error("Faltam NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY no ambiente."); + process.exit(1); +} + +const argv = process.argv.slice(2); +const dryRun = argv.includes("--dry-run"); +const refazer = argv.includes("--refazer"); +const todos = argv.includes("--all"); +const projetoArg = argv[argv.indexOf("--project") + 1]; + +if (!todos && !projetoArg) { + console.error("Use --project ou --all."); + process.exit(1); +} + +const sb = createClient(URL, KEY, { auth: { persistSession: false } }); + +async function mesesFechados(projectId: string): Promise { + const { data } = await sb + .from("dre_monthly_snapshots") + .select("mes_referencia") + .eq("project_id", projectId) + .is("invalidated_at", null) + .order("mes_referencia", { ascending: true }); + // Distintos e em ordem cronológica: M depende do snapshot de M−1. + return [...new Set((data ?? []).map((r) => r.mes_referencia as string))].sort(); +} + +async function jaTemSnapshot(projectId: string, mes: string): Promise { + const { data } = await sb + .from("giro_monthly_snapshots") + .select("id") + .eq("project_id", projectId) + .eq("mes_referencia", mes) + .limit(1) + .maybeSingle(); + return data != null; +} + +async function main() { + let projetos: Array<{ id: string; nome: string | null }>; + if (todos) { + const { data } = await sb.from("projects").select("id, nome").order("nome"); + projetos = (data ?? []) as Array<{ id: string; nome: string | null }>; + } else { + const { data } = await sb.from("projects").select("id, nome").eq("id", projetoArg).single(); + if (!data) { + console.error(`Projeto ${projetoArg} não encontrado.`); + process.exit(1); + } + projetos = [data as { id: string; nome: string | null }]; + } + + console.log( + `${projetos.length} projeto(s)` + + `${dryRun ? " — DRY RUN, nada é gravado" : ""}` + + `${refazer ? " — REFAZER: apaga o snapshot existente antes de regravar" : ""}\n`, + ); + + let gravados = 0; + let pulados = 0; + let refeitos = 0; + let falhos = 0; + + for (const p of projetos) { + const meses = await mesesFechados(p.id); + if (meses.length === 0) continue; + + console.log(`▸ ${p.nome ?? p.id} — ${meses.length} mês(es) fechado(s)`); + + for (const mes of meses) { + if (await jaTemSnapshot(p.id, mes)) { + if (!refazer) { + pulados++; + continue; // idempotente: não recria o que já existe + } + if (!dryRun) { + await sb + .from("giro_monthly_snapshots") + .delete() + .eq("project_id", p.id) + .eq("mes_referencia", mes); + } + refeitos++; + } + if (dryRun) { + console.log(` ${mes} (gravaria)`); + gravados++; + continue; + } + const r = await snapshotGiro(sb, p.id, mes); + if (r.gravado) { + gravados++; + const av = r.avisos.length > 0 ? ` ⚠ ${r.avisos.join(" · ")}` : ""; + console.log(` ${mes} v${r.version}${av}`); + } else { + falhos++; + console.log(` ${mes} ✗ ${r.motivo}`); + } + } + } + + console.log( + `\ngravados ${gravados} · já existiam ${pulados} · refeitos ${refeitos} · sem gravar ${falhos}`, + ); + if (falhos > 0) { + console.log( + "\nMês 'sem gravar' não é necessariamente erro: sem DRE ativo ou sem conta\n" + + "bancária, a foto do giro não existe — e a série começa onde há dado (§24.3).", + ); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/db/gerar-estrutura.ts b/scripts/db/gerar-estrutura.ts index c0ac8ff..338ce39 100644 --- a/scripts/db/gerar-estrutura.ts +++ b/scripts/db/gerar-estrutura.ts @@ -64,6 +64,9 @@ const DOMINIOS: Array<{ nome: string; descricao: string; tabelas: string[] }> = "dre_monthly_snapshots", "dre_reports", "dre_detalhamento", "dre_detalhamento_antecipacao_linha", "dre_detalhamento_contrato", "financial_snapshots", "cockpit_monthly_deltas", + // Nasce no mesmo evento (after() do fechar-mes) e tem o mesmo grão + // projeto+mês+version — é apuração, não análise derivada. + "giro_monthly_snapshots", ], }, { diff --git a/scripts/db/provar-fidelidade-trigger.ts b/scripts/db/provar-fidelidade-trigger.ts new file mode 100644 index 0000000..1f9678d --- /dev/null +++ b/scripts/db/provar-fidelidade-trigger.ts @@ -0,0 +1,101 @@ +/** + * Prova que `marcar_snapshot_desatualizado()` foi ESTENDIDA, não reescrita. + * + * Anti-padrão 7 da lei de método (`docs/atros-v3/metodo.md`): reescrever função + * `SECURITY DEFINER` longa por transcrição é como se introduz divergência que só + * aparece em runtime, com privilégio elevado — o apply sempre passa. A lei exige + * provar a fidelidade: "única diferença = a pretendida". + * + * Este script faz isso mecanicamente. Extrai o corpo da função nas duas fontes, + * remove do lado novo APENAS o bloco marcado como a diferença pretendida, e exige + * que o que sobra seja idêntico caractere a caractere. + * + * READ-ONLY, sem conexão com o Postgres — compara dois arquivos SQL. + * + * npx tsx scripts/db/provar-fidelidade-trigger.ts + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const RAIZ = join(import.meta.dirname, "..", ".."); +const BASELINE = join(RAIZ, "supabase", "migrations", "00000000000000_baseline.sql"); +const NOVA = join(RAIZ, "supabase", "migrations", "20260817000000_giro_monthly_snapshots.sql"); + +const FUNCAO = "marcar_snapshot_desatualizado"; +const MARCA_INICIO = "▼▼▼"; +const MARCA_FIM = "▲▲▲"; + +/** + * Extrai o corpo `$$ ... $$` da definição da função. + * + * Ancora no `CREATE ... FUNCTION`, não em qualquer menção ao nome: a baseline + * também traz `GRANT ALL ON FUNCTION ...` mais adiante, e ancorar na última + * ocorrência do nome cairia lá, num ponto sem corpo. + */ +function extrairCorpo(sql: string, arquivo: string): string { + const decl = sql.lastIndexOf(`FUNCTION "public"."${FUNCAO}"() RETURNS "trigger"`); + if (decl === -1) throw new Error(`CREATE de ${FUNCAO} não encontrado em ${arquivo}`); + const abre = sql.indexOf("$$", decl); + const fecha = sql.indexOf("$$", abre + 2); + if (abre === -1 || fecha === -1) throw new Error(`corpo $$...$$ não delimitado em ${arquivo}`); + return sql.slice(abre + 2, fecha); +} + +/** + * Tira o bloco pretendido: da linha que abre a marca até a que fecha, inclusive. + * Se as marcas sumirem, o script falha em vez de "passar" comparando outra coisa. + */ +function removerBlocoPretendido(corpo: string): { resto: string; bloco: string } { + const linhas = corpo.split("\n"); + const i = linhas.findIndex((l) => l.includes(MARCA_INICIO)); + const j = linhas.findIndex((l) => l.includes(MARCA_FIM)); + if (i === -1 || j === -1 || j < i) { + throw new Error( + `marcas ${MARCA_INICIO}/${MARCA_FIM} não encontradas no corpo novo — ` + + `sem elas não dá para separar a diferença pretendida do resto.`, + ); + } + return { + resto: [...linhas.slice(0, i), ...linhas.slice(j + 1)].join("\n"), + bloco: linhas.slice(i, j + 1).join("\n"), + }; +} + +/** Normaliza só o que é irrelevante: espaço à direita e fim de linha. */ +function normalizar(s: string): string { + return s + .replace(/\r\n/g, "\n") + .split("\n") + .map((l) => l.replace(/\s+$/, "")) + .join("\n") + .trim(); +} + +const corpoBaseline = normalizar(extrairCorpo(readFileSync(BASELINE, "utf8"), "baseline")); +const { resto, bloco } = removerBlocoPretendido(extrairCorpo(readFileSync(NOVA, "utf8"), "nova")); +const corpoNovoSemBloco = normalizar(resto); + +if (corpoNovoSemBloco === corpoBaseline) { + const linhasBloco = bloco.split("\n").length; + console.log("✓ FIDELIDADE PROVADA"); + console.log(` Corpo idêntico à baseline: ${corpoBaseline.split("\n").length} linhas.`); + console.log(` Única diferença: o bloco marcado, de ${linhasBloco} linhas`); + console.log(` (o UPDATE que marca giro_monthly_snapshots).`); + process.exit(0); +} + +console.error("✗ DIVERGÊNCIA — o corpo novo difere da baseline ALÉM do bloco pretendido."); +console.error(" Isto é exatamente o anti-padrão 7. NÃO aplique a migration.\n"); + +const a = corpoBaseline.split("\n"); +const b = corpoNovoSemBloco.split("\n"); +let achou = 0; +for (let i = 0; i < Math.max(a.length, b.length) && achou < 12; i++) { + if (a[i] !== b[i]) { + achou++; + console.error(` linha ${i + 1}:`); + console.error(` baseline: ${a[i] ?? "(ausente)"}`); + console.error(` nova : ${b[i] ?? "(ausente)"}`); + } +} +process.exit(1); diff --git a/src/app/api/projetos/[id]/dre/fechar-mes/route.ts b/src/app/api/projetos/[id]/dre/fechar-mes/route.ts index 60a82e0..6dfe436 100644 --- a/src/app/api/projetos/[id]/dre/fechar-mes/route.ts +++ b/src/app/api/projetos/[id]/dre/fechar-mes/route.ts @@ -27,6 +27,7 @@ import { parseDataBanco } from "@/lib/parsers/utils"; import type { DreDetalhamento, LancamentoPendencia } from "@/lib/dre-detalhamento/types"; import { onMonthClosed } from "@/lib/iniciativas/on-month-closed"; import { reconciliarCarteiraNoFechamento } from "@/lib/dre-detalhamento/recebivel-reconciliacao"; +import { snapshotGiro } from "@/lib/giro/giro-snapshot-service"; const MES_REF_REGEX = /^[0-9]{4}-(0[1-9]|1[0-2])$/; @@ -378,6 +379,19 @@ export async function POST( // créditos reais (±2%/±5d). Pós-resposta, idempotente, nunca bloqueia. after(() => reconciliarCarteiraNoFechamento(service, projectId, mesRef).catch(() => { })); + // ─── Foto mensal do capital de giro (G2, spec §8.3) ───────────────── + // DEPOIS da reconciliação, de propósito: a carteira do mês precisa estar + // baixada antes de o CR medido ser lido (§8.3). Como os `after()` rodam em + // ordem de registro, este vem por último. Nunca lança — `snapshotGiro` + // devolve `{gravado:false, motivo}` em vez de estourar, porque um erro aqui + // não pode derrubar um fechamento já confirmado ao cliente. + after(async () => { + const r = await snapshotGiro(service, projectId, mesRef); + if (!r.gravado) { + console.warn(`[fechar-mes/POST] snapshot de giro não gravado (${mesRef}):`, r.motivo); + } + }); + return NextResponse.json(inserted as MonthlySnapshot); } catch (err) { console.error("[fechar-mes/POST] exceção:", err); diff --git a/src/app/api/projetos/[id]/giro/route.ts b/src/app/api/projetos/[id]/giro/route.ts new file mode 100644 index 0000000..4304db7 --- /dev/null +++ b/src/app/api/projetos/[id]/giro/route.ts @@ -0,0 +1,60 @@ +/** + * GET /api/projetos/[id]/giro?mes=YYYY-MM&janela=6|12 + * + * Devolve o `GiroViewDTO` inteiro — spec §24.1: **a tela consome UM DTO, montado + * por um serviço só.** Nenhum componente busca dado por conta própria; foi assim + * que a tela velha acabou com dois motores de ciclo servindo números diferentes. + * + * Existe como rota (e não como fetch no client) porque o bloco do crescimento + * precisa de `montarProjecaoAnualParaProjeto`, que é server-only. + */ +import { NextRequest, NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; +import { montarGiroView, type Janela } from "@/lib/giro/giro-view-service"; + +export const dynamic = "force-dynamic"; + +interface Params { + params: Promise<{ id: string }>; +} + +export async function GET(req: NextRequest, { params }: Params) { + try { + const { id: projectId } = await params; + const supabase = await createClient(); + + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // A RLS de `giro_monthly_snapshots` já restringe por `project_members`; + // este check devolve 403 explícito em vez de uma tela vazia sem motivo. + const { data: project } = await supabase + .from("projects") + .select("id") + .eq("id", projectId) + .single(); + if (!project) { + return NextResponse.json({ error: "Access denied or project not found" }, { status: 403 }); + } + + const mesParam = req.nextUrl.searchParams.get("mes"); + const mesRef = mesParam && /^\d{4}-(0[1-9]|1[0-2])$/.test(mesParam) ? mesParam : undefined; + const janela: Janela = req.nextUrl.searchParams.get("janela") === "12" ? 12 : 6; + + const dto = await montarGiroView(supabase, projectId, { mesRef, janela }); + + // `null` = ainda não há foto de giro (mês nenhum fechado, ou backfill não + // rodou). 200 com `null` e não 404: é estado legítimo do produto, e a tela + // sabe desenhar o "ainda não" sem tratar como erro. + return NextResponse.json(dto); + } catch (err) { + console.error("[api/giro/GET]", err); + const message = err instanceof Error ? err.message : "Erro interno"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/components/central-dados/PainelCasaTab.tsx b/src/components/central-dados/PainelCasaTab.tsx index f6b87a8..7c055aa 100644 --- a/src/components/central-dados/PainelCasaTab.tsx +++ b/src/components/central-dados/PainelCasaTab.tsx @@ -23,7 +23,6 @@ import { Card, Skeleton, SkeletonGroup, PerguntaHeader, PerguntaHeaderSkeleton } import { useDelayedLoading } from "@/hooks/useDelayedLoading"; import { FinKpiCard } from "@/components/financeiro/cockpit/FinKpiCard"; import { AcompanhamentoResultadosCard } from "@/components/financeiro/cockpit/AcompanhamentoResultadosCard"; -import { ContasPagarResumoCard } from "@/components/financeiro/cockpit/ContasPagarResumoCard"; import { ProjecaoAnualCard } from "@/components/financeiro/cockpit/ProjecaoAnualCard"; import { CaixaProjecaoView } from "@/components/financeiro/cockpit/CaixaProjecaoView"; import { getFinancialOverview, type FinancialOverview } from "@/lib/financial-dashboard-service"; @@ -35,6 +34,7 @@ import { classificarABC, type ResultadoABC, type ClasseABC } from "@/lib/abc/abc import { calcularHHI, type ResultadoHHI } from "@/lib/concentracao/hhi"; import { analisarEstoque, type ResultadoEstoque, type EstoqueItem } from "@/lib/estoque/estoque-analysis"; import { EndividamentoSection } from "@/components/endividamento/EndividamentoSection"; +import { GiroView } from "@/components/giro/GiroView"; import { CockpitPacingView } from "@/components/cockpit/CockpitPacingView"; import { CockpitPacingGrupo } from "@/components/cockpit/CockpitPacingGrupo"; import { getBankAccountService } from "@/lib/bank-account-service"; @@ -253,15 +253,16 @@ export function PainelEmpresaView({ {secao === "giro" && ( <> - - - - {/* Sem link pra `contas`: a tela está atrás de flag OFF e devolvia - um cartaz de "recurso em rollout" pro dono (I3). Enquanto ela não - entrar no loop de fechamento, não se manda ninguém pra lá. */} -
- -
+ {/* G3: a seção virou view própria, alimentada por UM DTO + (`GET /api/projetos/[id]/giro`, spec §24.1). Saíram daqui o + `PainelAnaliseSections mostrar="ciclo"` — que lia o motor legado + `cash_cycle_analyses`, com PMR/PMP de regex e prazo constante + (§3.1) — e o link para `/ciclo`, tela que pede PMR/PME/PMP em + DIAS, três índices que nenhum dono sabe de cabeça (§3.2). + `ContasPagarResumoCard` e `EstoqueSection` voltam no G5, quando + as pernas passam a ser MEDIDAS e entram na composição em vez de + ficarem soltas como cards vizinhos. */} + )} @@ -304,6 +305,11 @@ interface EndRow { dependencia_antecipacao_faixa: string | null; } +/* Órfã desde o G3 (o giro virou view própria). NÃO deletar aqui: é um dos 6 + leitores de `cash_cycle_analyses` do §3.1, e a aposentadoria deles é o G7, em + sessão própria, com grep-de-prova na hora e re-dump. Misturar deleção numa + sessão de feature é o anti-padrão 6 da lei de método. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars function PainelAnaliseSections({ projectId, mostrar = "ambos", @@ -978,6 +984,10 @@ const CONF_ESTOQUE: Record(null); const [caixaNow, setCaixaNow] = useState(null); diff --git a/src/components/giro/GiroCharts.tsx b/src/components/giro/GiroCharts.tsx new file mode 100644 index 0000000..bbf0bdf --- /dev/null +++ b/src/components/giro/GiroCharts.tsx @@ -0,0 +1,271 @@ +"use client"; + +/** + * Gráficos do Capital de Giro. + * + * **Paleta validada** com `scripts/validate_palette.js --mode dark`, os 6 checks: + * entrada `#0D9488` × saída `#DC2626` → CVD deutan ΔE 13,1 (alvo ≥ 8), visão normal + * ΔE 31,4 (piso 15), contraste ≥ 3:1. O par verde/rosa do DS **reprova** + * (ΔE 4,6 em deuteranopia) e por isso não é usado aqui — ver + * `ds_paleta_grafico_reprova_cvd`. + * + * Barras finas, topo arredondado ancorado na base, 2px de respiro entre marcas, + * eixo recessivo, rótulo direto só onde informa. Legenda sempre presente com 2 + * séries, para identidade nunca depender só de cor. + * + * ⚠️ `max-w-[860px]` no não é estética, é ESCALA. O viewBox tem 660 de largura; + * solto num container de ~1570px ele estica 2,4× e TODO texto e traço escala junto — + * o rótulo de 11px vira 26px e a barra passa de 400px de altura. O teto segura a + * ampliação em ~1,3×, que é onde os tamanhos declarados ainda valem. + */ + +import { brl, mesCurto } from "@/lib/giro/formato"; +import type { PerfilDia } from "@/lib/giro/serie-operacional"; +import type { PontoFolga } from "@/lib/giro/crescimento"; + +const COR_ENTRADA = "#0D9488"; +const COR_SAIDA = "#DC2626"; +const COR_EIXO = "rgba(255,255,255,.08)"; +const COR_BASE = "rgba(255,255,255,.20)"; +const COR_TEXTO = "#6E7887"; +const COR_TEXTO_FORTE = "#A8B2C0"; + +/** Topo arredondado, base reta — a marca fica ancorada na linha de base. */ +function barra(x0: number, x1: number, yTopo: number, yBase: number, r = 3): string { + const raio = Math.min(r, Math.max(0, (yBase - yTopo) / 2), (x1 - x0) / 2); + return ( + `M${x0},${yTopo + raio} Q${x0},${yTopo} ${x0 + raio},${yTopo} ` + + `H${x1 - raio} Q${x1},${yTopo} ${x1},${yTopo + raio} V${yBase} H${x0} Z` + ); +} + +// ════════════════════════════════════════════════════════════════ +// 1. PERFIL DO MÊS — entradas × saídas por semana +// ════════════════════════════════════════════════════════════════ + +const SEMANAS: Array<{ rotulo: string; de: number; ate: number }> = [ + { rotulo: "Dias 1 a 7", de: 1, ate: 7 }, + { rotulo: "Dias 8 a 15", de: 8, ate: 15 }, + { rotulo: "Dias 16 a 23", de: 16, ate: 23 }, + { rotulo: "Dias 24 a 31", de: 24, ate: 31 }, +]; + +export interface PerfilSemana { + rotulo: string; + entradaPct: number; + saidaPct: number; +} + +/** + * Agrega os 31 dias em 4 semanas. + * + * O grão diário existe no snapshot e é o certo para guardar, mas 31 colunas numa + * tela de dono é ruído: ele age por quinzena ("mudar o vencimento do dia 20 pro + * 30"), não por dia isolado. + */ +export function agregarSemanas(perfil: PerfilDia[]): PerfilSemana[] { + return SEMANAS.map((s) => { + const dentro = perfil.filter((p) => p.dia >= s.de && p.dia <= s.ate); + return { + rotulo: s.rotulo, + entradaPct: dentro.reduce((a, p) => a + p.entradaPct, 0), + saidaPct: dentro.reduce((a, p) => a + p.saidaPct, 0), + }; + }); +} + +export function GraficoPerfilMes({ perfil }: { perfil: PerfilDia[] }) { + const semanas = agregarSemanas(perfil); + const max = Math.max(...semanas.flatMap((s) => [s.entradaPct, s.saidaPct]), 1); + + const TOPO = 20; + const BASE = 190; + const y = (v: number) => BASE - (v / max) * (BASE - TOPO); + const centros = [110, 265, 420, 575]; + + return ( +
+
+ + + Entradas + + + + Saídas + +
+ + + {[0.5, 1].map((f) => ( + + ))} + + + + 0% + {Math.round(max)}% + + + {semanas.map((s, i) => { + const c = centros[i]; + return ( + + {/* 2px de respiro entre as duas marcas do par */} + + + + {s.rotulo} + + {/* rótulo direto só na maior saída — não um número em cada marca */} + {s.saidaPct === Math.max(...semanas.map((x) => x.saidaPct)) && ( + + {Math.round(s.saidaPct)}% + + )} + + ); + })} + + + ); +} + +// ════════════════════════════════════════════════════════════════ +// 2. A FOLGA DO CRESCIMENTO — barras divergentes +// ════════════════════════════════════════════════════════════════ + +/** + * Diverging: um par de hues em torno de um **zero neutro** (a linha de base cinza). + * Barra pra cima = sobra; pra baixo = falta. Uma série só, polaridade codificada + * pela cor E pelo lado — nunca só pela cor. + */ +export function GraficoFolga({ curva, meses }: { curva: PontoFolga[]; meses: string[] }) { + if (curva.length === 0) return null; + + const vs = curva.map((p) => p.folga); + const alto = Math.max(...vs, 0) * 1.15; + const baixo = Math.min(...vs, 0) * 1.35 || -alto * 0.18; + const span = alto - baixo || 1; + + const TOPO = 18; + const FUNDO = 176; + const y = (v: number) => TOPO + ((alto - v) / span) * (FUNDO - TOPO); + const zero = y(0); + const larg = 596 / curva.length; + + const primeiroNeg = curva.findIndex((p) => p.folga < 0); + const ultimo = curva[curva.length - 1]; + + return ( +
+ + + + 0 + + + sobra + + + falta + + + {curva.map((p, i) => { + const c = 52 + larg * i + larg / 2; + const neg = p.folga < 0; + const meia = larg * 0.3; + return ( + + {neg ? ( + // negativo: canto reto no zero, arredondado embaixo + + ) : ( + + )} + {i % 2 === 0 && ( + + {mesCurto(meses[i] ?? "")} + + )} + + ); + })} + + {primeiroNeg >= 0 && ( + <> + + + a falta começa aqui + + + )} + + {/* último mês sempre rotulado, COM sinal — negativo nunca travestido de positivo */} + = 0 ? y(ultimo.folga) - 7 : y(ultimo.folga) + 15} + textAnchor="middle" + fontSize="12" + fontWeight="700" + fill={ultimo.folga >= 0 ? "#2DD4BF" : "#F87171"} + fontFamily="Inter" + > + {brl(ultimo.folga)} + + +
+ ); +} diff --git a/src/components/giro/GiroView.tsx b/src/components/giro/GiroView.tsx new file mode 100644 index 0000000..78785ca --- /dev/null +++ b/src/components/giro/GiroView.tsx @@ -0,0 +1,597 @@ +"use client"; + +/** + * Capital de Giro — a tela (G3, Nível 0). + * + * Responde, nesta ordem, as perguntas do §13.1: + * herói → quanto isso está me custando, e o que mudou neste mês + * bloco 3 → por que falta dinheiro no meio do mês + * bloco 4 → eu aguento crescer? + * bloco 6 → a conta detalhada + * captura → os 3 números que destravam o resto + * + * **Nível 0 não tem a espinha** — sem as três pernas não existe gráfico de capital + * de giro (§13.2). Mas também não é só uma porta: o bloco 4 roda sobre o buraco do + * mês e responde a pergunta do crescimento sem nenhum dado digitado. + * + * A tela consome **um DTO** de `GET /api/projetos/[id]/giro` (§24.1). Nenhum bloco + * busca dado por conta própria. + */ + +import { useEffect, useState } from "react"; +import { Card, Skeleton, SkeletonGroup } from "@/components/ui"; +import { useDelayedLoading } from "@/hooks/useDelayedLoading"; +import { brl, dias, pct, mesLongo } from "@/lib/giro/formato"; +import type { GiroViewDTO } from "@/lib/giro/giro-view-service"; +import { GraficoPerfilMes, GraficoFolga } from "./GiroCharts"; + +interface Props { + projectId: string; + /** Mês do Shell. Ausente ⇒ o serviço escolhe o mais recente fechado. */ + mes?: string; +} + +interface Resposta { + /** Para qual pedido esta resposta é. Trocar de mês invalida a anterior. */ + chave: string; + dto: GiroViewDTO | null; + erro: string | null; +} + +export function GiroView({ projectId, mes }: Props) { + const chave = `${projectId}|${mes ?? ""}`; + const [resposta, setResposta] = useState(null); + + // O estado carrega a chave do pedido em vez de ser zerado no início do efeito: + // `setState` síncrono dentro de efeito dispara render em cascata (e a regra + // `react-hooks/set-state-in-effect` barra). Casando dado × chave, a troca de mês + // volta a "carregando" sozinha, sem reset imperativo. + useEffect(() => { + let vivo = true; + const qs = new URLSearchParams(); + if (mes) qs.set("mes", mes); + fetch(`/api/projetos/${projectId}/giro?${qs}`) + .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status))))) + .then((d: GiroViewDTO | null) => vivo && setResposta({ chave, dto: d, erro: null })) + .catch((e: Error) => vivo && setResposta({ chave, dto: null, erro: e.message })); + return () => { + vivo = false; + }; + }, [chave, projectId, mes]); + + const atual = resposta?.chave === chave ? resposta : null; + const dto = atual ? atual.dto : undefined; + const erro = atual?.erro ?? null; + const carregando = useDelayedLoading(dto === undefined); + + if (dto === undefined) { + return carregando ? ( + // `SkeletonGroup` é só semântica (role/aria-busy) — não tem layout. + // Sem o flex+gap os blocos colam numa laje só. As alturas espelham a + // tela real: herói baixo, mecanismo alto (tem gráfico), custo médio. + + + + + + + ) : null; + } + + if (!dto) return ; + + return ( +
+ + + {dto.crescimento && } + + + +
+ ); +} + +// ════════════════════════════════════════════════════════════════ +// Estado "ainda não" — nunca um cartaz de erro no lugar de conteúdo +// ════════════════════════════════════════════════════════════════ + +function SemFoto({ erro }: { erro: string | null }) { + return ( + +

Capital de giro começa no primeiro mês fechado

+

+ A foto do giro nasce quando você fecha o mês — é ela que guarda o quanto o seu caixa + afundou, quando afundou e quanto isso custou. Feche um mês na Apuração e esta tela + passa a existir. +

+ {erro &&

Detalhe técnico: {erro}

} +
+ ); +} + +// ════════════════════════════════════════════════════════════════ +// HERÓI — o custo (permanente) + a variação (o que muda todo mês) +// ════════════════════════════════════════════════════════════════ + +function Heroi({ dto }: { dto: GiroViewDTO }) { + const { custo, variacao } = dto; + if (!custo) return null; + + return ( +
+
+ + {brl(custo.total)} + +

+ por ano é o que o seu capital de giro custa hoje + {custo.pctLucro != null && ( + <> + {" — "} + {pct(custo.pctLucro)} do lucro do + período + + )} + . É deságio de antecipação mais juros de empréstimo de giro: dinheiro que sai porque a + operação imobiliza caixa. +

+
+ + {variacao && ( +

+ {/* Os SINAIS ficam visíveis. A versão anterior usava Math.abs nos três + números e a frase virava "caiu R$ 51 mil: R$ 21 mil ... e R$ 72 mil" — + 21 + 72 = 93, não 51. O dono faz a conta de cabeça e ela falha, na + tela cuja premissa é não fabricar nada. */} + Em {mesLongo(dto.escopo.mesRef)}, o faturamento{" "} + + {variacao.efeitoVolume >= 0 ? "somou" : "tirou"}{" "} + {brl(Math.abs(variacao.efeitoVolume))} + {" "} + ao buraco do mês — isso acompanha a venda — e o descasamento{" "} + + {variacao.efeitoEficiencia >= 0 ? "piorou e somou" : "melhorou e tirou"}{" "} + {brl(Math.abs(variacao.efeitoEficiencia))} + + {variacao.deltaDias !== 0 && <> ({dias(variacao.deltaDias, { sinal: true })} na régua)}. No + líquido, o buraco{" "} + + {variacao.total >= 0 ? "subiu" : "caiu"}{" "} + + {brl(Math.abs(variacao.total))} + + + . A segunda parcela é a que dá pra mexer. +

+ )} +
+ ); +} + +// ════════════════════════════════════════════════════════════════ +// BLOCO 3 — por que falta dinheiro no meio do mês +// ════════════════════════════════════════════════════════════════ + +function BlocoPerfil({ dto }: { dto: GiroViewDTO }) { + const { perfil, descasamento, buraco } = dto; + if (perfil.length === 0) return null; + // O sinal decide a HISTÓRIA, não só o número: paga-antes é aperto a financiar, + // recebe-antes é o mês trabalhando a favor. Um texto só para os dois casos + // fabricaria um problema onde não há (achado no check contra dado real). + const pagaAntes = (descasamento?.dias ?? 0) > 0; + + return ( + +

Entradas e saídas ao longo do mês

+

+ Fonte: extrato bancário, fluxo + operacional — sem antecipação, aporte nem empréstimo. Média dos {dto.escopo.janela} meses + fechados até {mesLongo(dto.escopo.mesRef)}. +

+ +
+ +
+ + {descasamento && ( +

+ Seu dinheiro sai em média no dia{" "} + {Math.round(descasamento.diaMedioSaida)} e{" "} + entra em média no dia{" "} + {Math.round(descasamento.diaMedioEntrada)}.{" "} + {pagaAntes ? ( + <> + Você paga{" "} + {dias(Math.abs(descasamento.dias))} antes{" "} + de receber — e é esse descasamento, de calendário e não de resultado, que precisa ser + financiado todo mês. + + ) : ( + <> + Você recebe{" "} + {dias(Math.abs(descasamento.dias))} antes{" "} + de pagar: o calendário do mês trabalha a seu + favor. O que trava caixa aqui não é o descasamento — é o volume que passa pela + operação. + + )} +

+ )} + + {buraco && + (buraco.exibivel ? ( +
+ + {brl(buraco.valor)} + +

+ é a queda máxima típica do caixa dentro do mês, + contra o saldo de abertura — mediana de {buraco.meses} meses + {buraco.valeDiaTipico != null && <>, em geral por volta do dia {buraco.valeDiaTipico}}. + {buraco.dias != null && ( + <> Vale {dias(buraco.dias)} de faturamento. + )} +

+
+ ) : ( +

+ A queda máxima do mês não é exibida. Só{" "} + {pct(dto.frescor.bancosCobertosPct)} das suas contas bancárias têm dado neste mês, e um + vale medido sobre parte das contas sai menor do que o real — é o erro que passa + despercebido. Suba os extratos que faltam e o número aparece. +

+ ))} +
+ ); +} + +// ════════════════════════════════════════════════════════════════ +// BLOCO 4 — crescer custa caixa, você tem? +// ════════════════════════════════════════════════════════════════ + +function BlocoCrescimento({ dto }: { dto: GiroViewDTO }) { + const c = dto.crescimento; + if (!c) return null; + if (c.modo === "teto") return ; + const cabe = c.gap <= 0; + + return ( + +

Crescer custa caixa — você tem?

+

+ Base: a queda máxima do caixa dentro do + mês ({brl(dto.buraco?.valor ?? 0)}), medida no seu extrato, contra{" "} + {c.origem === "meta_travada" ? ( + <> + a meta de +{pct(c.crescimentoAlvo * 100)} de + faturamento em 12 meses travada no Plano de Voo + + ) : ( + <> + o crescimento que você já vem + entregando — +{pct(c.crescimentoAlvo * 100)} nos últimos 12 meses, medido, porque não + há meta travada no Plano de Voo + + )} + . Assume o mesmo padrão de entrada e saída: se crescer dando prazo maior, leva os dois golpes + juntos. +

+ +
+ + a mais de caixa afundando dentro do mês, todo mês, para sustentar esse ritmo + + + {brl(c.caixaLivreHoje)} livres hoje + {brl(c.geracaoMensal)}/mês que a operação gera + + + {cabe ? ( + <>cabe nos 12 meses — nenhum mês fica negativo + ) : ( + <> + e falta a partir do {c.mesDoGap}º mês do + plano + + )} + +
+ +

+ Como ler: cada barra é a sobra de caixa + daquele mês já descontando o giro que o + crescimento vai pedir. Enquanto a barra está para cima, o plano cabe no seu dinheiro. +

+
+ proximoMes(dto.escopo.mesRef, p.mes))} /> +
+ + {c.tetoMensal != null && ( +

+ Sua operação autofinancia{" "} + {pct(c.tetoMensal * 100)} de crescimento ao mês — + perto de {pct(c.tetoMensal * 12 * 100)} ao ano. Acima disso, crescer exige dinheiro que não + vem da operação. +

+ )} + + {/* §13.2 — o N0 responde a pergunta, mas diz o que ele NÃO enxerga */} +

+ O que esta conta ainda não enxerga. O extrato mostra + só a oscilação dentro do mês. O dinheiro que fica preso o ano inteiro — o cliente que + ainda não pagou, a mercadoria na prateleira — nunca passa pelo banco, e costuma ser bem maior + que o buraco mensal. Três números no fechamento mostram o outro lado. +

+
+ ); +} + +/** + * Bloco 4, modo TETO — quando não há meta travada nem crescimento no histórico. + * + * A pergunta inverte: em vez de *"sua meta pede X, você tem?"*, vira *"quanto dá + * pra crescer com o caixa que eu já gero?"*. É o teto do §10.3, um número que + * sempre existe — e é a pergunta certa para o ICP da constituição, que é a empresa + * estagnada, não a que já cresce. + */ +function BlocoTeto({ + dto, + c, +}: { + dto: GiroViewDTO; + c: Extract, { modo: "teto" }>; +}) { + const MOTIVO: Record = { + sem_meta: "Você ainda não travou uma meta no Plano de Voo", + meta_sem_crescimento: "Sua meta do Plano de Voo está sem crescimento de faturamento", + sem_crescimento_historico: "Seu faturamento está estável nos últimos 12 meses", + }; + const geraCaixa = c.geracaoMensal > 0; + // Anualizar COMPONDO, não × 12: 14% ao mês não é 166% ao ano, é 380%. + const tetoAnual = Math.pow(1 + c.tetoMensal, 12) - 1; + // Acima de 5% ao mês o número anualizado vira uma cifra que ninguém acredita — + // e cifra inacreditável queima a confiança na tela inteira. Acima do limiar a + // resposta certa não é um número maior, é a leitura: o giro não é o gargalo. + const tetoFolgado = c.tetoMensal >= 0.05; + + return ( + +

Crescer custa caixa — quanto você aguenta?

+

+ {MOTIVO[c.motivo]}, então a pergunta aqui é a + outra ponta: com o caixa que a operação gera hoje, quanto dá pra crescer sem buscar dinheiro de + fora. Base: o buraco típico do mês ({brl(c.grandeza)}) contra {brl(c.geracaoMensal)}/mês de + geração operacional, em {mesLongo(dto.escopo.mesRef)}. +

+ + {geraCaixa ? ( + <> +
+ + {tetoFolgado ? "de sobra" : `${pct(c.tetoMensal * 100)} ao mês`} + +

+ {tetoFolgado ? ( + <> + é o caixa que a operação gera perto do que o giro consome:{" "} + o capital de giro não é o que + limita o seu crescimento hoje. O limite está na venda, não no caixa + parado. + + ) : ( + <> + é o quanto você pode crescer{" "} + se autofinanciando — cerca de{" "} + {pct(tetoAnual * 100)} ao ano. Acima disso, cada real a mais de venda pede + caixa que a operação não produz. + + )} +

+
+

+ Na prática: cada +10% de venda pede{" "} + {brl(c.custoPor10Pct)} a mais de caixa afundando dentro do mês. Com{" "} + {brl(c.caixaLivreHoje)} livres hoje, isso é o que decide se o crescimento cabe ou pede + dinheiro de fora. +

+ + ) : ( +

+ Hoje a operação não autofinancia crescimento + nenhum. Ela consome {brl(Math.abs(c.geracaoMensal))} por mês em vez de gerar — então + qualquer aumento de venda vai pedir dinheiro de fora, não sobra da própria operação. O teto + volta a existir quando a geração virar positiva. +

+ )} + +

+ Trave uma meta no Plano de Voo e este bloco vira a + conta completa: quanto ela vai pedir, quanto você tem, quanto falta e a partir de que mês. +

+
+ ); +} + +function Degrau({ + n, + rotulo, + valor, + tom = "neutro", + children, +}: { + n: number; + rotulo: string; + valor: string; + tom?: "neutro" | "bom" | "ruim"; + children: React.ReactNode; +}) { + const caixa = + tom === "ruim" + ? "border-danger/30 bg-danger/10" + : tom === "bom" + ? "border-success/25 bg-success/10" + : "border-border-subtle bg-black/25"; + const cor = tom === "ruim" ? "text-danger" : tom === "bom" ? "text-success" : "text-text-main"; + + return ( +
+ + {n} + +
+ + {rotulo} + + + {valor} + + {children} +
+
+ ); +} + +/** A curva do §10 é indexada 1..12 a partir do mês de referência. */ +function proximoMes(mesRef: string, offset: number): string { + const [a, m] = mesRef.split("-").map(Number); + const d = new Date(a, m - 1 + offset, 1); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; +} + +// ════════════════════════════════════════════════════════════════ +// BLOCO 6 — a conta detalhada +// ════════════════════════════════════════════════════════════════ + +function BlocoCusto({ dto }: { dto: GiroViewDTO }) { + const c = dto.custo; + if (!c || c.total <= 0) return null; + + return ( + +

O que isso te custa

+

+ Composição: {brl(c.desagio)} de deságio de + antecipação + {brl(c.juros)} de juros de empréstimo com finalidade de capital de giro, nos{" "} + {c.janelaMeses} meses fechados até {mesLongo(dto.escopo.mesRef)}. Número exato — sai do + fechamento, não de estimativa. +

+
+ + {brl(c.total)} + +

+ por ano + {c.pctReceita != null && <> — {pct(c.pctReceita)} do faturamento} + {c.pctLucro != null && ( + <> + {" e "} + {pct(c.pctLucro)} do lucro + + )} + . É o preço de financiar o capital de giro por fora, e ele cai na medida em que o + descasamento do mês diminui. +

+
+ + {dto.cobertura && ( +
+

Como você banca isso hoje

+
+ + + +
+

+ Retrovisor: é o dinheiro que já entrou neste mês para tapar o buraco. Antecipação é venda + já feita puxada com deságio; aporte é falta de faturamento; empréstimo é dívida. +

+
+ )} +
+ ); +} + +function LinhaCobertura({ rotulo, valor }: { rotulo: string; valor: number }) { + return ( +
+ {rotulo} + {brl(valor)} +
+ ); +} + +// ════════════════════════════════════════════════════════════════ +// CAPTURA — o convite nasce do "por quê?" que o N0 deixou aberto +// ════════════════════════════════════════════════════════════════ + +function ConviteCaptura({ dto }: { dto: GiroViewDTO }) { + if (dto.nivel >= 1) return null; + return ( +
+

+ Três números e esta tela passa a medir o seu capital de giro +

+

+ Hoje ela mede o que passou pelo banco. O que ainda + não passou — quanto os clientes te devem, quanto você deve a fornecedor, quanto tem parado + em estoque — só você sabe, e são três saldos que você olha em um minuto. +

+ {/* Sem borda/fundo de campo: com eles os itens viravam inputs vazios + com cara de clicáveis, e não são — a captura acontece no fechamento, + não aqui. Prometer interação que não existe é mentira de UI. */} +
    + {[ + "Quanto te devem no último dia do mês", + "Quanto você deve a fornecedores", + "Quanto tem de estoque, a preço de compra", + ].map((t) => ( +
  • + + {t} +
  • + ))} +
+

+ Vão ser perguntados no fechamento do mês, junto do que você já responde — custam cerca de 30 + segundos e não criam ritual novo. +

+
+ ); +} + +// ════════════════════════════════════════════════════════════════ +// RODAPÉ — frescor, sempre visível, nunca escondido em chip +// ════════════════════════════════════════════════════════════════ + +function Rodape({ dto }: { dto: GiroViewDTO }) { + const { frescor } = dto; + return ( +
+ + Foto de {mesLongo(dto.escopo.mesRef)} + {frescor.fechadoEm && <> · congelada em {new Date(frescor.fechadoEm).toLocaleDateString("pt-BR")}} + + {frescor.bancosCobertosPct != null && ( + + {pct(frescor.bancosCobertosPct)} das contas + bancárias + + )} + {frescor.pctCategorizado != null && ( + + {pct(frescor.pctCategorizado)} categorizado + + )} + {frescor.desatualizado && ( + + ⚠ Este mês mudou depois de ter sido fechado — o número acima era verdade quando congelou. + + )} +
+ ); +} diff --git a/src/lib/giro/crescimento.test.ts b/src/lib/giro/crescimento.test.ts new file mode 100644 index 0000000..85b8d7e --- /dev/null +++ b/src/lib/giro/crescimento.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect } from "vitest"; +import { + cadeiaDoCrescimento, + ordenarFontes, + precificarGap, + alavancas, + type Fonte, +} from "./crescimento"; + +/** Cenário do mockup v5, Nível 1: capital preso de R$ 819.800 sobre receita de R$ 848.000. */ +const N1 = { + grandeza: 819800, + receitaMensal: 848000, + crescimentoAlvo: 0.3, + caixaLivreHoje: 88000, + geracaoMensal: 9400, +}; + +describe("cadeiaDoCrescimento — §10, os três degraus", () => { + it("degrau 1: crescer 30% pede ~R$ 246 mil a mais", () => { + const c = cadeiaDoCrescimento(N1)!; + expect(Math.round(c.capitalAdicional / 1000)).toBe(246); + }); + + it("degrau 2: caixa livre + geração no horizonte", () => { + const c = cadeiaDoCrescimento(N1)!; + expect(c.capacidade).toBe(88000 + 9400 * 12); + expect(Math.round(c.capacidade / 1000)).toBe(201); + }); + + it("degrau 3: falta R$ 45 mil, e falta a partir do 9º mês", () => { + const c = cadeiaDoCrescimento(N1)!; + expect(Math.round(c.gap / 1000)).toBe(45); + expect(c.mesDoGap).toBe(9); + }); + + it("o crescimento é COMPOSTO — por isso o estouro cai no meio, não no fim", () => { + const c = cadeiaDoCrescimento(N1)!; + const meio = c.curva[5].necessidadeAdicional; + const linear = c.capitalAdicional / 2; + expect(meio).toBeLessThan(linear); // composto sobe devagar no começo + expect(c.mesDoGap!).toBeLessThan(12); + }); + + it("a curva tem um ponto por mês do horizonte e a folga fecha com o gap", () => { + const c = cadeiaDoCrescimento(N1)!; + expect(c.curva).toHaveLength(12); + expect(c.curva[11].folga).toBeCloseTo(-c.gap, 6); + }); + + it("N0 (buraco do mês) roda a mesma conta e CABE — é o argumento de ingestão", () => { + // a mesma meta, medida sobre a grandeza que o extrato enxerga + const c0 = cadeiaDoCrescimento({ ...N1, grandeza: 118000 })!; + expect(c0.gap).toBeLessThan(0); // cabe + expect(c0.mesDoGap).toBeNull(); + // e o capital preso é ~7× maior: é o que o N1 revela + const c1 = cadeiaDoCrescimento(N1)!; + expect(c1.capitalAdicional / c0.capitalAdicional).toBeCloseTo(819800 / 118000, 4); + }); + + it("usa o saldo projetado quando existe, em vez da reta", () => { + const projetado = new Array(12).fill(0).map((_, i) => 88000 + 20000 * (i + 1)); + const c = cadeiaDoCrescimento({ ...N1, saldoProjetado: projetado })!; + expect(c.capacidade).toBe(projetado[11]); + expect(c.mesDoGap).toBeNull(); // com mais caixa projetado, cabe + }); + + it("teto autofinanciável: a operação banca ~1,15% ao mês", () => { + const c = cadeiaDoCrescimento(N1)!; + expect(c.tetoMensal! * 100).toBeCloseTo(1.15, 2); + }); + + it("grandeza zero não vira teto infinito", () => { + expect(cadeiaDoCrescimento({ ...N1, grandeza: 0 })!.tetoMensal).toBeNull(); + }); + + it("empresa em queima (geração negativa) tem o estouro mais cedo", () => { + const c = cadeiaDoCrescimento({ ...N1, geracaoMensal: -5000 })!; + expect(c.mesDoGap!).toBeLessThan(9); + }); + + it("sem receita não há cadeia", () => { + expect(cadeiaDoCrescimento({ ...N1, receitaMensal: 0 })).toBeNull(); + }); + + it("crescimento zero não pede capital nenhum", () => { + const c = cadeiaDoCrescimento({ ...N1, crescimentoAlvo: 0 })!; + expect(c.capitalAdicional).toBeCloseTo(0, 6); + expect(c.mesDoGap).toBeNull(); + }); +}); + +describe("ordenarFontes — §10.4.2, a ordem É o conselho", () => { + const fontes: Fonte[] = [ + { tipo: "externa", id: "ant", rotulo: "Antecipação", taxaMensal: 0.031, custoMensalDoGap: 1395, procedencia: "desagio_l65" }, + { tipo: "interna", id: "pagar", rotulo: "Pagar depois", libera: 93000, deltaDias: 3 }, + { tipo: "externa", id: "bx", rotulo: "Banco X", taxaMensal: 0.021, custoMensalDoGap: 945, procedencia: "loan_contract" }, + { tipo: "interna", id: "receber", rotulo: "Receber antes", libera: 141000, deltaDias: 5 }, + ]; + + it("de dentro SEMPRE antes de de fora", () => { + const o = ordenarFontes(fontes); + expect(o.map((f) => f.tipo)).toEqual(["interna", "interna", "externa", "externa"]); + }); + + it("internas por quanto liberam; externas da mais barata pra mais cara", () => { + const o = ordenarFontes(fontes); + expect(o.map((f) => f.id)).toEqual(["receber", "pagar", "bx", "ant"]); + }); + + it("a fonte externa não tem campo de quanto libera — §10.4.1, o schema não sabe", () => { + const externa = fontes.find((f) => f.tipo === "externa")!; + expect("libera" in externa).toBe(false); + }); +}); + +describe("precificarGap", () => { + it("precifica o buraco na taxa da fonte", () => { + expect(Math.round(precificarGap(45078, 0.021))).toBe(947); + }); + + it("gap que cabe não custa nada", () => { + expect(precificarGap(-165000, 0.031)).toBe(0); + }); +}); + +describe("alavancas — §11", () => { + const insumo = { + receitaMensal: 848000, + comprasMensal: 556000, + cmvMensal: 530000, + taxaEfetivaAnual: 0.0716, + }; + + it("as três alavancas, com R$ liberado e dias que saem da régua", () => { + const a = alavancas(insumo, { receber: 5, pagar: 5, estoque: 3 }); + expect(a.map((x) => x.id)).toEqual(["receber_antes", "pagar_depois", "girar_estoque"]); + expect(Math.round(a[0].liberaCaixa / 1000)).toBe(141); + expect(Math.round(a[0].deltaDias)).toBe(5); // receber N dias antes tira N dias + }); + + it("'crescer X%' NÃO é alavanca — saiu daqui e virou a cadeia (D16)", () => { + const a = alavancas(insumo, { receber: 5, pagar: 5, estoque: 3 }); + expect(a.map((x) => String(x.id))).not.toContain("crescer"); + }); + + it("alavanca sem insumo NÃO VEM no array — não vem desabilitada (§11.3)", () => { + const semEstoque = alavancas( + { ...insumo, cmvMensal: null }, + { receber: 5, pagar: 5, estoque: 3 }, + ); + expect(semEstoque.map((x) => x.id)).toEqual(["receber_antes", "pagar_depois"]); + + const soReceber = alavancas( + { ...insumo, cmvMensal: null, comprasMensal: null }, + { receber: 5, pagar: 5, estoque: 3 }, + ); + expect(soReceber.map((x) => x.id)).toEqual(["receber_antes"]); + }); + + it("sem taxa efetiva, a economia é null em vez de zero fabricado", () => { + const a = alavancas({ ...insumo, taxaEfetivaAnual: null }, { receber: 5 }); + expect(a[0].economiaAno).toBeNull(); + }); + + it("dias zero ou negativos não geram alavanca", () => { + expect(alavancas(insumo, { receber: 0, pagar: -3 })).toHaveLength(0); + }); + + it("sem receita não há alavanca — a régua não existe", () => { + expect(alavancas({ ...insumo, receitaMensal: 0 }, { receber: 5 })).toHaveLength(0); + }); +}); diff --git a/src/lib/giro/crescimento.ts b/src/lib/giro/crescimento.ts new file mode 100644 index 0000000..74f6cbb --- /dev/null +++ b/src/lib/giro/crescimento.ts @@ -0,0 +1,232 @@ +/** + * Capital de Giro — G1: a cadeia do crescimento e as alavancas. + * + * Núcleo PURO. Spec: `capital-de-giro-spec.md` §10 (crescer → tenho? → falta → de + * onde tiro) e §11 (as três alavancas). + * + * Este arquivo é a decisão D16 (2026-08-17) em código: a pergunta do crescimento + * deixou de ser um slider escondido no simulador e virou a cadeia inteira, que + * termina em **número, mês e caminho**. + */ + +import { DIAS_DO_MES } from "./regua"; + +// ============================================================ +// §10 — A CADEIA +// ============================================================ + +export interface InsumoCadeia { + /** A grandeza do nível vigente: buraco do mês (N0) ou capital preso (N1+). */ + grandeza: number; + receitaMensal: number; + /** Crescimento alvo no horizonte, em fração (0,30 = +30% em 12 meses). */ + crescimentoAlvo: number; + caixaLivreHoje: number; + /** Geração operacional média por mês (pode ser negativa: empresa em queima). */ + geracaoMensal: number; + horizonteMeses?: number; + /** + * Saldo acumulado projetado mês a mês, quando existir — vem de + * `annual-projection.ts` (`ProjecaoAnual.pontos[].saldoAcumulado`), que já embute + * sazonalidade e compromissos datados. Sem ele, cai na reta + * `caixaLivreHoje + geracaoMensal × m`, que é mais pobre mas honesta. + */ + saldoProjetado?: number[]; +} + +export interface PontoFolga { + /** 1..horizonte. */ + mes: number; + necessidadeAdicional: number; + disponivel: number; + /** disponivel − necessidadeAdicional. Negativo ⟹ não cabe. */ + folga: number; +} + +export interface Cadeia { + /** Degrau 1: quanto o crescimento vai pedir a mais, no fim do horizonte. */ + capitalAdicional: number; + /** Degrau 2: caixa livre hoje + geração no horizonte. */ + capacidade: number; + /** Degrau 3: `> 0` ⟹ FALTA esse tanto. `≤ 0` ⟹ cabe. */ + gap: number; + /** Primeiro mês (1..horizonte) em que a folga vira negativa. `null` ⟹ cabe sempre. */ + mesDoGap: number | null; + curva: PontoFolga[]; + /** §10.3 — crescimento autofinanciável, em fração ao mês. `null` se não calculável. */ + tetoMensal: number | null; +} + +/** + * A cadeia dos três degraus. + * + * O crescimento é COMPOSTO ao longo do horizonte (`(1+alvo)^(1/12)` ao mês), não + * linear: a necessidade sobe junto com a receita, mês a mês, e é por isso que o mês + * do estouro cai no meio do plano e não no fim. + * + * **Premissa declarada em voz alta na tela (§10.3): ciclo constante.** Se o dono + * cresce dando prazo maior para ganhar a venda, leva os dois golpes juntos — mais + * volume E mais dias — e esta conta subestima. + */ +export function cadeiaDoCrescimento(i: InsumoCadeia): Cadeia | null { + const horizonte = i.horizonteMeses ?? 12; + if (horizonte <= 0) return null; + if (i.receitaMensal <= 0) return null; + if (!Number.isFinite(i.grandeza)) return null; + + const razao = i.grandeza / i.receitaMensal; + const fatorMensal = Math.pow(1 + i.crescimentoAlvo, 1 / horizonte); + + const curva: PontoFolga[] = []; + let mesDoGap: number | null = null; + + for (let m = 1; m <= horizonte; m++) { + const receitaNoMes = i.receitaMensal * Math.pow(fatorMensal, m); + const necessidadeAdicional = razao * receitaNoMes - i.grandeza; + const disponivel = i.saldoProjetado?.[m - 1] ?? i.caixaLivreHoje + i.geracaoMensal * m; + const folga = disponivel - necessidadeAdicional; + if (folga < 0 && mesDoGap === null) mesDoGap = m; + curva.push({ mes: m, necessidadeAdicional, disponivel, folga }); + } + + const ultimo = curva[curva.length - 1]; + return { + capitalAdicional: ultimo.necessidadeAdicional, + capacidade: ultimo.disponivel, + gap: -ultimo.folga, + mesDoGap, + curva, + // Teto (§10.3): quanto a operação banca sozinha por mês. Grandeza ≤ 0 não tem + // teto — não há giro para financiar, e dividir por ela produziria um infinito + // apresentado como capacidade de crescimento. + tetoMensal: i.grandeza > 0 ? i.geracaoMensal / i.grandeza : null, + }; +} + +// ============================================================ +// §10.4 — DE ONDE TIRAR +// ============================================================ + +/** + * Fonte INTERNA: mexer no próprio ciclo. Não custa nada e é a única que melhora a + * régua — por isso carrega `deltaDias`. + */ +export interface FonteInterna { + tipo: "interna"; + id: string; + rotulo: string; + libera: number; + /** Dias que saem da régua (positivo = quantos dias caem). */ + deltaDias: number; +} + +/** + * Fonte EXTERNA: dinheiro de fora, ao preço que o dono JÁ paga. + * + * ⚠️ **Repare no que este tipo NÃO tem: um campo de quanto ela libera.** Isso é + * proposital e está travado no §10.4.1 — `loan_contracts` não tem coluna de limite + * contratado nem de limite disponível (conferido na baseline). A tela pode dizer + * *quanto custa*; não pode dizer *quanto você ainda pode tomar*. Sem o campo no tipo, + * ninguém "completa" isso depois sem antes mudar a spec. + */ +export interface FonteExterna { + tipo: "externa"; + id: string; + rotulo: string; + /** Fração ao mês (0,031 = 3,1% a.m.). */ + taxaMensal: number; + /** O que o gap custaria por mês nesta fonte. */ + custoMensalDoGap: number; + procedencia: "desagio_l65" | "loan_contract"; +} + +export type Fonte = FonteInterna | FonteExterna; + +/** + * Ordena as fontes pelo que cada real custa — e a ordem É o conselho (§10.4.2). + * + * De dentro SEMPRE antes de de fora, mesmo quando de dentro libera menos: a alavanca + * interna é a única que **melhora a régua**. Tomar dinheiro de fora banca o mesmo + * giro ruim por mais tempo e volta no mês seguinte como custo maior sem melhora de + * dias. + */ +export function ordenarFontes(fontes: Fonte[]): Fonte[] { + const internas = fontes + .filter((f): f is FonteInterna => f.tipo === "interna") + .sort((a, b) => b.libera - a.libera); + const externas = fontes + .filter((f): f is FonteExterna => f.tipo === "externa") + .sort((a, b) => a.taxaMensal - b.taxaMensal); + return [...internas, ...externas]; +} + +/** Precifica o gap numa fonte externa. Gap ≤ 0 (cabe) ⟹ custo zero. */ +export function precificarGap(gap: number, taxaMensal: number): number { + return gap > 0 ? gap * taxaMensal : 0; +} + +// ============================================================ +// §11 — AS ALAVANCAS +// ============================================================ + +export type AlavancaId = "receber_antes" | "pagar_depois" | "girar_estoque"; + +export interface Alavanca { + id: AlavancaId; + dias: number; + liberaCaixa: number; + /** Dias que saem da régua — sempre contra a RECEITA (§5.3.1). */ + deltaDias: number; + /** `null` quando não há taxa efetiva (sem custo de giro medido). */ + economiaAno: number | null; +} + +export interface InsumoAlavancas { + receitaMensal: number; + /** `null` ⟹ a alavanca de pagar não existe (§11.3: não se simula o não medido). */ + comprasMensal: number | null; + /** `null` ⟹ a alavanca de estoque não existe. */ + cmvMensal: number | null; + /** Fração ao ano, de `taxaEfetivaAnual()`. */ + taxaEfetivaAnual: number | null; +} + +/** + * As três alavancas (§11.1). "Crescer X%" NÃO está aqui — ela nunca foi uma alavanca, + * é a pergunta, e mora em `cadeiaDoCrescimento` (D16). + * + * **Alavanca sem insumo não vem no array** — não vem desabilitada, não vem com `null` + * (§11.3). Slider cinza é a tela dizendo "você está incompleto"; alavanca ausente é a + * tela funcionando com o que tem. + */ +export function alavancas( + i: InsumoAlavancas, + dias: { receber?: number; pagar?: number; estoque?: number }, +): Alavanca[] { + const out: Alavanca[] = []; + if (i.receitaMensal <= 0) return out; + + const montar = (id: AlavancaId, n: number, baseMensal: number): Alavanca => { + const liberaCaixa = n * (baseMensal / DIAS_DO_MES); + return { + id, + dias: n, + liberaCaixa, + // Contra a receita, sempre — é o que faz o Δ da alavanca falar a mesma + // língua da régua da manchete. + deltaDias: (liberaCaixa / i.receitaMensal) * DIAS_DO_MES, + economiaAno: i.taxaEfetivaAnual != null ? liberaCaixa * i.taxaEfetivaAnual : null, + }; + }; + + if (dias.receber && dias.receber > 0) { + out.push(montar("receber_antes", dias.receber, i.receitaMensal)); + } + if (dias.pagar && dias.pagar > 0 && i.comprasMensal != null && i.comprasMensal > 0) { + out.push(montar("pagar_depois", dias.pagar, i.comprasMensal)); + } + if (dias.estoque && dias.estoque > 0 && i.cmvMensal != null && i.cmvMensal > 0) { + out.push(montar("girar_estoque", dias.estoque, i.cmvMensal)); + } + return out; +} diff --git a/src/lib/giro/custo.test.ts b/src/lib/giro/custo.test.ts new file mode 100644 index 0000000..f12412d --- /dev/null +++ b/src/lib/giro/custo.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from "vitest"; +import { + custoDoGiro, + ehContratoDeGiro, + taxaEfetivaAnual, + FINALIDADES_DE_GIRO, + MODALIDADES_DE_GIRO, +} from "./custo"; + +describe("ehContratoDeGiro — quais dívidas contam", () => { + it("as DUAS finalidades de giro do código vivo contam", () => { + expect(ehContratoDeGiro({ finalidade: "capital_giro_operacao" })).toBe(true); + expect(ehContratoDeGiro({ finalidade: "cobrir_aperto_caixa" })).toBe(true); + expect(FINALIDADES_DE_GIRO.size).toBe(2); + }); + + it("dívida por outro motivo NÃO infla a manchete do giro", () => { + for (const f of ["comprar_ativo", "expansao", "refinanciar_divida", "emergencia", "outro"]) { + expect(ehContratoDeGiro({ finalidade: f })).toBe(false); + } + }); + + // ── O bug de 58% achado no check ao vivo do G2 (2026-08-17) ────────── + it("REGRESSÃO: modalidade conta mesmo com finalidade NULL", () => { + // Em produção, `finalidade` está NULL em 100% dos contratos. Filtrar só por + // ela media R$ 0 de juros num cliente que paga R$ 272.804/ano. + expect(ehContratoDeGiro({ finalidade: null, modalidade: "capital_giro" })).toBe(true); + expect(ehContratoDeGiro({ finalidade: null, modalidade: "cheque_especial" })).toBe(true); + expect(MODALIDADES_DE_GIRO.size).toBe(2); + }); + + it("modalidade que NÃO é giro continua fora", () => { + for (const m of ["finame", "cartao_bndes", "leasing", "financiamento_veiculos", "cdc"]) { + expect(ehContratoDeGiro({ finalidade: null, modalidade: m })).toBe(false); + } + }); + + it("antecipação e desconto de duplicata ficam FORA — já entram pelo deságio", () => { + // Contar as duas pontas dobraria o mesmo dinheiro. Inflar a manchete é pior + // que perder um pedaço: o dono confere e para de confiar no número. + expect(ehContratoDeGiro({ finalidade: null, modalidade: "antecipacao_cartao" })).toBe(false); + expect(ehContratoDeGiro({ finalidade: null, modalidade: "desconto_duplicatas" })).toBe(false); + }); + + it("é OU, não E: qualquer um dos dois sinais basta", () => { + expect(ehContratoDeGiro({ finalidade: "capital_giro_operacao", modalidade: "finame" })).toBe(true); + expect(ehContratoDeGiro({ finalidade: "comprar_ativo", modalidade: "capital_giro" })).toBe(true); + }); + + it("contrato sem sinal nenhum não conta", () => { + expect(ehContratoDeGiro({ finalidade: null, modalidade: null })).toBe(false); + expect(ehContratoDeGiro(null)).toBe(false); + expect(ehContratoDeGiro(undefined)).toBe(false); + }); +}); + +describe("custoDoGiro — §5.4, a manchete permanente", () => { + const base = { desagio12m: 47900, jurosGiro12m: 10800, receita12m: 10176000, lucro12m: 112900 }; + + it("soma deságio + juros e dá as três leituras (dados do mockup)", () => { + const c = custoDoGiro(base); + expect(c.total).toBe(58700); + expect(c.desagio).toBe(47900); + expect(c.juros).toBe(10800); + expect(Math.round(c.pctLucro!)).toBe(52); // "52% do seu lucro" + }); + + it("empresa NO PREJUÍZO não recebe '% do lucro' — seria número sem significado (I8)", () => { + expect(custoDoGiro({ ...base, lucro12m: -50000 }).pctLucro).toBeNull(); + expect(custoDoGiro({ ...base, lucro12m: 0 }).pctLucro).toBeNull(); + }); + + it("mas continua vendo o R$/ano e o % da receita", () => { + const c = custoDoGiro({ ...base, lucro12m: -50000 }); + expect(c.total).toBe(58700); + expect(c.pctReceita).not.toBeNull(); + }); + + it("sem receita no período, sem % da receita", () => { + expect(custoDoGiro({ ...base, receita12m: 0 }).pctReceita).toBeNull(); + }); + + it("custo zero é resposta legítima — quem não antecipa nem deve não paga giro", () => { + const c = custoDoGiro({ desagio12m: 0, jurosGiro12m: 0, receita12m: 100, lucro12m: 50 }); + expect(c.total).toBe(0); + expect(c.pctLucro).toBe(0); + }); +}); + +describe("taxaEfetivaAnual — §11.1, a taxa que ele paga de verdade", () => { + it("é derivada do gasto, não de tabela de banco", () => { + expect(taxaEfetivaAnual(58700, 819800)).toBeCloseTo(0.0716, 4); + }); + + it("sem necessidade média não há taxa", () => { + expect(taxaEfetivaAnual(58700, 0)).toBeNull(); + expect(taxaEfetivaAnual(58700, -1)).toBeNull(); + }); +}); diff --git a/src/lib/giro/custo.ts b/src/lib/giro/custo.ts new file mode 100644 index 0000000..a36dc89 --- /dev/null +++ b/src/lib/giro/custo.ts @@ -0,0 +1,124 @@ +/** + * Capital de Giro — G1: o custo do giro (a manchete permanente). + * + * Núcleo PURO. Spec: `capital-de-giro-spec.md` §5.4 e §11.1 (taxa efetiva). + * + * É o único número da tela que é EXATO e não estimado: sai de `dre_monthly_snapshots` + * (deságio isolado em L65 pela decomposição sintética) e de `loan_contracts`. + */ + +export interface InsumoCustoGiro { + /** Σ da subcategoria `custo_antecipacao` (deságio) nos 12 meses fechados. */ + desagio12m: number; + /** Σ dos juros de contratos com finalidade de giro nos 12 meses fechados. */ + jurosGiro12m: number; + receita12m: number; + lucro12m: number; +} + +export interface CustoGiro { + total: number; + desagio: number; + juros: number; + /** `null` quando não há receita no período. */ + pctReceita: number | null; + /** `null` quando o lucro é ≤ 0 — ver nota abaixo. */ + pctLucro: number | null; +} + +/** + * Finalidades de contrato que contam como custo DE GIRO — o POR QUÊ declarado. + * + * A spec §5.4 fala em "finalidade capital de giro", no singular. O código vivo tem + * DUAS que qualificam (`loan-contracts-finalidades.ts:11-12`), e as duas são dívida + * tomada por causa do giro — uma é o crônico, a outra é o agudo: + * - `capital_giro_operacao` → "Capital de giro da operação" + * - `cobrir_aperto_caixa` → "Cobrir um aperto de caixa" + * + * Ficam de FORA `comprar_ativo`, `expansao`, `refinanciar_divida` e `emergencia`: + * são dívida por outro motivo, e imputá-las ao giro infla a manchete. + */ +export const FINALIDADES_DE_GIRO: ReadonlySet = new Set([ + "capital_giro_operacao", + "cobrir_aperto_caixa", +]); + +/** + * Modalidades que contam como custo DE GIRO — o QUE o produto é. + * + * ⚠️ **Esta lista existe por causa de um achado no check ao vivo do G2 (2026-08-17), + * e ela é a diferença entre a manchete certa e menos da metade dela.** + * + * A spec §5.4 mandava filtrar por `finalidade`. No banco de produção, `finalidade` + * está **NULL em 100% dos contratos** — ninguém preenche, porque é uma pergunta de + * intenção feita depois do fato. Filtrando só por ela, a Vertímetal media: + * + * deságio R$ 201.390 + juros R$ 0 = R$ 201.390/ano + * + * quando o real era **R$ 474.194/ano** — dois contratos `modalidade='capital_giro'` + * pagando R$ 272.804 de juros em 12 meses, invisíveis. Erro *pra menos* de 58% na + * manchete permanente da tela, que é justamente a direção que ninguém percebe. + * + * `modalidade` é o que o produto É, e vem preenchida porque sai do cadastro do + * contrato. Vale como evidência independente da intenção declarada. + * + * **`desconto_duplicatas` e `antecipacao_cartao` ficam de FORA de propósito:** o + * custo delas já chega pelo deságio do L65 (`custo_antecipacao`). Contar as duas + * pontas dobraria o mesmo dinheiro — e inflar a manchete é pior que perder um + * pedaço, porque destrói a confiança no número quando o dono confere. + */ +export const MODALIDADES_DE_GIRO: ReadonlySet = new Set([ + "capital_giro", + "cheque_especial", +]); + +export interface ContratoParaGiro { + finalidade?: string | null; + modalidade?: string | null; +} + +/** + * Um contrato é de giro se a **finalidade declarada** OU a **modalidade do produto** + * disserem que é. É um OU, não um E: exigir os dois zera na prática (a finalidade + * quase nunca está preenchida), e exigir só a finalidade foi o bug de 58%. + */ +export function ehContratoDeGiro(contrato: ContratoParaGiro | null | undefined): boolean { + if (!contrato) return false; + const { finalidade, modalidade } = contrato; + if (finalidade != null && FINALIDADES_DE_GIRO.has(finalidade)) return true; + if (modalidade != null && MODALIDADES_DE_GIRO.has(modalidade)) return true; + return false; +} + +/** + * O custo do giro em três leituras — R$/ano, % do faturamento, % do lucro — porque é + * a terceira que dá a dimensão ("52% do seu lucro"). + * + * **`pctLucro` é `null` quando o lucro é ≤ 0, e isso é deliberado.** "O giro custa + * −180% do seu lucro" não significa nada para ninguém, e um número sem significado + * apresentado com ar de medição é exatamente o que o I8 proíbe. Empresa no prejuízo vê + * o R$/ano e o % da receita; o % do lucro simplesmente não aparece. + */ +export function custoDoGiro(i: InsumoCustoGiro): CustoGiro { + const desagio = Math.max(0, i.desagio12m); + const juros = Math.max(0, i.jurosGiro12m); + const total = desagio + juros; + return { + total, + desagio, + juros, + pctReceita: i.receita12m > 0 ? (total / i.receita12m) * 100 : null, + pctLucro: i.lucro12m > 0 ? (total / i.lucro12m) * 100 : null, + }; +} + +/** + * A taxa que o dono EFETIVAMENTE paga pelo giro, derivada do que ele já gastou — + * não uma taxa de tabela (§11.1). Devolve fração ao ano (0,28 = 28% a.a.). + * + * É ela que precifica as alavancas ("isto economiza R$ X/ano") e o gap do §10.4. + */ +export function taxaEfetivaAnual(custo12m: number, necessidadeMedia: number): number | null { + if (necessidadeMedia <= 0 || custo12m < 0) return null; + return custo12m / necessidadeMedia; +} diff --git a/src/lib/giro/formato.test.ts b/src/lib/giro/formato.test.ts new file mode 100644 index 0000000..e6081ca --- /dev/null +++ b/src/lib/giro/formato.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from "vitest"; +import { brl, dias, pct, taxaMensal, mesLongo, mesCurto } from "./formato"; + +describe("brl — §13.5, a régua de arredondamento", () => { + it("≥ R$ 10 mil vira milhar cheio", () => { + expect(brl(819800)).toBe("R$ 820 mil"); + expect(brl(45078)).toBe("R$ 45 mil"); + expect(brl(10000)).toBe("R$ 10 mil"); + }); + + it("R$ 1 mil a 10 mil ganha uma casa — ali ela ainda informa", () => { + expect(brl(9400)).toBe("R$ 9,4 mil"); + expect(brl(1400)).toBe("R$ 1,4 mil"); + }); + + it("casa zerada não vira '9,0 mil'", () => { + expect(brl(9000)).toBe("R$ 9 mil"); + }); + + it("< R$ 1 mil em unidade", () => { + expect(brl(945)).toBe("R$ 945"); + expect(brl(0)).toBe("R$ 0"); + }); + + it("NUNCA centavos — nem em valor quebrado", () => { + for (const v of [819800.47, 9400.99, 945.5, 12.34]) { + expect(brl(v)).not.toMatch(/,\d\d(?!\d)\s*$/); + expect(brl(v)).not.toContain(",00"); + } + }); + + it("negativo usa o menos tipográfico, não o hífen", () => { + expect(brl(-45078)).toBe("− R$ 45 mil"); + }); + + it("sinal explícito só quando pedido", () => { + expect(brl(45078, { sinal: true })).toBe("+ R$ 45 mil"); + expect(brl(45078)).toBe("R$ 45 mil"); + }); + + it("ausência vira travessão, nunca R$ 0 — zero e 'não sei' são coisas diferentes", () => { + expect(brl(null)).toBe("—"); + expect(brl(undefined)).toBe("—"); + expect(brl(Number.NaN)).toBe("—"); + expect(brl(0)).toBe("R$ 0"); + }); + + it("separador de milhar é o brasileiro", () => { + expect(brl(1_234_000)).toBe("R$ 1.234 mil"); + }); +}); + +describe("dias — a régua", () => { + it("sempre inteiro", () => { + expect(dias(29.51)).toBe("30 dias"); + expect(dias(3.4)).toBe("3 dias"); + }); + + it("singular quando é 1", () => { + expect(dias(1)).toBe("1 dia"); + }); + + it("negativo é melhora — sinal explícito", () => { + expect(dias(-10)).toBe("− 10 dias"); + }); + + it("com sinal, alta ganha o mais", () => { + expect(dias(4, { sinal: true })).toBe("+ 4 dias"); + }); + + it("sem dado, travessão", () => { + expect(dias(null)).toBe("—"); + }); +}); + +describe("pct", () => { + it("inteiro acima de 10%", () => { + expect(pct(52)).toBe("52%"); + expect(pct(52.4)).toBe("52%"); + }); + + it("uma casa abaixo de 10%, onde ela ainda informa", () => { + expect(pct(3.14)).toBe("3,1%"); + }); + + it("sem dado, travessão — não zero", () => { + expect(pct(null)).toBe("—"); + }); +}); + +describe("taxaMensal", () => { + it("fração vira % a.m. com uma casa", () => { + expect(taxaMensal(0.021)).toBe("2,1% a.m."); + expect(taxaMensal(0.031)).toBe("3,1% a.m."); + }); +}); + +describe("mesLongo / mesCurto", () => { + it("formata sem passar por Date — mês é string, não instante", () => { + expect(mesLongo("2026-07")).toBe("jul/2026"); + expect(mesCurto("2026-01")).toBe("jan"); + expect(mesCurto("2026-12")).toBe("dez"); + }); + + it("não escorrega de mês por fuso (o bug clássico do new Date('2026-01'))", () => { + expect(mesLongo("2026-01")).toBe("jan/2026"); + expect(mesLongo("2026-03")).toBe("mar/2026"); + }); +}); diff --git a/src/lib/giro/formato.ts b/src/lib/giro/formato.ts new file mode 100644 index 0000000..32bf834 --- /dev/null +++ b/src/lib/giro/formato.ts @@ -0,0 +1,77 @@ +/** + * Capital de Giro — a régua de arredondamento da tela (spec §13.5). + * + * Existe como módulo, e não como helper solto em cada componente, porque a regra é + * do PRODUTO, não da apresentação: metade da sensação de "planilha de analista" + * vinha de exibir sete dígitos num número que o dono digitou de cabeça. Um lugar só + * decide, e tem teste. + * + * Nada aqui arredonda para GRAVAR — o snapshot guarda o número cru (§8.2). Isto é + * exclusivamente exibição. + */ + +/** Onde o milhar cheio começa. Abaixo disso, uma casa; acima, zero casas. */ +const CORTE_MILHAR_CHEIO = 10_000; + +/** + * R$ na régua do §13.5: + * ≥ R$ 10 mil → milhar cheio `R$ 820 mil` + * R$ 1 mil–10 mil → uma casa `R$ 9,4 mil` + * < R$ 1 mil → unidade `R$ 945` + * + * O corte é em R$ 10 mil e não em R$ 100 mil de propósito: `R$ 45,1 mil` num número + * que é projeção é exatamente a casa decimal que faz a tela parecer planilha. + * `R$ 45 mil` é o que o dono repete em voz alta. + * + * **Nunca centavos.** Em nenhum bloco, tooltip ou tabela. + */ +export function brl(valor: number | null | undefined, opts: { sinal?: boolean } = {}): string { + if (valor == null || !Number.isFinite(valor)) return "—"; + const neg = valor < 0; + const v = Math.abs(valor); + const prefixo = neg ? "− " : opts.sinal ? "+ " : ""; + + if (v >= CORTE_MILHAR_CHEIO) { + return `${prefixo}R$ ${Math.round(v / 1000).toLocaleString("pt-BR")} mil`; + } + if (v >= 1000) { + const s = (v / 1000).toFixed(1).replace(".", ","); + return `${prefixo}R$ ${s.endsWith(",0") ? s.slice(0, -2) : s} mil`; + } + return `${prefixo}R$ ${Math.round(v).toLocaleString("pt-BR")}`; +} + +/** A régua, sempre em dias inteiros. Nunca "razão", nunca centavos (§5.3). */ +export function dias(n: number | null | undefined, opts: { sinal?: boolean } = {}): string { + if (n == null || !Number.isFinite(n)) return "—"; + const i = Math.round(n); + const prefixo = i > 0 && opts.sinal ? "+ " : i < 0 ? "− " : ""; + const abs = Math.abs(i); + return `${prefixo}${abs} ${abs === 1 ? "dia" : "dias"}`; +} + +/** Inteiro, ou uma casa só abaixo de 10% — onde a casa ainda carrega informação. */ +export function pct(n: number | null | undefined): string { + if (n == null || !Number.isFinite(n)) return "—"; + const v = Math.abs(n) < 10 ? n.toFixed(1).replace(".", ",") : String(Math.round(n)); + return `${v}%`; +} + +/** Taxa mensal com uma casa: `2,1% a.m.` */ +export function taxaMensal(fracao: number | null | undefined): string { + if (fracao == null || !Number.isFinite(fracao)) return "—"; + return `${(fracao * 100).toFixed(1).replace(".", ",")}% a.m.`; +} + +const MESES_CURTOS = ["jan", "fev", "mar", "abr", "mai", "jun", "jul", "ago", "set", "out", "nov", "dez"]; + +/** `2026-07` → `jul/2026`. Sem `new Date()`: mês é string, não instante. */ +export function mesLongo(mes: string): string { + const [ano, m] = mes.split("-"); + return `${MESES_CURTOS[Number(m) - 1] ?? "?"}/${ano}`; +} + +/** `2026-07` → `jul` — para o eixo, onde o ano é repetido e só polui. */ +export function mesCurto(mes: string): string { + return MESES_CURTOS[Number(mes.split("-")[1]) - 1] ?? "?"; +} diff --git a/src/lib/giro/giro-snapshot-service.test.ts b/src/lib/giro/giro-snapshot-service.test.ts new file mode 100644 index 0000000..429431e --- /dev/null +++ b/src/lib/giro/giro-snapshot-service.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect } from "vitest"; +import { montarSerieDoMes, extrairDesagio, somarCobertura, type LancRow } from "./giro-snapshot-service"; +import { profundidadeDoMes } from "./serie-operacional"; + +const lanc = (p: Partial & { data: string }): LancRow => ({ + credito: null, + debito: null, + categoria_id: "receita_operacional", + subcategoria_id: null, + socio_natureza: null, + conta_numero: "1234", + ...p, +}); + +const CONTAS = [{ id: "c1", conta: "1234" }]; +const ANCORA = [{ conta: "1234", valor: 100000, asOf: "2026-06-30" }]; + +describe("montarSerieDoMes — a ancoragem por mês", () => { + it("abre no saldo REAL do último dia de M−1", () => { + const s = montarSerieDoMes([], ANCORA, CONTAS, "2026-07"); + expect(s.saldoInicio).toBe(100000); + }); + + it("dentro do mês caminha SÓ com o fluxo operacional", () => { + const lancs = [ + lanc({ data: "2026-07-05", debito: 40000, categoria_id: "custos" }), + lanc({ data: "2026-07-12", credito: 50000, categoria_id: "movimentacao_patrimonial" }), + lanc({ data: "2026-07-18", debito: 30000, categoria_id: "custos" }), + ]; + const s = montarSerieDoMes(lancs, ANCORA, CONTAS, "2026-07"); + // a antecipação do dia 12 NÃO entra: 100.000 − 40.000 − 30.000 + expect(s.dias.map((d) => d.saldoFim)).toEqual([60000, 30000]); + }); + + it("é exatamente o socorro que mascararia o buraco (§5.1)", () => { + const lancs = [ + lanc({ data: "2026-07-05", debito: 40000, categoria_id: "custos" }), + lanc({ data: "2026-07-12", credito: 50000, categoria_id: "movimentacao_patrimonial" }), + lanc({ data: "2026-07-18", debito: 30000, categoria_id: "custos" }), + ]; + const s = montarSerieDoMes(lancs, ANCORA, CONTAS, "2026-07"); + const prof = profundidadeDoMes( + [ + { data: "2026-06-30", entrada: 0, saida: 0, saldoFim: s.saldoInicio! }, + ...s.dias, + ], + "2026-07", + )!; + expect(prof.profundidade).toBe(70000); // o buraco REAL + expect(prof.valeDia).toBe(18); + // com o socorro dentro, o vale seria 80.000 e o buraco só 20.000 — pra menos + }); + + it("juros e deságio (L65) também ficam fora da caminhada", () => { + const lancs = [ + lanc({ data: "2026-07-05", debito: 10000, categoria_id: "despesas_financeiras" }), + lanc({ data: "2026-07-06", debito: 10000, categoria_id: "custos" }), + ]; + const s = montarSerieDoMes(lancs, ANCORA, CONTAS, "2026-07"); + expect(s.dias).toHaveLength(1); + expect(s.dias[0].saldoFim).toBe(90000); + }); + + it("lançamento de OUTRA conta não entra na âncora daquela conta", () => { + const lancs = [lanc({ data: "2026-06-15", credito: 999999, conta_numero: "9999" })]; + const s = montarSerieDoMes(lancs, ANCORA, CONTAS, "2026-07"); + expect(s.saldoInicio).toBe(100000); + }); + + it("reporta a cobertura bancária — conta sem âncora conta no denominador (§16)", () => { + const s = montarSerieDoMes([], ANCORA, [...CONTAS, { id: "c2", conta: "5678" }], "2026-07"); + expect(s.cobertura).toEqual([1, 2]); + }); + + it("sem âncora nenhuma, não inventa saldo de abertura", () => { + const s = montarSerieDoMes([], [], CONTAS, "2026-07"); + expect(s.saldoInicio).toBeNull(); + }); + + it("vira o ano sem quebrar (dez → jan)", () => { + const s = montarSerieDoMes( + [lanc({ data: "2027-01-10", debito: 5000, categoria_id: "custos" })], + [{ conta: "1234", valor: 50000, asOf: "2026-12-31" }], + CONTAS, + "2027-01", + ); + expect(s.saldoInicio).toBe(50000); + expect(s.dias[0].saldoFim).toBe(45000); + }); +}); + +describe("extrairDesagio — o sintético que não existe em transactions", () => { + /** Forma real de `dre_detalhado` (DREMultiMensal, extrato-processor.ts:557). */ + const detalhado = { + categorias: [ + { + id: "custos", + subcategorias: [{ id: "cmv", total: 500000 }], + }, + { + id: "despesas_financeiras", + subcategorias: [ + { id: "juros_emprestimos", total: 900 }, + { id: "custo_antecipacao", total: 47900 }, + ], + }, + ], + }; + + it("acha o deságio dentro da linha de despesas financeiras", () => { + expect(extrairDesagio(detalhado)).toBe(47900); + }); + + it("devolve valor absoluto — o sinal da despesa não vira crédito", () => { + expect( + extrairDesagio({ + categorias: [{ subcategorias: [{ id: "custo_antecipacao", total: -47900 }] }], + }), + ).toBe(47900); + }); + + it("mês sem antecipação dá zero, não erro", () => { + expect(extrairDesagio({ categorias: [{ subcategorias: [{ id: "cmv", total: 1 }] }] })).toBe(0); + }); + + it("jsonb ausente ou de outra forma não explode", () => { + expect(extrairDesagio(null)).toBe(0); + expect(extrairDesagio({})).toBe(0); + expect(extrairDesagio({ categorias: [{}] })).toBe(0); + expect(extrairDesagio("nada disso")).toBe(0); + }); +}); + +describe("somarCobertura — §5.6, como ele bancou o buraco", () => { + it("decompõe em antecipação × aporte × empréstimo", () => { + const c = somarCobertura([ + lanc({ data: "2026-07-12", credito: 190000, categoria_id: "movimentacao_patrimonial", subcategoria_id: "recebimento_antecipacao" }), + lanc({ data: "2026-07-15", credito: 50000, categoria_id: "movimentacao_patrimonial", subcategoria_id: "socio_aporte" }), + lanc({ data: "2026-07-20", credito: 80000, categoria_id: "movimentacao_patrimonial", subcategoria_id: "captacao_emprestimo" }), + ]); + expect(c).toEqual({ antecipacao: 190000, aporte: 50000, emprestimo: 80000 }); + }); + + it("aporte por socio_natureza também conta", () => { + const c = somarCobertura([ + lanc({ data: "2026-07-15", credito: 30000, categoria_id: "movimentacao_patrimonial", socio_natureza: "aporte" }), + ]); + expect(c.aporte).toBe(30000); + }); + + it("distribuição e imobilizado NÃO são cobertura — não sustentam caixa", () => { + const c = somarCobertura([ + lanc({ data: "2026-07-28", debito: 60000, categoria_id: "movimentacao_patrimonial", subcategoria_id: "socio_distribuicao" }), + lanc({ data: "2026-07-29", debito: 40000, categoria_id: "movimentacao_patrimonial", subcategoria_id: "compra_imobilizado" }), + ]); + expect(c).toEqual({ antecipacao: 0, aporte: 0, emprestimo: 0 }); + }); + + it("lançamento operacional não vira cobertura", () => { + const c = somarCobertura([lanc({ data: "2026-07-02", credito: 500000, categoria_id: "receita_operacional" })]); + expect(c).toEqual({ antecipacao: 0, aporte: 0, emprestimo: 0 }); + }); +}); diff --git a/src/lib/giro/giro-snapshot-service.ts b/src/lib/giro/giro-snapshot-service.ts new file mode 100644 index 0000000..9d4eef2 --- /dev/null +++ b/src/lib/giro/giro-snapshot-service.ts @@ -0,0 +1,574 @@ +/** + * Capital de Giro — G2: a foto mensal. + * + * Camada de I/O do giro: busca os insumos, chama o núcleo puro (`./regua`, + * `./serie-operacional`, `./custo`) e grava uma linha em `giro_monthly_snapshots`. + * Toda a matemática mora no núcleo; aqui só tem busca, mapeamento e escrita. + * + * Spec: `docs/atros-v3/capital-de-giro-spec.md` §8. Roda no `after()` do + * `fechar-mes`, DEPOIS de `reconciliarCarteiraNoFechamento` (§8.3) — para que a + * carteira do mês já esteja baixada quando o CR medido for lido (G5). + * + * **G2 grava o Nível 0.** As colunas das pernas (`cr_valor`, `cp_valor`, + * `estoque_valor`) e seus derivados ficam nulas até o G4/G5. Nulo é resposta + * honesta; zero seria mentira (§24.4). + */ +import type { SupabaseClient } from "@supabase/supabase-js"; +import { reconstruirSerieDiaria, consolidarHistorico, type MovimentoCaixa } from "@/lib/treasury/saldo-diario"; +import { + filtrarOperacional, + profundidadeDoMes, + perfilCalendario, + type DiaOperacional, +} from "./serie-operacional"; +import { razaoGiro, decompor } from "./regua"; +import { ehContratoDeGiro } from "./custo"; + +/** Quantos meses o perfil de calendário resume (§5.5: "média dos últimos 6"). */ +const JANELA_PERFIL = 6; +/** Janela do custo do giro (§5.4: 12 meses fechados). */ +const JANELA_CUSTO = 12; +/** Teto de linhas por página no PostgREST — acima disso ele trunca EM SILÊNCIO. */ +const PAGINA = 1000; + +export interface ResultadoSnapshotGiro { + gravado: boolean; + version: number | null; + /** Por que não gravou, quando não gravou. Nunca lança para não derrubar o fechamento. */ + motivo?: string; + avisos: string[]; +} + +// ============================================================ +// HELPERS DE DATA (string 'YYYY-MM-DD', TZ-safe — padrão da casa) +// ============================================================ + +function primeiroDia(mes: string): string { + return `${mes}-01`; +} +function ultimoDia(mes: string): string { + const [a, m] = mes.split("-").map(Number); + return `${mes}-${String(new Date(a, m, 0).getDate()).padStart(2, "0")}`; +} +function mesAnterior(mes: string, n = 1): string { + const [a, m] = mes.split("-").map(Number); + const d = new Date(a, m - 1 - n, 1); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; +} +function menor(a: string, b: string): string { + return a < b ? a : b; +} +function maior(a: string, b: string): string { + return a > b ? a : b; +} + +// ============================================================ +// BUSCA +// ============================================================ + +interface ContaRow { + id: string; + conta: string | null; +} + +export interface LancRow { + data: string; + credito: number | null; + debito: number | null; + categoria_id: string | null; + subcategoria_id: string | null; + socio_natureza: string | null; + conta_numero: string | null; +} + +/** + * Paginação obrigatória: o PostgREST devolve no máximo 1000 linhas e **não avisa** + * que truncou. Uma janela de 12 meses de extrato passa disso com folga, e o + * resultado seria um vale medido sobre metade do movimento — errado pra menos. + */ +async function buscarTudo( + montarQuery: (de: number, ate: number) => PromiseLike<{ data: T[] | null; error: unknown }>, +): Promise { + const out: T[] = []; + for (let pagina = 0; ; pagina++) { + const de = pagina * PAGINA; + const { data, error } = await montarQuery(de, de + PAGINA - 1); + if (error || !data) break; + out.push(...data); + if (data.length < PAGINA) break; + } + return out; +} + +/** Contas bancárias no escopo do projeto — mesma regra do `saldo-diario-service`. */ +async function resolverContas(sb: SupabaseClient, projectId: string): Promise { + const { data: proj } = await sb + .from("projects") + .select("id, client_id, company_id") + .eq("id", projectId) + .single(); + if (!proj) return []; + + let q = sb.from("bank_accounts").select("id, conta"); + if (proj.company_id) { + q = q.eq("company_id", proj.company_id); + } else { + const { data: comps } = await sb.from("companies").select("id").eq("client_id", proj.client_id); + const ids = (comps ?? []).map((c) => c.id as string); + if (ids.length === 0) return []; + q = q.in("company_id", ids); + } + const { data } = await q; + return (data ?? []) as ContaRow[]; +} + +// ============================================================ +// A SÉRIE OPERACIONAL DO MÊS +// ============================================================ + +export interface SerieDoMes { + /** Saldo REAL de fechamento do último dia de M−1 (a âncora do mês). */ + saldoInicio: number | null; + /** Dias de M com o saldo OPERACIONAL acumulado a partir de `saldoInicio`. */ + dias: DiaOperacional[]; + /** Contas com âncora / total de contas — a cobertura bancária (§16). */ + cobertura: [number, number]; +} + +/** + * Monta a série operacional do mês. + * + * **A ancoragem é por mês, e isso é deliberado.** O saldo de abertura vem da série + * REAL (todos os movimentos, inclusive o socorro) no último dia de M−1; dentro do + * mês, caminha só com o fluxo OPERACIONAL. Assim o nível continua sendo dinheiro de + * verdade — dá para ler "sem a antecipação do dia 12, teria ficado negativo" — mas a + * queda medida é a de antes do socorro (§5.1). Reconstruir a série inteira sem + * movpat a partir de uma âncora distante faria o nível derivar mês a mês. + */ +export function montarSerieDoMes( + lancamentos: LancRow[], + ancoras: Array<{ conta: string; valor: number; asOf: string }>, + contas: ContaRow[], + mes: string, +): SerieDoMes { + const fimMesAnterior = ultimoDia(mesAnterior(mes)); + + // ── série REAL, multi-conta, para achar o saldo de abertura ────────── + const series = ancoras.map((anc) => { + const movs: MovimentoCaixa[] = lancamentos + .filter((l) => l.conta_numero === anc.conta) + .map((l) => ({ data: l.data, credito: l.credito, debito: l.debito })); + return reconstruirSerieDiaria({ + ancora: { valor: anc.valor, asOf: anc.asOf, rotulo: "confirmado" }, + movimentos: movs, + ate: ultimoDia(mes), + }); + }); + + const real = series.length > 0 ? consolidarHistorico(series) : null; + const pontoAbertura = real?.pontos.filter((p) => p.data <= fimMesAnterior).at(-1) ?? null; + const saldoInicio = pontoAbertura ? pontoAbertura.saldoFim : null; + + // ── caminhada OPERACIONAL dentro do mês ────────────────────────────── + const doMes = filtrarOperacional( + lancamentos + .filter((l) => l.data >= primeiroDia(mes) && l.data <= ultimoDia(mes)) + .map((l) => ({ + data: l.data, + credito: l.credito, + debito: l.debito, + categoriaId: l.categoria_id, + })), + ); + + const porDia = new Map(); + for (const l of doMes) { + const d = porDia.get(l.data) ?? { entrada: 0, saida: 0 }; + d.entrada += Number(l.credito) || 0; + d.saida += Number(l.debito) || 0; + porDia.set(l.data, d); + } + + let acumulado = saldoInicio ?? 0; + const dias: DiaOperacional[] = []; + for (const data of [...porDia.keys()].sort()) { + const d = porDia.get(data)!; + acumulado += d.entrada - d.saida; + dias.push({ data, entrada: d.entrada, saida: d.saida, saldoFim: acumulado }); + } + + return { + saldoInicio, + dias, + cobertura: [ancoras.length, contas.length], + }; +} + +// ============================================================ +// O SNAPSHOT +// ============================================================ + +/** + * Tira a foto do giro do mês e grava. Idempotente por versão: re-fechar o mês cria + * `version + 1` e a anterior fica intacta (§8.3). + * + * **Nunca lança.** Roda em `after()`, depois da resposta do fechar-mes — uma falha + * aqui não pode derrubar um fechamento que já foi confirmado ao cliente. + */ +export async function snapshotGiro( + sb: SupabaseClient, + projectId: string, + mesRef: string, +): Promise { + const avisos: string[] = []; + try { + const contas = await resolverContas(sb, projectId); + if (contas.length === 0) { + return { gravado: false, version: null, motivo: "projeto sem conta bancária", avisos }; + } + + // ── âncoras (uma por conta; conta sem âncora não entra na série) ── + const ancoras: Array<{ conta: string; valor: number; asOf: string }> = []; + for (const c of contas) { + if (c.conta == null) continue; + const { data: anc } = await sb + .from("bank_account_balance_anchors") + .select("balance_amount, as_of") + .eq("bank_account_id", c.id) + .eq("balance_type", "ledger") + .order("as_of", { ascending: false }) + .limit(1) + .maybeSingle(); + if (anc) { + ancoras.push({ + conta: c.conta, + valor: Number(anc.balance_amount), + asOf: String(anc.as_of).slice(0, 10), + }); + } + } + + const coberturaPct = contas.length > 0 ? (ancoras.length / contas.length) * 100 : 0; + if (coberturaPct < 100) { + // §16: o vale não é exibido com cobertura incompleta — erraria PRA MENOS. + avisos.push( + `cobertura bancária ${ancoras.length}/${contas.length}: o vale do mês não é confiável`, + ); + } + + // ── janela de busca: cobre o perfil (6m), o mês, e a âncora ─────── + const inicioPerfil = primeiroDia(mesAnterior(mesRef, JANELA_PERFIL - 1)); + const fimMes = ultimoDia(mesRef); + const asOfs = ancoras.map((a) => a.asOf); + const de = asOfs.reduce((acc, x) => menor(acc, x), inicioPerfil); + const ate = asOfs.reduce((acc, x) => maior(acc, x), fimMes); + + const lancamentos = await buscarTudo((d, a) => + sb + .from("transactions") + .select("data, credito, debito, categoria_id, subcategoria_id, socio_natureza, conta_numero") + .eq("project_id", projectId) + .eq("tipo_origem", "conta_bancaria") + .gte("data", de) + .lte("data", ate) + .order("data", { ascending: true }) + .range(d, a), + ); + const norm = lancamentos.map((l) => ({ ...l, data: String(l.data).slice(0, 10) })); + + const serie = montarSerieDoMes(norm, ancoras, contas, mesRef); + const prof = profundidadeDoMes( + [ + ...(serie.saldoInicio !== null + ? [ + { + data: ultimoDia(mesAnterior(mesRef)), + entrada: 0, + saida: 0, + saldoFim: serie.saldoInicio, + }, + ] + : []), + ...serie.dias, + ], + mesRef, + ); + + // ── DRE do mês: receita e lucro ─────────────────────────────────── + const dre = await lerDreDoMes(sb, projectId, mesRef); + if (!dre) { + return { + gravado: false, + version: null, + motivo: `sem snapshot de DRE ativo em ${mesRef} — o giro deriva dele`, + avisos, + }; + } + + const receitaMes = dre.receita; + const razaoBuraco = prof ? razaoGiro(prof.profundidade, receitaMes) : null; + + // ── custo do giro (12m) ─────────────────────────────────────────── + const custo = await somarCustoDoGiro(sb, projectId, mesRef); + + // ── cobertura do mês: como ele bancou o buraco (§5.6) ───────────── + const cob = somarCobertura( + norm.filter((l) => l.data >= primeiroDia(mesRef) && l.data <= fimMes), + ); + + // ── perfil de calendário (§5.5) ─────────────────────────────────── + const mesesPerfil = Array.from({ length: JANELA_PERFIL }, (_, i) => + mesAnterior(mesRef, JANELA_PERFIL - 1 - i), + ); + const diasOperacionaisJanela = mapearDiasOperacionais(norm, inicioPerfil, fimMes); + const perfil = perfilCalendario(diasOperacionaisJanela, mesesPerfil); + + // ── decomposição vs. o mês anterior (§9) ────────────────────────── + const anterior = await lerSnapshotAtivo(sb, projectId, mesAnterior(mesRef)); + let deltaBuraco: number | null = null; + let efeitoVolume: number | null = null; + let efeitoEficiencia: number | null = null; + if (prof && anterior?.profundidade != null && anterior.receita_mes) { + const d = decompor({ + x0: Number(anterior.profundidade), + receita0: Number(anterior.receita_mes), + x1: prof.profundidade, + receita1: receitaMes, + }); + if (d) { + deltaBuraco = d.total; + efeitoVolume = d.efeitoVolume; + efeitoEficiencia = d.efeitoEficiencia; + } + } + + // ── grava (version = anterior + 1, nunca sobrescreve) ───────────── + const { data: ultima } = await sb + .from("giro_monthly_snapshots") + .select("version") + .eq("project_id", projectId) + .eq("mes_referencia", mesRef) + .order("version", { ascending: false }) + .limit(1) + .maybeSingle(); + const version = (ultima?.version ?? 0) + 1; + + const { error } = await sb.from("giro_monthly_snapshots").insert({ + project_id: projectId, + mes_referencia: mesRef, + version, + nivel_efetivo: 0, // G2 grava o N0; as pernas chegam no G4/G5 + saldo_inicio_mes: serie.saldoInicio, + vale_valor: prof?.vale ?? null, + vale_dia: prof?.valeDia ?? null, + profundidade: prof?.profundidade ?? null, + receita_mes: receitaMes, + razao_buraco: razaoBuraco, + razao_ncg: null, + custo_giro_desagio: custo.desagio, + custo_giro_juros: custo.juros, + cobertura_antecipacao: cob.antecipacao, + cobertura_aporte: cob.aporte, + cobertura_emprestimo: cob.emprestimo, + perfil_calendario: perfil.map((p) => ({ + dia: p.dia, + entrada_pct: p.entradaPct, + saida_pct: p.saidaPct, + })), + delta_buraco: deltaBuraco, + buraco_efeito_volume: efeitoVolume, + buraco_efeito_eficiencia: efeitoEficiencia, + cobertura_bancaria_pct: coberturaPct, + pct_categorizado: dre.pctCategorizado, + avisos, + }); + + if (error) { + return { gravado: false, version: null, motivo: error.message, avisos }; + } + return { gravado: true, version, avisos }; + } catch (e) { + return { + gravado: false, + version: null, + motivo: e instanceof Error ? e.message : "erro desconhecido", + avisos, + }; + } +} + +// ============================================================ +// LEITURAS AUXILIARES +// ============================================================ + +interface DreDoMes { + receita: number; + lucro: number; + desagio: number; + pctCategorizado: number | null; +} + +/** Snapshot de DRE ATIVO do mês (maior version, não invalidado). */ +async function lerDreDoMes( + sb: SupabaseClient, + projectId: string, + mes: string, +): Promise { + const { data } = await sb + .from("dre_monthly_snapshots") + .select("kpis, linhas_dre, dre_detalhado") + .eq("project_id", projectId) + .eq("mes_referencia", mes) + .is("invalidated_at", null) + .order("version", { ascending: false }) + .limit(1) + .maybeSingle(); + if (!data) return null; + + const linhas = (data.linhas_dre ?? {}) as Record; + const kpis = (data.kpis ?? {}) as Record; + + return { + receita: Number(linhas.L10) || 0, + lucro: Number(kpis.resultado_liquido) || 0, + desagio: extrairDesagio(data.dre_detalhado), + pctCategorizado: + typeof kpis.pct_categorizado === "number" ? (kpis.pct_categorizado as number) : null, + }; +} + +/** + * O deságio é SINTÉTICO: não existe em `transactions`. Ele nasce na decomposição da + * antecipação (`dre-detalhamento/decomposicao.ts`) e só fica persistido dentro do + * `dre_detalhado` do snapshot, como uma subcategoria da linha de despesas + * financeiras. Por isso a leitura passa por aqui e não por um `sum()` no extrato. + */ +export function extrairDesagio(dreDetalhado: unknown): number { + const d = dreDetalhado as + | { categorias?: Array<{ subcategorias?: Array<{ id?: string; total?: number }> }> } + | null; + if (!d?.categorias) return 0; + for (const cat of d.categorias) { + for (const sub of cat.subcategorias ?? []) { + if (sub.id === "custo_antecipacao") return Math.abs(Number(sub.total) || 0); + } + } + return 0; +} + +async function lerSnapshotAtivo( + sb: SupabaseClient, + projectId: string, + mes: string, +): Promise<{ profundidade: number | null; receita_mes: number | null } | null> { + const { data } = await sb + .from("giro_monthly_snapshots") + .select("profundidade, receita_mes") + .eq("project_id", projectId) + .eq("mes_referencia", mes) + .is("invalidated_at", null) + .order("version", { ascending: false }) + .limit(1) + .maybeSingle(); + return data ?? null; +} + +/** Deságio (dos snapshots de DRE) + juros de contratos de giro, nos 12 meses fechados. */ +async function somarCustoDoGiro( + sb: SupabaseClient, + projectId: string, + mesRef: string, +): Promise<{ desagio: number; juros: number }> { + const meses = Array.from({ length: JANELA_CUSTO }, (_, i) => mesAnterior(mesRef, i)); + + const { data: snaps } = await sb + .from("dre_monthly_snapshots") + .select("mes_referencia, version, dre_detalhado") + .eq("project_id", projectId) + .in("mes_referencia", meses) + .is("invalidated_at", null) + .order("version", { ascending: false }); + + const vistos = new Set(); + let desagio = 0; + for (const s of (snaps ?? []) as Array<{ mes_referencia: string; dre_detalhado: unknown }>) { + if (vistos.has(s.mes_referencia)) continue; // já pegou a maior version + vistos.add(s.mes_referencia); + desagio += extrairDesagio(s.dre_detalhado); + } + + // Juros: contrato de giro por FINALIDADE declarada ou por MODALIDADE do produto + // (§5.4 + `./custo`). Filtrar só por finalidade zerava o número em produção — + // ela está NULL em 100% dos contratos. + const { data: contratos } = await sb + .from("loan_contracts") + .select("id, finalidade, modalidade") + .eq("project_id", projectId); + const idsDeGiro = (contratos ?? []) + .filter((c) => ehContratoDeGiro(c as { finalidade: string | null; modalidade: string | null })) + .map((c) => (c as { id: string }).id); + + let juros = 0; + if (idsDeGiro.length > 0) { + // `dre_detalhamento_contrato` tem colunas PLANAS (`contract_id`, `juros`) — + // conferido na baseline. Não há um jsonb `resultado`. + const { data: det } = await sb + .from("dre_detalhamento_contrato") + .select("juros, mes_referencia, contract_id") + .eq("project_id", projectId) + .in("contract_id", idsDeGiro) + .in("mes_referencia", meses); + for (const r of (det ?? []) as Array<{ juros: number | null }>) { + juros += Number(r.juros) || 0; + } + } + + return { desagio, juros }; +} + +/** Cobertura do mês, decomposta (§5.6) — mesma regra do `annual-projection-service`. */ +export function somarCobertura(lancs: LancRow[]): { + antecipacao: number; + aporte: number; + emprestimo: number; +} { + let antecipacao = 0; + let aporte = 0; + let emprestimo = 0; + for (const t of lancs) { + if (t.categoria_id !== "movimentacao_patrimonial") continue; + const v = (Number(t.credito) || 0) - (Number(t.debito) || 0); + const sub = t.subcategoria_id; + const nat = t.socio_natureza; + if (sub === "recebimento_antecipacao" || sub === "captacao_antecipacao") antecipacao += v; + else if (nat === "aporte" || sub === "socio_aporte") aporte += v; + else if (sub === "captacao_emprestimo" || nat === "mutuo" || sub === "socio_emprestimo_entrada") + emprestimo += v; + // distribuição e imobilizado não são cobertura de caixa. + } + return { antecipacao, aporte, emprestimo }; +} + +/** Agrega os lançamentos OPERACIONAIS por dia — insumo do perfil de calendário. */ +function mapearDiasOperacionais(lancs: LancRow[], de: string, ate: string): DiaOperacional[] { + const op = filtrarOperacional( + lancs + .filter((l) => l.data >= de && l.data <= ate) + .map((l) => ({ + data: l.data, + credito: l.credito, + debito: l.debito, + categoriaId: l.categoria_id, + })), + ); + const porDia = new Map(); + for (const l of op) { + const d = porDia.get(l.data) ?? { entrada: 0, saida: 0 }; + d.entrada += Number(l.credito) || 0; + d.saida += Number(l.debito) || 0; + porDia.set(l.data, d); + } + return [...porDia.entries()] + .sort(([a], [b]) => (a < b ? -1 : 1)) + .map(([data, v]) => ({ data, entrada: v.entrada, saida: v.saida, saldoFim: 0 })); +} diff --git a/src/lib/giro/giro-view-service.test.ts b/src/lib/giro/giro-view-service.test.ts new file mode 100644 index 0000000..d9f8c8d --- /dev/null +++ b/src/lib/giro/giro-view-service.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from "vitest"; +import { apenasAtivos, lerPerfil, descasamentoDoMes, tendenciaHistorica } from "./giro-view-service"; +import type { PerfilDia } from "./serie-operacional"; + +describe("apenasAtivos — a foto ativa de cada mês", () => { + const linha = (mes: string, version: number) => + ({ mes_referencia: mes, version }) as never; + + it("fica com a maior version de cada mês", () => { + const r = apenasAtivos([linha("2026-07", 1), linha("2026-07", 3), linha("2026-07", 2)]); + expect(r).toHaveLength(1); + expect((r[0] as unknown as { version: number }).version).toBe(3); + }); + + it("devolve em ordem cronológica, não na ordem que veio", () => { + const r = apenasAtivos([linha("2026-07", 1), linha("2026-05", 1), linha("2026-06", 1)]); + expect(r.map((x) => (x as unknown as { mes_referencia: string }).mes_referencia)).toEqual([ + "2026-05", + "2026-06", + "2026-07", + ]); + }); +}); + +describe("lerPerfil — jsonb nunca é confiável sem olhar", () => { + it("lê a forma que o G2 grava", () => { + const p = lerPerfil([ + { dia: 5, entrada_pct: 10, saida_pct: 40 }, + { dia: 1, entrada_pct: 2, saida_pct: 3 }, + ]); + expect(p).toEqual([ + { dia: 1, entradaPct: 2, saidaPct: 3 }, + { dia: 5, entradaPct: 10, saidaPct: 40 }, + ]); + }); + + it("descarta dia fora de 1..31 em vez de plotar lixo", () => { + expect(lerPerfil([{ dia: 0 }, { dia: 32 }, { dia: "x" }])).toEqual([]); + }); + + it("jsonb ausente ou de outra forma não explode", () => { + expect(lerPerfil(null)).toEqual([]); + expect(lerPerfil({})).toEqual([]); + expect(lerPerfil("nada")).toEqual([]); + }); + + it("campo faltando vira zero, não NaN", () => { + expect(lerPerfil([{ dia: 3 }])).toEqual([{ dia: 3, entradaPct: 0, saidaPct: 0 }]); + }); +}); + +describe("descasamentoDoMes — o sinal decide a história", () => { + const perfil = (pares: Array<[number, number, number]>): PerfilDia[] => + pares.map(([dia, entradaPct, saidaPct]) => ({ dia, entradaPct, saidaPct })); + + it("paga antes de receber ⇒ POSITIVO (o aperto clássico)", () => { + // saída concentrada no dia 5, entrada no dia 25 + const d = descasamentoDoMes(perfil([[5, 0, 100], [25, 100, 0]]))!; + expect(d.diaMedioSaida).toBe(5); + expect(d.diaMedioEntrada).toBe(25); + expect(d.dias).toBe(20); + }); + + it("REGRESSÃO: recebe antes de pagar ⇒ NEGATIVO, e a tela não pode inventar aperto", () => { + // o caso real da Vertímetal, que derrubou a métrica anterior + const d = descasamentoDoMes(perfil([[5, 100, 0], [25, 0, 100]]))!; + expect(d.dias).toBe(-20); + expect(d.dias).toBeLessThan(0); + }); + + it("mês casado dá zero", () => { + expect(descasamentoDoMes(perfil([[10, 50, 50], [20, 50, 50]]))!.dias).toBe(0); + }); + + it("pondera pelo peso, não conta dias", () => { + // 90% da saída no dia 2 puxa a média para perto do 2, não para o meio + const d = descasamentoDoMes(perfil([[2, 0, 90], [30, 100, 10]]))!; + expect(d.diaMedioSaida).toBeCloseTo(4.8, 1); + expect(d.diaMedioEntrada).toBe(30); + }); + + it("um lado zerado devolve null — não há descasamento a afirmar", () => { + expect(descasamentoDoMes(perfil([[10, 100, 0]]))).toBeNull(); + expect(descasamentoDoMes([])).toBeNull(); + }); +}); + +describe("tendenciaHistorica — o fallback do bloco 4 é medido, não arbitrado", () => { + it("cresceu 20% ⇒ 0,20", () => { + expect(tendenciaHistorica(1200, 1000)).toBeCloseTo(0.2, 9); + }); + + it("encolheu ⇒ null: projetar giro sobre queda responde pergunta que ninguém fez", () => { + expect(tendenciaHistorica(800, 1000)).toBeNull(); + }); + + it("sem histórico anterior ⇒ null", () => { + expect(tendenciaHistorica(1000, 0)).toBeNull(); + }); +}); diff --git a/src/lib/giro/giro-view-service.ts b/src/lib/giro/giro-view-service.ts new file mode 100644 index 0000000..9948489 --- /dev/null +++ b/src/lib/giro/giro-view-service.ts @@ -0,0 +1,493 @@ +/** + * Capital de Giro — G3: o DTO da tela. + * + * **Um serviço só monta a tela inteira** (spec §24.1). Nenhum componente busca dado + * por conta própria — foi exatamente assim que a tela velha acabou com dois motores + * de ciclo discordando (`investigacao_motores_dre`, mesmo padrão). + * + * Roda no SERVIDOR: o bloco do crescimento precisa de `montarProjecaoAnualParaProjeto`, + * que é server-only. A tela consome via `GET /api/projetos/[id]/giro`. + * + * **Nada aqui recalcula o passado.** O que é histórico vem congelado dos snapshots + * (§24.2); só o que deriva do presente — a meta, a projeção de caixa — é vivo. + */ +import type { SupabaseClient } from "@supabase/supabase-js"; +import { montarProjecaoAnualParaProjeto } from "@/lib/treasury/annual-projection-service"; +import { lerMeta } from "@/lib/pacing/pacing-service"; +import { custoDoGiro, type CustoGiro } from "./custo"; +import { cadeiaDoCrescimento, type Cadeia } from "./crescimento"; +import { buracoRecorrente, type PerfilDia, type ProfundidadeMes } from "./serie-operacional"; +import { DIAS_DO_MES } from "./regua"; + +export type Janela = 6 | 12; + +export interface PontoSerie { + mes: string; + profundidade: number | null; + /** A régua, em dias inteiros — já derivada, para nenhum componente decidir a unidade. */ + dias: number | null; + receita: number | null; + ncg: number | null; + diasNcg: number | null; + desatualizado: boolean; +} + +export interface GiroViewDTO { + escopo: { projectId: string; mesRef: string; janela: Janela }; + /** `nivel: 0` enquanto as pernas não chegam (G4/G5). */ + nivel: 0 | 1 | 2; + frescor: { + fechadoEm: string | null; + bancosCobertosPct: number | null; + pctCategorizado: number | null; + desatualizado: boolean; + avisos: string[]; + }; + custo: (CustoGiro & { janelaMeses: number }) | null; + variacao: { + total: number; + efeitoVolume: number; + efeitoEficiencia: number; + deltaDias: number; + } | null; + /** O buraco recorrente (mediana da janela) + o dia típico do vale. */ + buraco: { + valor: number; + banda: [number, number]; + meses: number; + dias: number | null; + valeDiaTipico: number | null; + /** §16: com cobertura bancária incompleta o vale NÃO é exibido. */ + exibivel: boolean; + } | null; + perfil: PerfilDia[]; + /** O descasamento do mês, com SINAL — ver `descasamentoDoMes`. */ + descasamento: Descasamento | null; + cobertura: { antecipacao: number; aporte: number; emprestimo: number } | null; + /** + * O bloco 4 tem DOIS modos, e o segundo existe por causa do ICP. + * + * `plano` — há meta travada (ou crescimento histórico real): roda a cadeia + * inteira (vai pedir → você tem → falta, com o mês do estouro). + * + * `teto` — não há meta nem crescimento. **O bloco NÃO some**: inverte a + * pergunta para *"quanto dá pra crescer com o caixa que eu gero?"*, que é o teto + * autofinanciável do §10.3. A constituição diz que o ICP é empresa **estagnada + * ou em prejuízo, que não cresce** — um bloco que só aparece para quem cresce + * nunca apareceria para o cliente típico, e a pergunta é ainda mais útil para + * quem está parado: é o "o que eu preciso para sair daqui". + */ + crescimento: + | ({ modo: "plano" } & Cadeia & { + origem: "meta_travada" | "tendencia_historica"; + crescimentoAlvo: number; + receitaAlvo: number; + geracaoMensal: number; + caixaLivreHoje: number; + }) + | { + modo: "teto"; + /** Fração ao mês que a operação banca sozinha. */ + tetoMensal: number; + geracaoMensal: number; + caixaLivreHoje: number; + grandeza: number; + /** Quanto CADA +10% de venda pede a mais de caixa parado. */ + custoPor10Pct: number; + /** Por que caiu neste modo — a tela declara. */ + motivo: "sem_meta" | "meta_sem_crescimento" | "sem_crescimento_historico"; + } + | null; + serie: PontoSerie[]; +} + +// ============================================================ +// LINHA DO SNAPSHOT (o que a tela lê) +// ============================================================ + +interface SnapRow { + mes_referencia: string; + version: number; + frozen_at: string; + nivel_efetivo: number; + saldo_inicio_mes: number | null; + vale_valor: number | null; + vale_dia: number | null; + profundidade: number | null; + receita_mes: number | null; + razao_buraco: number | null; + razao_ncg: number | null; + ncg: number | null; + custo_giro_desagio: number | null; + custo_giro_juros: number | null; + cobertura_antecipacao: number | null; + cobertura_aporte: number | null; + cobertura_emprestimo: number | null; + perfil_calendario: unknown; + delta_buraco: number | null; + buraco_efeito_volume: number | null; + buraco_efeito_eficiencia: number | null; + cobertura_bancaria_pct: number | null; + pct_categorizado: number | null; + avisos: unknown; + desatualizado_em: string | null; +} + +const COLUNAS = + "mes_referencia, version, frozen_at, nivel_efetivo, saldo_inicio_mes, vale_valor, vale_dia, " + + "profundidade, receita_mes, razao_buraco, razao_ncg, ncg, custo_giro_desagio, custo_giro_juros, " + + "cobertura_antecipacao, cobertura_aporte, cobertura_emprestimo, perfil_calendario, delta_buraco, " + + "buraco_efeito_volume, buraco_efeito_eficiencia, cobertura_bancaria_pct, pct_categorizado, " + + "avisos, desatualizado_em"; + +function mesAnterior(mes: string, n = 1): string { + const [a, m] = mes.split("-").map(Number); + const d = new Date(a, m - 1 - n, 1); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; +} + +/** Fica só a maior `version` de cada mês — a foto ativa (§24.3). */ +export function apenasAtivos(rows: SnapRow[]): SnapRow[] { + const porMes = new Map(); + for (const r of rows) { + const atual = porMes.get(r.mes_referencia); + if (!atual || r.version > atual.version) porMes.set(r.mes_referencia, r); + } + return [...porMes.values()].sort((a, b) => (a.mes_referencia < b.mes_referencia ? -1 : 1)); +} + +/** `perfil_calendario` é jsonb — nunca confiar na forma sem olhar. */ +export function lerPerfil(raw: unknown): PerfilDia[] { + if (!Array.isArray(raw)) return []; + const out: PerfilDia[] = []; + for (const it of raw) { + const o = it as Record; + const dia = Number(o?.dia); + if (!Number.isFinite(dia) || dia < 1 || dia > 31) continue; + out.push({ + dia, + entradaPct: Number(o?.entrada_pct) || 0, + saidaPct: Number(o?.saida_pct) || 0, + }); + } + return out.sort((a, b) => a.dia - b.dia); +} + +export interface Descasamento { + /** Dia médio ponderado em que o dinheiro entra (1..31). */ + diaMedioEntrada: number; + /** Dia médio ponderado em que o dinheiro sai. */ + diaMedioSaida: number; + /** + * `diaMedioEntrada − diaMedioSaida`, em dias. + * **Positivo** ⇒ paga antes de receber: é o aperto que precisa ser financiado. + * **Negativo** ⇒ recebe antes de pagar: o mês trabalha a favor. + */ + dias: number; +} + +/** + * O descasamento do mês, pelo **centro de massa** de cada lado. + * + * A primeira versão media "em que dia a saída passa de 50%" e o texto da tela + * assumia que saída vem antes de entrada. O check contra dado real derrubou isso: + * na Vertímetal a entrada acumula 72% até o dia 23 contra 50% da saída — ela + * **recebe antes de pagar**, e a tela teria fabricado um aperto que não existe. + * + * O centro de massa é simétrico e tem sinal: funciona nos dois sentidos e diz qual + * é. É também o número que vira ação direta ("empurrar o imposto 10 dias" mexe no + * dia médio da saída). + */ +export function descasamentoDoMes(perfil: PerfilDia[]): Descasamento | null { + if (perfil.length === 0) return null; + const totalEnt = perfil.reduce((a, p) => a + p.entradaPct, 0); + const totalSai = perfil.reduce((a, p) => a + p.saidaPct, 0); + if (totalEnt <= 0 || totalSai <= 0) return null; + + const diaMedioEntrada = perfil.reduce((a, p) => a + p.dia * p.entradaPct, 0) / totalEnt; + const diaMedioSaida = perfil.reduce((a, p) => a + p.dia * p.saidaPct, 0) / totalSai; + return { diaMedioEntrada, diaMedioSaida, dias: diaMedioEntrada - diaMedioSaida }; +} + +// ============================================================ +// O MONTADOR +// ============================================================ + +export async function montarGiroView( + sb: SupabaseClient, + projectId: string, + opts: { mesRef?: string; janela?: Janela } = {}, +): Promise { + const janela: Janela = opts.janela ?? 6; + + // ── a janela de snapshots ───────────────────────────────────────── + const { data: brutos } = await sb + .from("giro_monthly_snapshots") + .select(COLUNAS) + .eq("project_id", projectId) + .is("invalidated_at", null) + .order("mes_referencia", { ascending: false }) + .limit((janela + 2) * 3); // folga para versões repetidas do mesmo mês + + const ativos = apenasAtivos((brutos ?? []) as unknown as SnapRow[]); + if (ativos.length === 0) return null; // sem foto, sem tela (§13.5) + + const mesRef = opts.mesRef ?? ativos[ativos.length - 1].mes_referencia; + const atual = ativos.find((r) => r.mes_referencia === mesRef); + if (!atual) return null; + + const daJanela = ativos.filter( + (r) => r.mes_referencia <= mesRef && r.mes_referencia > mesAnterior(mesRef, janela), + ); + + // ── custo do giro: o snapshot já traz a soma dos 12m ────────────── + // O lucro NÃO está no snapshot de giro (só no de DRE), então o `% do lucro` + // se resolve com uma leitura a mais. Ele é derivado de exibição, não série. + // 24 meses: os 12 recentes dão receita/lucro do custo; os 12 anteriores dão a + // tendência histórica, que é o fallback do bloco de crescimento quando não há + // meta travada (§10.2) — e é medida, não chutada. + const meses12 = Array.from({ length: 12 }, (_, i) => mesAnterior(mesRef, i)); + const meses24 = Array.from({ length: 24 }, (_, i) => mesAnterior(mesRef, i)); + const { data: dres } = await sb + .from("dre_monthly_snapshots") + .select("mes_referencia, version, kpis, linhas_dre") + .eq("project_id", projectId) + .in("mes_referencia", meses24) + .is("invalidated_at", null) + .order("version", { ascending: false }); + + const vistos = new Set(); + const recentes = new Set(meses12); + let receita12 = 0; + let lucro12 = 0; + let receitaAnterior12 = 0; + for (const d of (dres ?? []) as Array<{ + mes_referencia: string; + kpis: Record; + linhas_dre: Record; + }>) { + if (vistos.has(d.mes_referencia)) continue; + vistos.add(d.mes_referencia); // já ordenado por version desc ⇒ pega a ativa + const receita = Number(d.linhas_dre?.L10) || 0; + if (recentes.has(d.mes_referencia)) { + receita12 += receita; + lucro12 += Number(d.kpis?.resultado_liquido) || 0; + } else { + receitaAnterior12 += receita; + } + } + + const desagio = Number(atual.custo_giro_desagio) || 0; + const juros = Number(atual.custo_giro_juros) || 0; + const custo = + desagio + juros > 0 || receita12 > 0 + ? { + ...custoDoGiro({ + desagio12m: desagio, + jurosGiro12m: juros, + receita12m: receita12, + lucro12m: lucro12, + }), + janelaMeses: recentes.size, + } + : null; + + // ── o buraco recorrente (mediana da janela) ─────────────────────── + const profs: ProfundidadeMes[] = daJanela + .filter((r) => r.profundidade != null && r.vale_dia != null) + .map((r) => ({ + mes: r.mes_referencia, + saldoInicio: Number(r.saldo_inicio_mes) || 0, + vale: Number(r.vale_valor) || 0, + valeDia: Number(r.vale_dia), + profundidade: Number(r.profundidade), + })); + const rec = buracoRecorrente(profs); + + // §16 — cobertura bancária incompleta ⇒ o vale NÃO é exibido. Ele erraria + // PRA MENOS (cliente com 3 bancos que subiu 1), e é o erro que não se percebe. + const coberturaOk = (Number(atual.cobertura_bancaria_pct) || 0) >= 100; + const receitaMes = Number(atual.receita_mes) || 0; + + const buraco = rec + ? { + valor: rec.valor, + banda: rec.banda, + meses: rec.meses, + dias: receitaMes > 0 ? Math.round((rec.valor / receitaMes) * DIAS_DO_MES) : null, + valeDiaTipico: medianaInteira(profs.map((p) => p.valeDia)), + exibivel: coberturaOk, + } + : null; + + // ── variação decomposta (o herói, §9) ───────────────────────────── + const variacao = + atual.delta_buraco != null && + atual.buraco_efeito_volume != null && + atual.buraco_efeito_eficiencia != null + ? { + total: Number(atual.delta_buraco), + efeitoVolume: Number(atual.buraco_efeito_volume), + efeitoEficiencia: Number(atual.buraco_efeito_eficiencia), + deltaDias: deltaDiasEntre(ativos, mesRef), + } + : null; + + const perfil = lerPerfil(atual.perfil_calendario); + + // ── a cadeia do crescimento (§10) ───────────────────────────────── + const crescimento = await montarCrescimento(sb, projectId, { + grandeza: rec?.valor ?? Number(atual.profundidade) ?? 0, + receitaMensal: receitaMes, + tendencia: tendenciaHistorica(receita12, receitaAnterior12), + }); + + return { + escopo: { projectId, mesRef, janela }, + nivel: (atual.nivel_efetivo as 0 | 1 | 2) ?? 0, + frescor: { + fechadoEm: atual.frozen_at, + bancosCobertosPct: atual.cobertura_bancaria_pct != null ? Number(atual.cobertura_bancaria_pct) : null, + pctCategorizado: atual.pct_categorizado != null ? Number(atual.pct_categorizado) : null, + desatualizado: atual.desatualizado_em != null, + avisos: Array.isArray(atual.avisos) ? (atual.avisos as string[]) : [], + }, + custo, + variacao, + buraco, + perfil, + descasamento: descasamentoDoMes(perfil), + cobertura: + atual.cobertura_antecipacao != null + ? { + antecipacao: Number(atual.cobertura_antecipacao) || 0, + aporte: Number(atual.cobertura_aporte) || 0, + emprestimo: Number(atual.cobertura_emprestimo) || 0, + } + : null, + crescimento, + serie: daJanela.map((r) => ({ + mes: r.mes_referencia, + profundidade: r.profundidade != null ? Number(r.profundidade) : null, + dias: r.razao_buraco != null ? Math.round(Number(r.razao_buraco) * DIAS_DO_MES) : null, + receita: r.receita_mes != null ? Number(r.receita_mes) : null, + ncg: r.ncg != null ? Number(r.ncg) : null, + diasNcg: r.razao_ncg != null ? Math.round(Number(r.razao_ncg) * DIAS_DO_MES) : null, + desatualizado: r.desatualizado_em != null, + })), + }; +} + +// ============================================================ +// AUXILIARES +// ============================================================ + +function medianaInteira(xs: number[]): number | null { + if (xs.length === 0) return null; + const s = [...xs].sort((a, b) => a - b); + const m = Math.floor(s.length / 2); + return s.length % 2 === 1 ? s[m] : Math.round((s[m - 1] + s[m]) / 2); +} + +/** Δ da régua entre M−1 e M, em dias inteiros — a diferença dos dois exibidos. */ +function deltaDiasEntre(ativos: SnapRow[], mesRef: string): number { + const cur = ativos.find((r) => r.mes_referencia === mesRef); + const ant = ativos.find((r) => r.mes_referencia === mesAnterior(mesRef)); + if (!cur?.razao_buraco || !ant?.razao_buraco) return 0; + return Math.round(Number(cur.razao_buraco) * DIAS_DO_MES) - Math.round(Number(ant.razao_buraco) * DIAS_DO_MES); +} + +/** + * Crescimento implícito no próprio histórico: receita dos últimos 12m contra os 12 + * anteriores. É o fallback do §10.2 — **medido, não arbitrado**. + * + * `null` quando não há 24 meses ou quando a empresa encolheu: projetar giro sobre + * crescimento negativo responde uma pergunta que ninguém fez. + */ +export function tendenciaHistorica(receita12: number, receitaAnterior12: number): number | null { + if (receitaAnterior12 <= 0 || receita12 <= 0) return null; + const t = receita12 / receitaAnterior12 - 1; + return t > 0 ? t : null; +} + +/** + * A cadeia crescer → tenho → falta (§10). + * + * A meta vem do Plano de Voo, pelo **mesmo leitor que o Cockpit usa** (`lerMeta`) — + * nunca uma segunda régua. Ela mora em `plano_voo_snapshots.html_payload`, validada + * pelo schema v3; não existe coluna `meta` (conferido no dump). + * + * **Sem meta travada o bloco não some** — cai na tendência histórica, rotulada como + * tal (§10.2). Bloco vazio mandando o dono travar meta em outra tela seria I3. + */ +async function montarCrescimento( + sb: SupabaseClient, + projectId: string, + base: { grandeza: number; receitaMensal: number; tendencia: number | null }, +): Promise { + if (base.receitaMensal <= 0 || base.grandeza <= 0) return null; + + // `.catch()` na promise NÃO basta: `montarProjecaoAnualParaProjeto` chama + // `cookies()`, que estoura de forma SÍNCRONA fora de um contexto de request — + // e aí o throw passa por cima do catch e derruba o DTO inteiro. §24.3: um bloco + // quebrado degrada sozinho, nunca leva a tela junto. + const seguro = async (f: () => Promise): Promise => { + try { + return await f(); + } catch { + return null; + } + }; + + const [projecao, meta] = await Promise.all([ + seguro(() => montarProjecaoAnualParaProjeto(projectId, 12)), + seguro(() => lerMeta(sb as never, projectId)), + ]); + if (!projecao) return null; + + // A meta é anual: objetivo ÷ baseline (trailing-12) − 1 = o crescimento de 12m. + const metaCrescimento = + meta && meta.receita.baseline > 0 ? meta.receita.objetivo / meta.receita.baseline - 1 : null; + + const crescimentoAlvo = metaCrescimento != null && metaCrescimento > 0 ? metaCrescimento : base.tendencia; + + // Sem meta e sem crescimento, o bloco INVERTE a pergunta em vez de sumir. + if (crescimentoAlvo == null) { + const teto = projecao.operacionalMensalMedio / base.grandeza; + return { + modo: "teto", + tetoMensal: teto, + geracaoMensal: projecao.operacionalMensalMedio, + caixaLivreHoje: projecao.saldoInicial, + grandeza: base.grandeza, + // A grandeza é proporcional à receita: +10% de venda ⇒ +10% de giro. + custoPor10Pct: base.grandeza * 0.1, + motivo: + meta == null + ? "sem_meta" + : metaCrescimento != null && metaCrescimento <= 0 + ? "meta_sem_crescimento" + : "sem_crescimento_historico", + }; + } + + const cadeia = cadeiaDoCrescimento({ + grandeza: base.grandeza, + receitaMensal: base.receitaMensal, + crescimentoAlvo, + caixaLivreHoje: projecao.saldoInicial, + geracaoMensal: projecao.operacionalMensalMedio, + saldoProjetado: projecao.pontos.map((p) => p.saldoAcumulado), + }); + if (!cadeia) return null; + + return { + modo: "plano", + ...cadeia, + origem: metaCrescimento != null && metaCrescimento > 0 ? "meta_travada" : "tendencia_historica", + crescimentoAlvo, + receitaAlvo: base.receitaMensal * (1 + crescimentoAlvo), + geracaoMensal: projecao.operacionalMensalMedio, + caixaLivreHoje: projecao.saldoInicial, + }; +} diff --git a/src/lib/giro/regua.test.ts b/src/lib/giro/regua.test.ts new file mode 100644 index 0000000..6c7bc8f --- /dev/null +++ b/src/lib/giro/regua.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from "vitest"; +import { razaoGiro, diasParados, reguaEmDias, decompor, DIAS_DO_MES } from "./regua"; + +describe("razaoGiro — a base de cálculo, nunca a tela", () => { + it("devolve a razão crua", () => { + expect(razaoGiro(819800, 848000)).toBeCloseTo(819800 / 848000, 12); + }); + + it("receita zero ou negativa devolve null — não fabrica número (I8)", () => { + expect(razaoGiro(100, 0)).toBeNull(); + expect(razaoGiro(100, -5)).toBeNull(); + }); + + it("entrada não-finita devolve null em vez de NaN", () => { + expect(razaoGiro(Number.NaN, 1000)).toBeNull(); + expect(razaoGiro(100, Number.POSITIVE_INFINITY)).toBeNull(); + }); +}); + +describe("diasParados — a régua que o dono lê", () => { + it("é a razão × 30, em dias inteiros", () => { + // dados do mockup v5, jul/2026 + expect(diasParados(819800, 848000)).toBe(29); + }); + + it("acompanha a piora sem se mexer com o crescimento puro", () => { + // cresceu 30% e o giro cresceu 30% junto: MESMOS dias, nada piorou + expect(diasParados(819800, 848000)).toBe(diasParados(819800 * 1.3, 848000 * 1.3)); + }); + + it("sem receita não há régua", () => { + expect(diasParados(500, 0)).toBeNull(); + }); +}); + +describe("reguaEmDias — §5.3.1, as pernas fecham com o total", () => { + it("abre a régua por perna contra a receita (dados do mockup)", () => { + const r = reguaEmDias({ cr: 961100, estoque: 311000, cp: 452300, receitaMensal: 848000 }); + expect(r).toEqual({ receber: 34, estoque: 11, pagar: 16, total: 29 }); + }); + + it("o total é a soma dos INTEIROS exibidos, não o arredondamento do cru", () => { + // cru: (483 + 483 − 100) / 1000 × 30 = 25,98 → arredondaria pra 26. + // exibido: 14 + 14 − 3 = 25. A tela mostra 25, senão o dono soma e não bate. + const r = reguaEmDias({ cr: 483, estoque: 483, cp: 100, receitaMensal: 1000 }); + expect(r).not.toBeNull(); + expect(r!.receber + r!.estoque - r!.pagar).toBe(r!.total); + expect(r!.total).toBe(25); + expect(Math.round(((483 + 483 - 100) / 1000) * DIAS_DO_MES)).toBe(26); // o cru diverge + }); + + it("empresa de serviço (estoque zero) tem régua de duas pernas", () => { + const r = reguaEmDias({ cr: 300000, estoque: 0, cp: 100000, receitaMensal: 300000 }); + expect(r).toEqual({ receber: 30, estoque: 0, pagar: 10, total: 20 }); + }); + + it("sem receita, sem régua", () => { + expect(reguaEmDias({ cr: 1, estoque: 1, cp: 1, receitaMensal: 0 })).toBeNull(); + }); +}); + +describe("decompor — §9, volume × eficiência", () => { + const jul = { x0: 612500, receita0: 735000, x1: 819800, receita1: 848000 }; + + it("fecha exata: volume + eficiência = ΔX, sem resíduo", () => { + const d = decompor(jul)!; + expect(d.efeitoVolume + d.efeitoEficiencia).toBeCloseTo(d.total, 6); + expect(d.total).toBe(819800 - 612500); + }); + + it("separa o que era esperado do que não era (dados do mockup)", () => { + const d = decompor(jul)!; + expect(Math.round(d.efeitoVolume / 1000)).toBe(94); // cresceu 15% + expect(Math.round(d.efeitoEficiencia / 1000)).toBe(113); // o ciclo esticou + expect(Math.round(d.deltaDias)).toBe(4); + }); + + it("crescer sem piorar o ciclo joga TUDO em volume", () => { + const d = decompor({ x0: 100, receita0: 1000, x1: 130, receita1: 1300 })!; + expect(d.efeitoEficiencia).toBeCloseTo(0, 9); + expect(d.efeitoVolume).toBeCloseTo(30, 9); + expect(d.deltaDias).toBeCloseTo(0, 9); + }); + + it("piorar sem crescer joga TUDO em eficiência", () => { + const d = decompor({ x0: 100, receita0: 1000, x1: 130, receita1: 1000 })!; + expect(d.efeitoVolume).toBeCloseTo(0, 9); + expect(d.efeitoEficiencia).toBeCloseTo(30, 9); + }); + + it("o termo cruzado cai na EFICIÊNCIA — erra pro lado que o dono precisa ver", () => { + // cresce 10% E o ciclo estica: se o cruzado caísse em volume, a piora + // apareceria menor do que é. + const d = decompor({ x0: 100, receita0: 1000, x1: 143, receita1: 1100 })!; + expect(d.efeitoVolume).toBeCloseTo(10, 6); // 0,10 × 100 + expect(d.efeitoEficiencia).toBeCloseTo(33, 6); // (0,13 − 0,10) × 1100 + expect(d.efeitoEficiencia).toBeGreaterThan(d.total - d.efeitoEficiencia); + }); + + it("melhora vem com sinal negativo nos dois efeitos certos", () => { + const d = decompor({ x0: 819800, receita0: 848000, x1: 532800, receita1: 848000 })!; + expect(d.total).toBeLessThan(0); + expect(d.efeitoVolume).toBeCloseTo(0, 6); + expect(Math.round(d.deltaDias)).toBe(-10); // as 3 alavancas tiram 10 dias + }); + + it("receita zero em qualquer ponta devolve null", () => { + expect(decompor({ x0: 1, receita0: 0, x1: 2, receita1: 10 })).toBeNull(); + expect(decompor({ x0: 1, receita0: 10, x1: 2, receita1: 0 })).toBeNull(); + }); +}); diff --git a/src/lib/giro/regua.ts b/src/lib/giro/regua.ts new file mode 100644 index 0000000..79a9a9a --- /dev/null +++ b/src/lib/giro/regua.ts @@ -0,0 +1,109 @@ +/** + * Capital de Giro — G1: a régua (dias de faturamento parados) e a decomposição. + * + * Núcleo PURO. Spec: `capital-de-giro-spec.md` §5.3, §5.3.1 e §9. + * + * A decisão que este arquivo materializa (D15, 2026-08-17): **a régua é em dias.** + * "Razão de giro" e "centavos por real" saíram do produto — a razão continua sendo a + * base de cálculo e o que se GRAVA (§8.2), mas nunca o que se exibe. + */ + +/** A régua converte razão em dias com mês comercial de 30. Uma constante, um lugar. */ +export const DIAS_DO_MES = 30; + +export interface ReguaPernas { + receber: number; + estoque: number; + pagar: number; + /** = receber + estoque − pagar, pelos INTEIROS exibidos (§13.5). */ + total: number; +} + +export interface Decomposicao { + /** ΔX do mês. Sempre igual a efeitoVolume + efeitoEficiencia (sem resíduo). */ + total: number; + efeitoVolume: number; + efeitoEficiencia: number; + /** Variação da régua, em dias — o que o texto do §9 narra. */ + deltaDias: number; +} + +// ============================================================ +// §5.3 — A RÉGUA +// ============================================================ + +/** + * A razão CRUA (grandeza ÷ receita mensal). É isto que vai para + * `giro_monthly_snapshots.razao_giro` — e **nunca** para a tela. + * + * `null` quando não há receita: dividir por zero para exibir um número seria + * fabricar (I8). Sem receita não há régua, e a tela não finge que há. + */ +export function razaoGiro(grandeza: number, receitaMensal: number): number | null { + if (!Number.isFinite(grandeza) || !Number.isFinite(receitaMensal)) return null; + if (receitaMensal <= 0) return null; + return grandeza / receitaMensal; +} + +/** A régua, em dias inteiros — o número que o dono lê e repete (§5.3). */ +export function diasParados(grandeza: number, receitaMensal: number): number | null { + const r = razaoGiro(grandeza, receitaMensal); + return r === null ? null : Math.round(r * DIAS_DO_MES); +} + +/** + * A régua aberta por perna (§5.3.1), **sempre contra a receita** — nunca contra CMV + * ou compras, que é o que faz PMR/PME/PMP não somarem. + * + * O total é a soma dos INTEIROS exibidos, não o arredondamento do total cru. Parece + * detalhe e não é: um dono que soma 34 + 11 − 16 e não chega no total que a tela + * mostra perde a confiança na tela inteira, e com razão (§13.5). + */ +export function reguaEmDias(input: { + cr: number; + estoque: number; + cp: number; + receitaMensal: number; +}): ReguaPernas | null { + const { cr, estoque, cp, receitaMensal } = input; + if (receitaMensal <= 0) return null; + const emDias = (v: number) => Math.round((v / receitaMensal) * DIAS_DO_MES); + const receber = emDias(cr); + const est = emDias(estoque); + const pagar = emDias(cp); + return { receber, estoque: est, pagar, total: receber + est - pagar }; +} + +// ============================================================ +// §9 — DECOMPOSIÇÃO VOLUME × EFICIÊNCIA +// ============================================================ + +/** + * Separa o quanto da variação veio de CRESCER (notícia boa com fatura anexa) do + * quanto veio do CICLO PIORAR (cliente pagando devagar, estoque encalhando). + * + * Fórmula sequencial, fecha exata (soma = ΔX, sem resíduo). Uma fórmula só nos três + * níveis — muda apenas o numerador `x`: o buraco do mês (N0) ou o capital preso (N1+). + * + * **O termo cruzado cai no efeito eficiência de propósito** (§9): faz a piora parecer + * maior, nunca menor. Na dúvida, erra pro lado que o dono precisa ver. + */ +export function decompor(input: { + x0: number; + receita0: number; + x1: number; + receita1: number; +}): Decomposicao | null { + const { x0, receita0, x1, receita1 } = input; + if (receita0 <= 0 || receita1 <= 0) return null; + + const r0 = x0 / receita0; + const r1 = x1 / receita1; + + return { + total: x1 - x0, + efeitoVolume: r0 * (receita1 - receita0), + efeitoEficiencia: (r1 - r0) * receita1, + deltaDias: (r1 - r0) * DIAS_DO_MES, + }; +} diff --git a/src/lib/giro/serie-operacional.test.ts b/src/lib/giro/serie-operacional.test.ts new file mode 100644 index 0000000..430f003 --- /dev/null +++ b/src/lib/giro/serie-operacional.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect } from "vitest"; +import { + filtrarOperacional, + profundidadeDoMes, + buracoRecorrente, + perfilCalendario, + acumuladoAte, + MIN_MESES_BURACO, + type DiaOperacional, + type LancamentoGiro, + type ProfundidadeMes, +} from "./serie-operacional"; + +describe("filtrarOperacional — §5.1, medir o buraco ANTES do socorro", () => { + const lancs: LancamentoGiro[] = [ + { data: "2026-07-01", credito: 1000, categoriaId: "receita_operacional" }, + { data: "2026-07-12", credito: 5000, categoriaId: "movimentacao_patrimonial" }, // antecipação + { data: "2026-07-15", debito: 800, categoriaId: "custos" }, + { data: "2026-07-20", debito: 120, categoriaId: "despesas_financeiras" }, // deságio/juros + { data: "2026-07-25", debito: 300, categoriaId: null }, + ]; + + it("tira o socorro (movpat) e o custo do socorro (L65)", () => { + const op = filtrarOperacional(lancs); + expect(op.map((l) => l.data)).toEqual(["2026-07-01", "2026-07-15", "2026-07-25"]); + }); + + it("lançamento sem categoria FICA — não some do fluxo por falta de rótulo", () => { + expect(filtrarOperacional(lancs).some((l) => l.categoriaId === null)).toBe(true); + }); + + it("a antecipação do dia 12 é justamente o que mascararia o vale", () => { + const comSocorro = lancs.reduce((s, l) => s + (l.credito ?? 0) - (l.debito ?? 0), 0); + const semSocorro = filtrarOperacional(lancs).reduce( + (s, l) => s + (l.credito ?? 0) - (l.debito ?? 0), + 0, + ); + expect(comSocorro).toBeGreaterThan(semSocorro); // medir depois erra PRA MENOS + }); +}); + +/** Série curta: jun fecha em 100, jul afunda até 20 no dia 18 e volta pra 90. */ +const serie: DiaOperacional[] = [ + { data: "2026-06-29", entrada: 0, saida: 0, saldoFim: 120 }, + { data: "2026-06-30", entrada: 0, saida: 20, saldoFim: 100 }, + { data: "2026-07-05", entrada: 10, saida: 40, saldoFim: 70 }, + { data: "2026-07-18", entrada: 0, saida: 50, saldoFim: 20 }, + { data: "2026-07-26", entrada: 90, saida: 20, saldoFim: 90 }, +]; + +describe("profundidadeDoMes — §5.2", () => { + it("mede contra o fechamento do mês anterior, não contra o 1º dia do mês", () => { + const p = profundidadeDoMes(serie, "2026-07")!; + expect(p.saldoInicio).toBe(100); + expect(p.vale).toBe(20); + expect(p.valeDia).toBe(18); + expect(p.profundidade).toBe(80); + }); + + it("sem mês anterior na série, devolve null — prefere a lacuna ao chute", () => { + expect(profundidadeDoMes(serie, "2026-06")).toBeNull(); + }); + + it("mês ausente devolve null", () => { + expect(profundidadeDoMes(serie, "2026-09")).toBeNull(); + }); + + it("mês que só subiu tem buraco ZERO, nunca negativo", () => { + const so_sobe: DiaOperacional[] = [ + { data: "2026-06-30", entrada: 0, saida: 0, saldoFim: 100 }, + { data: "2026-07-10", entrada: 50, saida: 0, saldoFim: 150 }, + { data: "2026-07-20", entrada: 50, saida: 0, saldoFim: 200 }, + ]; + const p = profundidadeDoMes(so_sobe, "2026-07")!; + expect(p.profundidade).toBe(0); + expect(p.vale).toBe(150); + }); + + it("empate no vale fica com o dia mais CEDO — é quando o aperto começa", () => { + const empate: DiaOperacional[] = [ + { data: "2026-06-30", entrada: 0, saida: 0, saldoFim: 100 }, + { data: "2026-07-08", entrada: 0, saida: 60, saldoFim: 40 }, + { data: "2026-07-22", entrada: 0, saida: 0, saldoFim: 40 }, + ]; + expect(profundidadeDoMes(empate, "2026-07")!.valeDia).toBe(8); + }); +}); + +describe("buracoRecorrente — §5.2, mediana e não média", () => { + const p = (mes: string, profundidade: number): ProfundidadeMes => ({ + mes, + saldoInicio: 0, + vale: 0, + valeDia: 1, + profundidade, + }); + + it("um mês de compra de máquina não vira a régua", () => { + const b = buracoRecorrente([ + p("2026-02", 100), + p("2026-03", 110), + p("2026-04", 900), // outlier + p("2026-05", 120), + p("2026-06", 105), + ])!; + expect(b.valor).toBe(110); // mediana; a média daria 267 + expect(b.banda).toEqual([100, 900]); + expect(b.meses).toBe(5); + }); + + it("contagem par usa a média dos dois centrais", () => { + const b = buracoRecorrente([p("a", 10), p("b", 20), p("c", 30), p("d", 100)])!; + expect(b.valor).toBe(25); + }); + + it("abaixo de 3 meses não existe vale recorrente — devolve null (§16)", () => { + expect(buracoRecorrente([p("a", 10), p("b", 20)])).toBeNull(); + expect(buracoRecorrente([p("a", 10), p("b", 20), p("c", 30)])).not.toBeNull(); + expect(MIN_MESES_BURACO).toBe(3); + }); +}); + +describe("perfilCalendario — §5.5, o descasamento", () => { + /** Dois meses de tamanhos MUITO diferentes, com o mesmo formato. */ + const dois: DiaOperacional[] = [ + { data: "2026-06-05", entrada: 0, saida: 80, saldoFim: 0 }, + { data: "2026-06-25", entrada: 100, saida: 20, saldoFim: 0 }, + { data: "2026-07-05", entrada: 0, saida: 800, saldoFim: 0 }, + { data: "2026-07-25", entrada: 1000, saida: 200, saldoFim: 0 }, + ]; + + it("normaliza cada mês pelo próprio total — o mês grande não domina", () => { + const perfil = perfilCalendario(dois, ["2026-06", "2026-07"]); + const dia5 = perfil.find((d) => d.dia === 5)!; + const dia25 = perfil.find((d) => d.dia === 25)!; + expect(dia5.saidaPct).toBeCloseTo(80, 6); // 80% da saída no dia 5, nos dois meses + expect(dia25.entradaPct).toBeCloseTo(100, 6); + expect(dia5.entradaPct).toBe(0); + }); + + it("devolve sempre os 31 dias", () => { + const perfil = perfilCalendario(dois, ["2026-06", "2026-07"]); + expect(perfil).toHaveLength(31); + expect(perfil[0].dia).toBe(1); + expect(perfil[30].dia).toBe(31); + }); + + it("cada lado soma ~100% do mês típico", () => { + const perfil = perfilCalendario(dois, ["2026-06", "2026-07"]); + const somaEnt = perfil.reduce((s, d) => s + d.entradaPct, 0); + const somaSai = perfil.reduce((s, d) => s + d.saidaPct, 0); + expect(somaEnt).toBeCloseTo(100, 6); + expect(somaSai).toBeCloseTo(100, 6); + }); + + it("mês sem entrada não entra na média de entrada como zero", () => { + const comMesSeco: DiaOperacional[] = [ + { data: "2026-06-10", entrada: 0, saida: 100, saldoFim: 0 }, // só saída + { data: "2026-07-10", entrada: 500, saida: 100, saldoFim: 0 }, + ]; + const perfil = perfilCalendario(comMesSeco, ["2026-06", "2026-07"]); + // se jun entrasse como zero, o dia 10 daria 50% em vez dos 100% reais + expect(perfil.find((d) => d.dia === 10)!.entradaPct).toBeCloseTo(100, 6); + }); + + it("meses sem dado nenhum não quebram — devolve perfil zerado", () => { + const perfil = perfilCalendario([], ["2026-06"]); + expect(perfil).toHaveLength(31); + expect(perfil.every((d) => d.entradaPct === 0 && d.saidaPct === 0)).toBe(true); + }); + + it("acumuladoAte produz a frase do §5.5", () => { + const perfil = perfilCalendario(dois, ["2026-06", "2026-07"]); + const ate15 = acumuladoAte(perfil, 15); + expect(Math.round(ate15.saida)).toBe(80); // "80% da saída antes do dia 15" + expect(Math.round(ate15.entrada)).toBe(0); + }); +}); diff --git a/src/lib/giro/serie-operacional.ts b/src/lib/giro/serie-operacional.ts new file mode 100644 index 0000000..e946822 --- /dev/null +++ b/src/lib/giro/serie-operacional.ts @@ -0,0 +1,223 @@ +/** + * Capital de Giro — G1: a série operacional e o buraco do mês. + * + * Núcleo PURO: zero I/O, zero Supabase, zero `Date.now()`. Quem busca o dado é a + * camada de serviço (G2); aqui só entra número e sai número, para que a matemática + * seja testável sem banco. + * + * Spec: `docs/atros-v3/capital-de-giro-spec.md` §5.1 (série operacional), + * §5.2 (o buraco do mês) e §5.5 (o calendário de descasamento). + */ + +// ============================================================ +// TIPOS +// ============================================================ + +/** + * Lançamento já categorizado, na forma mínima que o giro precisa. + * Convenção de sinal herdada de `saldo-diario.ts`: crédito e débito são valores + * BRUTOS positivos em campos separados — nunca um único campo com sinal. + * (Ver `onda5_convencao_sinal`: sinal misturado é como o estorno sumia.) + */ +export interface LancamentoGiro { + data: string; // 'YYYY-MM-DD' + credito?: number | null; + debito?: number | null; + categoriaId?: string | null; +} + +/** + * Ponto diário da série. Estruturalmente compatível com `PontoDiario` de + * `saldo-diario.ts` — de propósito: a série é reconstruída lá (motor que já existe + * e é testado) e entra aqui sem conversão, mas sem acoplar o núcleo do giro àquele + * módulo (que importa `@/lib/cash-now`). + */ +export interface DiaOperacional { + data: string; // 'YYYY-MM-DD' + entrada: number; // ≥ 0 + saida: number; // ≥ 0 + saldoFim: number; +} + +export interface ProfundidadeMes { + mes: string; // 'YYYY-MM' + saldoInicio: number; // fechamento operacional do último dia de M−1 + vale: number; // menor saldo de fechamento dentro de M + valeDia: number; // dia do mês em que o vale ocorreu (1..31) + profundidade: number; // saldoInicio − vale, nunca negativo +} + +export interface BuracoRecorrente { + valor: number; // mediana das profundidades + banda: [number, number]; // [min, max] observados — a volatilidade real + meses: number; // quantos meses entraram na conta +} + +/** Um dia do mês (1..31) com o percentual médio de entrada e de saída. */ +export interface PerfilDia { + dia: number; // 1..31 + entradaPct: number; // 0..100 — % do total de ENTRADA do mês, média dos meses + saidaPct: number; // 0..100 — % do total de SAÍDA do mês, média dos meses +} + +// ============================================================ +// §5.1 — A SÉRIE OPERACIONAL +// ============================================================ + +/** + * Categorias que NÃO entram na série operacional. + * + * - `movimentacao_patrimonial` (L90): antecipação, aporte, empréstimo, amortização, + * imobilizado, distribuição. É o SOCORRO. Medir o buraco depois do socorro é medir + * o buraco já tapado — falha *pra menos*, que é o erro que não se percebe (§5.1). + * - `despesas_financeiras` (L65): juros e deságio são o CUSTO do socorro, não da + * operação. Entram separados, no custo do giro (§5.4). + * + * Os ids batem com `categorization-rules.ts:193` (movpat, linhaDre 90) e com a + * categoria de L65 usada por `custo_antecipacao` (`categorization-rules.ts:318`). + */ +export const CATEGORIAS_FORA_DO_OPERACIONAL: ReadonlySet = new Set([ + "movimentacao_patrimonial", + "despesas_financeiras", +]); + +/** Remove do fluxo tudo que é socorro ou custo de socorro (§5.1). */ +export function filtrarOperacional(lancamentos: T[]): T[] { + return lancamentos.filter((l) => { + const cat = l.categoriaId ?? ""; + return !CATEGORIAS_FORA_DO_OPERACIONAL.has(cat); + }); +} + +// ============================================================ +// §5.2 — O BURACO DO MÊS +// ============================================================ + +/** Mínimo de meses fechados para existir "vale recorrente" (§5.2). */ +export const MIN_MESES_BURACO = 3; + +function mesDe(data: string): string { + return data.slice(0, 7); +} +function diaDe(data: string): number { + return Number(data.slice(8, 10)); +} + +/** + * Profundidade de um mês: quanto o caixa afundou contra o saldo com que abriu. + * + * `null` quando não há saldo de abertura — ou seja, quando a série não alcança o mês + * anterior. Sem abertura não há "afundou quanto", e a tela prefere a lacuna ao chute. + */ +export function profundidadeDoMes(pontos: DiaOperacional[], mes: string): ProfundidadeMes | null { + const doMes = pontos.filter((p) => mesDe(p.data) === mes); + if (doMes.length === 0) return null; + + const primeiroDia = doMes.reduce((a, b) => (a.data <= b.data ? a : b)).data; + const anteriores = pontos.filter((p) => p.data < primeiroDia); + if (anteriores.length === 0) return null; + + const saldoInicio = anteriores.reduce((a, b) => (a.data >= b.data ? a : b)).saldoFim; + + let vale = doMes[0]; + for (const p of doMes) { + // `<` e não `<=`: empate fica com o dia MAIS CEDO, que é o que o dono + // precisa saber para agir (o aperto começa ali, não termina ali). + if (p.saldoFim < vale.saldoFim) vale = p; + } + + return { + mes, + saldoInicio, + vale: vale.saldoFim, + valeDia: diaDe(vale.data), + // Mês que só subiu tem buraco ZERO, não buraco negativo: não houve nada + // para financiar além do que já estava em caixa. + profundidade: Math.max(0, saldoInicio - vale.saldoFim), + }; +} + +/** Mediana — em contagem par, média dos dois centrais. */ +function mediana(xs: number[]): number { + const s = [...xs].sort((a, b) => a - b); + const m = Math.floor(s.length / 2); + return s.length % 2 === 1 ? s[m] : (s[m - 1] + s[m]) / 2; +} + +/** + * O buraco recorrente = MEDIANA das profundidades (§5.2). + * + * Mediana e não média: um mês com compra de máquina não pode virar a régua do que a + * empresa precisa ter parado todo mês. + */ +export function buracoRecorrente(profundidades: ProfundidadeMes[]): BuracoRecorrente | null { + if (profundidades.length < MIN_MESES_BURACO) return null; + const vs = profundidades.map((p) => p.profundidade); + return { + valor: mediana(vs), + banda: [Math.min(...vs), Math.max(...vs)], + meses: vs.length, + }; +} + +// ============================================================ +// §5.5 — O CALENDÁRIO DE DESCASAMENTO +// ============================================================ + +/** + * Perfil médio do mês: para cada dia (1..31), quanto por cento da entrada e da saída + * do mês acontece nele, na média dos meses informados. + * + * Cada mês é normalizado pelo PRÓPRIO total antes de entrar na média — senão um mês + * grande domina o perfil e o resultado deixa de ser "como é o meu mês típico". + * Um mês sem entrada (ou sem saída) não entra na média daquele lado, em vez de entrar + * como zero e puxar o perfil pra baixo. + */ +export function perfilCalendario(pontos: DiaOperacional[], meses: string[]): PerfilDia[] { + const somaEnt = new Array(32).fill(0) as number[]; + const somaSai = new Array(32).fill(0) as number[]; + let mesesComEntrada = 0; + let mesesComSaida = 0; + + for (const mes of meses) { + const doMes = pontos.filter((p) => mesDe(p.data) === mes); + if (doMes.length === 0) continue; + + const totalEnt = doMes.reduce((s, p) => s + p.entrada, 0); + const totalSai = doMes.reduce((s, p) => s + p.saida, 0); + if (totalEnt > 0) mesesComEntrada++; + if (totalSai > 0) mesesComSaida++; + + for (const p of doMes) { + const d = diaDe(p.data); + if (d < 1 || d > 31) continue; + if (totalEnt > 0) somaEnt[d] += (p.entrada / totalEnt) * 100; + if (totalSai > 0) somaSai[d] += (p.saida / totalSai) * 100; + } + } + + const perfil: PerfilDia[] = []; + for (let d = 1; d <= 31; d++) { + perfil.push({ + dia: d, + entradaPct: mesesComEntrada > 0 ? somaEnt[d] / mesesComEntrada : 0, + saidaPct: mesesComSaida > 0 ? somaSai[d] / mesesComSaida : 0, + }); + } + return perfil; +} + +/** + * Acumulado do perfil até o dia N — é daqui que sai a frase do §5.5: + * *"78% da sua saída acontece antes do dia 15."* + */ +export function acumuladoAte(perfil: PerfilDia[], dia: number): { entrada: number; saida: number } { + let entrada = 0; + let saida = 0; + for (const p of perfil) { + if (p.dia > dia) break; + entrada += p.entradaPct; + saida += p.saidaPct; + } + return { entrada, saida }; +} diff --git a/supabase/migrations/20260817000000_giro_monthly_snapshots.sql b/supabase/migrations/20260817000000_giro_monthly_snapshots.sql new file mode 100644 index 0000000..52b785c --- /dev/null +++ b/supabase/migrations/20260817000000_giro_monthly_snapshots.sql @@ -0,0 +1,337 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- Capital de Giro — G2: a série mensal +-- +-- Spec: docs/atros-v3/capital-de-giro-spec.md §8 (snapshot mensal) e §14. +-- +-- POR QUE UMA TABELA NOVA (§8.1): `cash_cycle_analyses` faz upsert por +-- `project_id` — UMA linha por projeto, sobrescrita para sempre. O sistema nunca +-- teve histórico de capital de giro, sempre e apenas a última foto com a anterior +-- destruída. É a causa raiz de "não dá pra medir com recorrência", e é estrutural, +-- não de cálculo. +-- +-- Esta migration é ADITIVA: cria a tabela, e estende a função de stale-by-event +-- que já existe. Não dropa nada. `cash_cycle_analyses` e `cash_flow_params` só +-- saem no G7, em sessão separada, depois de repontar os 6 leitores (§3.1). +-- ════════════════════════════════════════════════════════════════════════════ + +-- ─── 1. A TABELA ──────────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS "public"."giro_monthly_snapshots" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL, + "project_id" uuid NOT NULL, + "mes_referencia" text NOT NULL, + "version" integer DEFAULT 1 NOT NULL, + "frozen_at" timestamp with time zone DEFAULT now() NOT NULL, + "created_by" uuid, + + -- 0 = só extrato · 1 = + os 3 saldos informados · 2 = + pernas medidas (§4) + "nivel_efetivo" smallint DEFAULT 0 NOT NULL, + + -- ── Nível 0: sempre preenchido (§5) ────────────────────────────────── + "saldo_inicio_mes" numeric, + "vale_valor" numeric, + "vale_dia" smallint, + "profundidade" numeric, + "receita_mes" numeric, + + -- ⚠️ DUAS razões, não uma. A spec §8.2 previa um único `razao_giro`, mas o + -- §4.1 é taxativo: o buraco do mês e o capital preso são GRANDEZAS DISTINTAS, + -- e uma nunca é aproximação da outra. Um único campo faria a série misturar + -- as duas no mês em que o cliente começa a informar saldos — exatamente a + -- confusão que o §4.1 existe para impedir. Ambas são razões CRUAS; a tela + -- sempre exibe × 30, em dias (§5.3). + "razao_buraco" numeric, + "razao_ncg" numeric, + + "custo_giro_desagio" numeric, + "custo_giro_juros" numeric, + + "cobertura_antecipacao" numeric, + "cobertura_aporte" numeric, + "cobertura_emprestimo" numeric, + + -- 31 pares {dia, entrada_pct, saida_pct} — o perfil do mês típico (§5.5) + "perfil_calendario" jsonb DEFAULT '[]'::jsonb NOT NULL, + + -- ── As três pernas, cada uma com a PRÓPRIA procedência (§4, precedência + -- por perna: medido > informado > empírico) ────────────────────────── + "cr_valor" numeric, + "cr_fonte" text, + "cp_valor" numeric, + "cp_fonte" text, + "estoque_valor" numeric, + "estoque_fonte" text, + "informado_em" timestamp with time zone, + "informado_por" uuid, + + -- ── Derivados: NULL quando a perna que os sustenta falta ───────────── + "ncg" numeric, + "pmr" numeric, + "pme" numeric, + "pmp" numeric, + "ciclo_financeiro" numeric, + "cod_diario" numeric, + "compras_mes" numeric, + + -- ── Decomposição vs. mês anterior (§9), por grandeza ───────────────── + -- Mesma razão das duas razões acima: decompor o buraco e decompor o capital + -- preso são contas diferentes sobre numeradores diferentes. + "delta_buraco" numeric, + "buraco_efeito_volume" numeric, + "buraco_efeito_eficiencia" numeric, + "delta_ncg" numeric, + "ncg_efeito_volume" numeric, + "ncg_efeito_eficiencia" numeric, + + -- ── Honestidade (§16) ──────────────────────────────────────────────── + "cobertura_bancaria_pct" numeric, + "pct_categorizado" numeric, + "avisos" jsonb DEFAULT '[]'::jsonb NOT NULL, + + -- ── Ciclo de vida: mesmo padrão de dre_monthly_snapshots ───────────── + "invalidated_at" timestamp with time zone, + "invalidated_reason" text, + "desatualizado_em" timestamp with time zone, + "desatualizado_motivo" jsonb, + + CONSTRAINT "giro_monthly_snapshots_pkey" PRIMARY KEY ("id"), + CONSTRAINT "giro_monthly_snapshots_mes_format_chk" + CHECK (mes_referencia ~ '^[0-9]{4}-(0[1-9]|1[0-2])$'), + CONSTRAINT "giro_monthly_snapshots_nivel_chk" + CHECK (nivel_efetivo BETWEEN 0 AND 2), + CONSTRAINT "giro_monthly_snapshots_vale_dia_chk" + CHECK (vale_dia IS NULL OR vale_dia BETWEEN 1 AND 31), + -- Procedência declarada ou ausente — nunca um rótulo inventado (I8). + CONSTRAINT "giro_monthly_snapshots_cr_fonte_chk" + CHECK (cr_fonte IS NULL OR cr_fonte IN ('medido', 'informado')), + CONSTRAINT "giro_monthly_snapshots_cp_fonte_chk" + CHECK (cp_fonte IS NULL OR cp_fonte IN ('medido', 'informado')), + CONSTRAINT "giro_monthly_snapshots_estoque_fonte_chk" + CHECK (estoque_fonte IS NULL OR estoque_fonte IN ('medido', 'informado')), + -- Perna com valor exige fonte, e vice-versa: valor sem procedência é + -- exatamente o número órfão que o I8 proíbe. + CONSTRAINT "giro_monthly_snapshots_cr_par_chk" + CHECK ((cr_valor IS NULL) = (cr_fonte IS NULL)), + CONSTRAINT "giro_monthly_snapshots_cp_par_chk" + CHECK ((cp_valor IS NULL) = (cp_fonte IS NULL)), + CONSTRAINT "giro_monthly_snapshots_estoque_par_chk" + CHECK ((estoque_valor IS NULL) = (estoque_fonte IS NULL)), + + CONSTRAINT "giro_monthly_snapshots_project_id_fkey" + FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE CASCADE +); + +ALTER TABLE "public"."giro_monthly_snapshots" OWNER TO "postgres"; + +-- Uma foto por projeto+mês+versão. Re-fechamento cria version+1, nunca sobrescreve +-- — é a diferença estrutural para `cash_cycle_analyses` (§8.1). +CREATE UNIQUE INDEX IF NOT EXISTS "giro_snapshots_projeto_mes_versao_uk" + ON "public"."giro_monthly_snapshots" ("project_id", "mes_referencia", "version"); + +-- Leitura da série: a janela de 6/12 meses do projeto, versão ativa primeiro. +CREATE INDEX IF NOT EXISTS "idx_giro_snapshots_serie" + ON "public"."giro_monthly_snapshots" ("project_id", "mes_referencia" DESC, "version" DESC) + WHERE "invalidated_at" IS NULL; + +CREATE INDEX IF NOT EXISTS "idx_giro_snapshots_desatualizados" + ON "public"."giro_monthly_snapshots" ("project_id", "mes_referencia") + WHERE "desatualizado_em" IS NOT NULL; + +-- ─── 2. COMENTÁRIOS (o doc que viaja junto com o schema) ──────────────────── + +COMMENT ON TABLE "public"."giro_monthly_snapshots" IS +'Foto mensal imutável do capital de giro (spec capital-de-giro-spec.md §8). Grão projeto+mês+version. Substitui cash_cycle_analyses, que fazia upsert por project_id e destruía o histórico. Fechou o mês, congela; re-fechamento cria version+1. Escrita só por service_role (via after() do fechar-mes).'; + +COMMENT ON COLUMN "public"."giro_monthly_snapshots"."nivel_efetivo" IS +'0 = só extrato · 1 = + os 3 saldos informados no fechamento · 2 = + pernas medidas (carteira/aging/estoque). É o MAIOR nível com dado no mês, e pode variar ao longo da série — por isso cada perna carrega a própria fonte.'; + +COMMENT ON COLUMN "public"."giro_monthly_snapshots"."profundidade" IS +'Quanto o caixa afundou DENTRO do mês contra o saldo de abertura, medido no fluxo operacional puro (sem antecipação/aporte/empréstimo e sem juros/deságio). Nunca negativo: mês que só subiu tem buraco zero. NÃO confundir com ncg (§4.1) — este é oscilação, aquele é estoque de capital travado.'; + +COMMENT ON COLUMN "public"."giro_monthly_snapshots"."razao_buraco" IS +'profundidade ÷ receita_mes. Razão CRUA — nunca exibida como tal: a tela mostra sempre × 30, em dias de faturamento (§5.3). Guardar crua evita arredondamento acumulado na série.'; + +COMMENT ON COLUMN "public"."giro_monthly_snapshots"."razao_ncg" IS +'ncg ÷ receita_mes. Razão CRUA, mesma regra de exibição. Série SEPARADA de razao_buraco — são grandezas distintas (§4.1) e nunca se plotam na mesma linha.'; + +COMMENT ON COLUMN "public"."giro_monthly_snapshots"."ncg" IS +'Capital preso = cr_valor + estoque_valor − cp_valor. NULL quando faltam pernas. É estoque de capital travado o ano inteiro, já financiado — não é o buraco do mês (§4.1).'; + +COMMENT ON COLUMN "public"."giro_monthly_snapshots"."perfil_calendario" IS +'jsonb [{dia, entrada_pct, saida_pct}] × 31 — o perfil do mês TÍPICO (média dos meses da janela, cada mês normalizado pelo próprio total). Alimenta o calendário de descasamento (§5.5). Percentuais 0..100.'; + +COMMENT ON COLUMN "public"."giro_monthly_snapshots"."desatualizado_em" IS +'Stale-by-event: transações deste mês mudaram DEPOIS do fechamento. O snapshot continua sendo registro válido e CONTINUA SENDO EXIBIDO (era verdade quando congelou) — a UI só acende o aviso. Não filtre leitura por esta coluna; para isso existe invalidated_at.'; + +COMMENT ON COLUMN "public"."giro_monthly_snapshots"."cobertura_bancaria_pct" IS +'% das contas bancárias do projeto com dado no mês. Abaixo de 100 o vale NÃO é exibido (§16): cliente com 3 bancos que subiu 1 vê um vale falso, e falso PRA MENOS — o erro que não se percebe.'; + +COMMENT ON COLUMN "public"."giro_monthly_snapshots"."avisos" IS +'jsonb [] — o que a tela precisa dizer em voz alta sobre este mês (cobertura incompleta, saldo informado envelhecido, divergência informado × medido). Nunca resolve em silêncio (§20).'; + +-- ─── 3. RLS — mesmo padrão de dre_monthly_snapshots ───────────────────────── +-- Leitura: membro do projeto ou admin. Escrita: só service_role (o snapshot nasce +-- do after() do fechar-mes, nunca do browser). + +ALTER TABLE "public"."giro_monthly_snapshots" ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "giro_monthly_snapshots select by project members" ON "public"."giro_monthly_snapshots"; +CREATE POLICY "giro_monthly_snapshots select by project members" + ON "public"."giro_monthly_snapshots" FOR SELECT + USING ( + public.is_admin(auth.uid()) + OR EXISTS ( + SELECT 1 FROM public.project_members pm + WHERE pm.project_id = giro_monthly_snapshots.project_id + AND pm.user_id = auth.uid() + ) + ); + +DROP POLICY IF EXISTS "giro_monthly_snapshots write service_role only" ON "public"."giro_monthly_snapshots"; +CREATE POLICY "giro_monthly_snapshots write service_role only" + ON "public"."giro_monthly_snapshots" + USING (auth.role() = 'service_role') + WITH CHECK (auth.role() = 'service_role'); + +GRANT ALL ON TABLE "public"."giro_monthly_snapshots" TO "anon"; +GRANT ALL ON TABLE "public"."giro_monthly_snapshots" TO "authenticated"; +GRANT ALL ON TABLE "public"."giro_monthly_snapshots" TO "service_role"; + +-- ─── 4. STALE-BY-EVENT: estende a função que já existe (§8.4) ─────────────── +-- +-- ⚠️ ANTI-PADRÃO 7 DA LEI DE MÉTODO: reescrever função SECURITY DEFINER longa por +-- transcrição é como se introduz divergência que só aparece em runtime, com +-- privilégio elevado. O corpo abaixo é CÓPIA VERBATIM da baseline +-- (00000000000000_baseline.sql:2230-2315); a ÚNICA diferença pretendida é o +-- segundo UPDATE, que marca o snapshot de giro dos mesmos meses tocados. +-- A fidelidade é provada por diff em scripts/db/provar-fidelidade-trigger.ts. +-- +-- Por que estender e não criar função nova: `transactions` é tabela quente e o +-- trigger é STATEMENT-level justamente para custar O(meses) e não O(linhas). +-- Uma segunda função faria a mesma varredura de novo, dobrando o custo. + +CREATE OR REPLACE FUNCTION "public"."marcar_snapshot_desatualizado"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public' + AS $$ +DECLARE + v_meses jsonb; -- [{project_id, mes, linhas}] + v_cols text; +BEGIN + -- Cada ramo só referencia a transition table que existe no seu evento. + IF TG_OP = 'INSERT' THEN + v_cols := 'linha nova'; + SELECT jsonb_agg(x) INTO v_meses FROM ( + SELECT n.project_id, to_char(n.data, 'YYYY-MM') AS mes, count(*) AS linhas + FROM novas n + WHERE n.data IS NOT NULL + GROUP BY 1, 2 + ) x; + + ELSIF TG_OP = 'DELETE' THEN + v_cols := 'linha removida'; + SELECT jsonb_agg(x) INTO v_meses FROM ( + SELECT a.project_id, to_char(a.data, 'YYYY-MM') AS mes, count(*) AS linhas + FROM antigas a + WHERE a.data IS NOT NULL + GROUP BY 1, 2 + ) x; + + ELSE -- UPDATE + v_cols := 'categorização ou valor'; + SELECT jsonb_agg(x) INTO v_meses FROM ( + -- Mês NOVO da linha (e, quando a data mudou, também o ANTIGO: + -- o mês de origem perdeu um lançamento). + SELECT project_id, mes, count(*) AS linhas FROM ( + SELECT n.project_id, to_char(n.data, 'YYYY-MM') AS mes + FROM novas n JOIN antigas a ON a.id = n.id + WHERE n.data IS NOT NULL + AND (n.categoria_id IS DISTINCT FROM a.categoria_id + OR n.subcategoria_id IS DISTINCT FROM a.subcategoria_id + OR n.credito IS DISTINCT FROM a.credito + OR n.debito IS DISTINCT FROM a.debito + OR n.data IS DISTINCT FROM a.data) + UNION ALL + SELECT a.project_id, to_char(a.data, 'YYYY-MM') AS mes + FROM novas n JOIN antigas a ON a.id = n.id + WHERE a.data IS NOT NULL AND n.data IS DISTINCT FROM a.data + ) u + GROUP BY 1, 2 + ) x; + END IF; + + IF v_meses IS NULL THEN + RETURN NULL; -- nada relevante mudou (ex.: só manually_reviewed) + END IF; + + -- Marca APENAS o snapshot ativo de cada mês tocado: maior version, não + -- invalidado, e ainda não marcado (não reescreve o motivo do primeiro aviso). + WITH tocados AS ( + SELECT (m->>'project_id')::uuid AS project_id, + m->>'mes' AS mes, + (m->>'linhas')::int AS linhas + FROM jsonb_array_elements(v_meses) m + ), + ativos AS ( + SELECT DISTINCT ON (s.project_id, s.mes_referencia) + s.id, t.linhas + FROM dre_monthly_snapshots s + JOIN tocados t + ON t.project_id = s.project_id AND t.mes = s.mes_referencia + WHERE s.invalidated_at IS NULL + ORDER BY s.project_id, s.mes_referencia, s.version DESC + ) + UPDATE dre_monthly_snapshots s + SET desatualizado_em = now(), + desatualizado_motivo = jsonb_build_object( + 'op', TG_OP, + 'colunas', v_cols, + 'linhas', a.linhas, + 'em', to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') + ) + FROM ativos a + WHERE s.id = a.id + AND s.desatualizado_em IS NULL; + + -- ▼▼▼ ÚNICA DIFERENÇA PRETENDIDA vs. a baseline ▼▼▼ + -- O giro sai das MESMAS transações do DRE: se o mês mudou para um, mudou + -- para o outro. Mesma semântica (só marca, nunca apaga nem invalida) e + -- mesma regra do "primeiro aviso manda". + WITH tocados AS ( + SELECT (m->>'project_id')::uuid AS project_id, + m->>'mes' AS mes, + (m->>'linhas')::int AS linhas + FROM jsonb_array_elements(v_meses) m + ), + ativos_giro AS ( + SELECT DISTINCT ON (g.project_id, g.mes_referencia) + g.id, t.linhas + FROM giro_monthly_snapshots g + JOIN tocados t + ON t.project_id = g.project_id AND t.mes = g.mes_referencia + WHERE g.invalidated_at IS NULL + ORDER BY g.project_id, g.mes_referencia, g.version DESC + ) + UPDATE giro_monthly_snapshots g + SET desatualizado_em = now(), + desatualizado_motivo = jsonb_build_object( + 'op', TG_OP, + 'colunas', v_cols, + 'linhas', a.linhas, + 'em', to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') + ) + FROM ativos_giro a + WHERE g.id = a.id + AND g.desatualizado_em IS NULL; + -- ▲▲▲ fim da diferença ▲▲▲ + RETURN NULL; -- AFTER STATEMENT ignora o retorno +END; +$$; + +COMMENT ON FUNCTION "public"."marcar_snapshot_desatualizado"() IS +'Stale-by-event do DRE E DO GIRO: marca desatualizado_em no snapshot ATIVO dos meses cujas transações mudaram depois do fechamento. STATEMENT-level (custo O(meses), não O(linhas)) porque transactions é tabela quente — uma varredura serve as duas tabelas. Só marca; nunca apaga nem invalida.'; + +-- Os 3 triggers de `transactions` (ins/upd/del) continuam os mesmos e já apontam +-- para esta função: nada a recriar. + +NOTIFY pgrst, 'reload schema';