diff --git a/CLAUDE.md b/CLAUDE.md index a4654f6e..1302d0f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,10 +73,19 @@ do DRE). Mapa do código vivo × a spec (o que serve/aposenta/falta): ## Trilha atual -Reposicionamento financeiro client-first — plano faseado `docs/atros-v3/fin-07`. -Fases 0–4 consolidadas em `origin/main` (migrations aplicadas no remoto). -Próximo: Fase 5 (motor por evento). Handoff canônico: o mais recente -`docs/atros-v3/handoff-*.md`. +Execução do pente fino da jornada do cliente. Handoff canônico: o mais recente +`docs/atros-v3/handoff-*.md` — hoje **`handoff-2026-08-20b-a1-executada.md`**, que +diz **onde pegar** (E3-B, com a conta e a armadilha) e o roteiro do check ao vivo. + +**A lei do trabalho é `docs/valor/pente-fino/PLANO-DE-CORRECAO.md`** — e dentro dele o +**§0: a jornada canônica do cliente em 14 passos**. Ela é a régua: o que não serve a +jornada, sai do backlog. O diagnóstico que a originou (52 achados) está em +`docs/valor/pente-fino/RELATORIO-TECNICO.md` (autocontido), mas **a ordem dele foi +substituída** pelo plano — a decisão D1 (autonomia total do cliente) inverteu as +prioridades. + +**Estado em 20/ago:** a corrente vai do extrato à meta travada. Falta o **E3-B** +(antecipação) e o **check ao vivo** — toda a prova até aqui é estática. ## Ambiente diff --git a/db/ESTRUTURA.md b/db/ESTRUTURA.md index e30c6d28..7ef09d46 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-17**, até a migration **20260817000000**) +Fonte: `db/snapshot/schema.sql` (dump de **2026-08-18**, até a migration **20260818050000**) cruzado com o código vivo em `src/`. A verdade do banco é **a baseline `00000000000000_baseline.sql` + as migrations @@ -15,12 +15,12 @@ posteriores a ela**. Este documento é a leitura humana disso; a leitura de máq | | | |---|---| | Tabelas | **113** | -| Views | **12** | +| Views | **13** | | Funções | **73** | | Triggers | **93** | -| Policies RLS | **362** | +| Policies RLS | **353** | | Tabelas com RLS | **113** de 113 | -| Tabelas referenciadas no código | **89** de 113 | +| Tabelas referenciadas no código | **88** de 113 | ## Sinais que pedem ação @@ -30,7 +30,7 @@ Contas que este gerador fecha toda vez que roda. Item aqui não é opinião — _Nenhuma — todas as tabelas têm RLS._ -### Tabelas sem nenhuma referência em `src/` (24) +### Tabelas sem nenhuma referência em `src/` (25) Candidatas a morta. **Não deletar por esta lista sozinha** — pode ser lida por RPC, por script, ou pelo Supabase direto. É ponto de partida de investigação. @@ -44,6 +44,7 @@ por script, ou pelo Supabase direto. É ponto de partida de investigação. - `endividamento_contratos` - `file_uploads` - `financial_benchmarks` +- `learned_rules` - `linhas_operacionais` - `lite_categories` - `lite_categorization_rules` @@ -76,13 +77,14 @@ na frente do `public` pode sequestrar as chamadas de dentro da função. _Nenhuma._ -### Views sem `security_invoker` (12 de 12) +### Views sem `security_invoker` (13 de 13) View sem `security_invoker` consulta com os direitos de quem a criou, não de quem a lê — ela **contorna a RLS** das tabelas de baixo. - `client_project_access` - `client_project_summary` +- `project_access` - `projects_with_client` - `v_batch_coverage` - `v_lead_velocity_metrics` @@ -106,16 +108,16 @@ _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 | 24 | +| [`companies`](#companies) | 25 | id | ✅ | 6 | 1 | 3 | 25 | | [`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 | | [`invites`](#invites) | 9 | id | ✅ | 1 | 0 | 3 | 2 | | [`notifications`](#notifications) | 9 | id | ✅ | 3 | 0 | 3 | 8 | | [`profiles`](#profiles) | 13 | id | ✅ | 5 | 1 | 2 | 77 | -| [`project_members`](#project_members) | 6 | id | ✅ | 2 | 0 | 5 | 33 | +| [`project_members`](#project_members) | 6 | id | ✅ | 2 | 0 | 5 | 31 | | [`project_notifications`](#project_notifications) | 12 | id | ✅ | 4 | 0 | 3 | 4 | -| [`projects`](#projects) | 38 | id | ✅ | 9 | 1 | 9 | 111 | +| [`projects`](#projects) | 38 | id | ✅ | 7 | 1 | 9 | 111 | | [`settings`](#settings) | 7 | id | ✅ | 5 | 1 | 2 | 6 | ### Financeiro · ingestão e dado bruto @@ -133,7 +135,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 | 77 | +| [`transactions`](#transactions) | 32 | id | ✅ | 9 | 5 | 15 | 79 | ### Financeiro · categorização @@ -143,10 +145,10 @@ _O catálogo e as regras que dão sentido contábil a cada lançamento._ |---|--:|---|:-:|--:|--:|--:|--:| | [`categories`](#categories) | 11 | id | ✅ | 2 | 1 | 2 | **0** | | [`categorization_patterns`](#categorization_patterns) | 13 | id | ✅ | 4 | 1 | 2 | 1 | -| [`categorization_rules`](#categorization_rules) | 18 | id | ✅ | 2 | 1 | 4 | 2 | +| [`categorization_rules`](#categorization_rules) | 18 | id | ✅ | 1 | 1 | 4 | 2 | | [`dre_categories`](#dre_categories) | 10 | id | ✅ | 1 | 0 | 1 | 4 | | [`dre_subcategories`](#dre_subcategories) | 21 | id | ✅ | 3 | 0 | 5 | 5 | -| [`learned_rules`](#learned_rules) | 13 | id | ✅ | 2 | 2 | 7 | 4 | +| [`learned_rules`](#learned_rules) | 13 | id | ✅ | 1 | 2 | 7 | **0** | | [`linhas_operacionais`](#linhas_operacionais) | 11 | id | ✅ | 4 | 1 | 1 | **0** | | [`partner_policies`](#partner_policies) | 25 | id | ✅ | 2 | 1 | 2 | 4 | | [`subcategories`](#subcategories) | 9 | id | ✅ | 4 | 1 | 3 | **0** | @@ -162,7 +164,7 @@ _O fechamento do mês: snapshots imutáveis e seus detalhamentos._ | [`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 | 3 | -| [`dre_monthly_snapshots`](#dre_monthly_snapshots) | 16 | id | ✅ | 2 | 2 | 6 | 60 | +| [`dre_monthly_snapshots`](#dre_monthly_snapshots) | 16 | id | ✅ | 2 | 2 | 6 | 59 | | [`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 | @@ -173,7 +175,7 @@ _Leituras calculadas em cima da apuração._ | Tabela | Cols | PK | RLS | Policies | Triggers | Índices | Usos em `src/` | |---|--:|---|:-:|--:|--:|--:|--:| -| [`breakeven_analyses`](#breakeven_analyses) | 25 | id | ✅ | 2 | 2 | 2 | 4 | +| [`breakeven_analyses`](#breakeven_analyses) | 25 | id | ✅ | 2 | 2 | 2 | 3 | | [`cash_cycle_analyses`](#cash_cycle_analyses) | 24 | id | ✅ | 2 | 2 | 2 | 6 | | [`chh_analyses`](#chh_analyses) | 21 | id | ✅ | 2 | 1 | 2 | 5 | | [`cost_audit_items`](#cost_audit_items) | 14 | id | ✅ | 4 | 1 | 2 | 2 | @@ -182,7 +184,7 @@ _Leituras calculadas em cima da apuração._ | [`health_scores`](#health_scores) | 9 | id | ✅ | 2 | 0 | 2 | 6 | | [`margin_analyses`](#margin_analyses) | 20 | id | ✅ | 4 | 1 | 1 | 7 | | [`margin_items`](#margin_items) | 22 | id | ✅ | 4 | 0 | 4 | 4 | -| [`module_analyses`](#module_analyses) | 23 | id | ✅ | 4 | 3 | 4 | 30 | +| [`module_analyses`](#module_analyses) | 23 | id | ✅ | 4 | 3 | 4 | 19 | | [`supplier_analyses`](#supplier_analyses) | 21 | id | ✅ | 2 | 2 | 2 | 3 | ### Endividamento e crédito @@ -220,7 +222,7 @@ _A meta do cliente, seu horizonte e a distribuição sazonal._ | [`commercial_target_members`](#commercial_target_members) | 8 | id | ✅ | 2 | 1 | 1 | 3 | | [`commercial_targets`](#commercial_targets) | 13 | id | ✅ | 2 | 1 | 1 | 6 | | [`financial_goals`](#financial_goals) | 11 | id | ✅ | 4 | 1 | 1 | 4 | -| [`plano_voo_snapshots`](#plano_voo_snapshots) | 21 | id | ✅ | 4 | 1 | 3 | 29 | +| [`plano_voo_snapshots`](#plano_voo_snapshots) | 21 | id | ✅ | 4 | 1 | 3 | 32 | ### HTR · hipótese, teste, resultado @@ -232,7 +234,7 @@ _O cérebro: onde a ação-raiz nasce e é medida._ | [`atr_hypotheses`](#atr_hypotheses) | 33 | id | ✅ | 4 | 2 | 4 | 28 | | [`atr_results`](#atr_results) | 17 | id | ✅ | 3 | 1 | 2 | 12 | | [`atr_tests`](#atr_tests) | 25 | id | ✅ | 4 | 2 | 3 | 23 | -| [`cockpit_actions`](#cockpit_actions) | 23 | id | ✅ | 3 | 1 | 4 | 7 | +| [`cockpit_actions`](#cockpit_actions) | 23 | id | ✅ | 3 | 1 | 4 | 6 | | [`htr_chat_messages`](#htr_chat_messages) | 11 | id | ✅ | 4 | 0 | 2 | 2 | ### Comercial · CRM e leads @@ -243,7 +245,7 @@ _Funil, enriquecimento de dados e comissionamento._ |---|--:|---|:-:|--:|--:|--:|--:| | [`assertiva_config`](#assertiva_config) | 13 | id | ✅ | 1 | 0 | 0 | 2 | | [`assertiva_enrichment_logs`](#assertiva_enrichment_logs) | 15 | id | ✅ | 2 | 0 | 2 | 1 | -| [`client_socios`](#client_socios) | 11 | id | ✅ | 4 | 1 | 2 | 1 | +| [`client_socios`](#client_socios) | 11 | id | ✅ | 4 | 1 | 2 | 4 | | [`contracts`](#contracts) | 15 | id | ✅ | 2 | 2 | 2 | 6 | | [`lead_acoes`](#lead_acoes) | 8 | id | ✅ | 2 | 1 | 2 | 16 | | [`lead_socios`](#lead_socios) | 20 | id | ✅ | 2 | 0 | 2 | 17 | @@ -261,14 +263,14 @@ _Pessoas, entregas, reuniões e catálogo de produto/serviço._ | [`estoque_produtos`](#estoque_produtos) | 12 | id | ✅ | 4 | 1 | 1 | 8 | | [`meetings`](#meetings) | 20 | id | ✅ | 0 | 1 | 1 | **0** | | [`products_services`](#products_services) | 12 | id | ✅ | 4 | 2 | 1 | 5 | -| [`project_deliverables`](#project_deliverables) | 13 | id | ✅ | 4 | 1 | 2 | **0** | -| [`project_meetings`](#project_meetings) | 16 | id | ✅ | 2 | 1 | 3 | **0** | +| [`project_deliverables`](#project_deliverables) | 13 | id | ✅ | 3 | 1 | 2 | **0** | +| [`project_meetings`](#project_meetings) | 16 | id | ✅ | 1 | 1 | 3 | **0** | | [`project_module_unification`](#project_module_unification) | 8 | id | ✅ | 4 | 1 | 1 | 3 | -| [`project_modules`](#project_modules) | 13 | id | ✅ | 2 | 2 | 4 | 22 | -| [`project_tasks`](#project_tasks) | 13 | id | ✅ | 2 | 1 | 3 | **0** | +| [`project_modules`](#project_modules) | 13 | id | ✅ | 1 | 2 | 4 | 19 | +| [`project_tasks`](#project_tasks) | 13 | id | ✅ | 1 | 1 | 3 | **0** | | [`project_transitions`](#project_transitions) | 8 | id | ✅ | 5 | 0 | 1 | **0** | | [`service_composition`](#service_composition) | 5 | id | ✅ | 3 | 0 | 1 | 3 | -| [`time_entries`](#time_entries) | 13 | id | ✅ | 2 | 2 | 3 | **0** | +| [`time_entries`](#time_entries) | 13 | id | ✅ | 1 | 2 | 3 | **0** | ### Onboarding e conteúdo @@ -291,7 +293,7 @@ _Telemetria de chamadas, dossiês e memória do consultor._ | Tabela | Cols | PK | RLS | Policies | Triggers | Índices | Usos em `src/` | |---|--:|---|:-:|--:|--:|--:|--:| | [`ai_calls`](#ai_calls) | 18 | id | ✅ | 4 | 0 | 4 | 3 | -| [`company_ai_dossiers`](#company_ai_dossiers) | 9 | id | ✅ | 4 | 0 | 1 | 4 | +| [`company_ai_dossiers`](#company_ai_dossiers) | 9 | id | ✅ | 4 | 0 | 1 | 3 | | [`consultant_memories`](#consultant_memories) | 10 | id | ✅ | 4 | 0 | 1 | 5 | ### Lite (produto paralelo) @@ -426,7 +428,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/`:** 24 +**Domínio:** Núcleo da plataforma · **RLS:** ✅ 6 policies · **Usos em `src/`:** 25 | Coluna | Tipo | Nulo | Padrão | |---|---|:-:|---| @@ -595,7 +597,7 @@ _Tabelas do produto Lite — namespace próprio, não se mistura com o principal > Acesso de consultores e clientes a projetos específicos. Substitui client_project_access. -**Domínio:** Núcleo da plataforma · **RLS:** ✅ 2 policies · **Usos em `src/`:** 33 +**Domínio:** Núcleo da plataforma · **RLS:** ✅ 2 policies · **Usos em `src/`:** 31 | Coluna | Tipo | Nulo | Padrão | |---|---|:-:|---| @@ -633,7 +635,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/`:** 111 +**Domínio:** Núcleo da plataforma · **RLS:** ✅ 7 policies · **Usos em `src/`:** 111 | Coluna | Tipo | Nulo | Padrão | |---|---|:-:|---| @@ -944,7 +946,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/`:** 77 +**Domínio:** Financeiro · ingestão e dado bruto · **RLS:** ✅ 9 policies · **Usos em `src/`:** 79 | Coluna | Tipo | Nulo | Padrão | |---|---|:-:|---| @@ -1031,7 +1033,7 @@ _Tabelas do produto Lite — namespace próprio, não se mistura com o principal ### categorization_rules -**Domínio:** Financeiro · categorização · **RLS:** ✅ 2 policies · **Usos em `src/`:** 2 +**Domínio:** Financeiro · categorização · **RLS:** ✅ 1 policies · **Usos em `src/`:** 2 | Coluna | Tipo | Nulo | Padrão | |---|---|:-:|---| @@ -1111,7 +1113,7 @@ _Tabelas do produto Lite — namespace próprio, não se mistura com o principal ### learned_rules -**Domínio:** Financeiro · categorização · **RLS:** ✅ 2 policies · **Usos em `src/`:** 4 +**Domínio:** Financeiro · categorização · **RLS:** ✅ 1 policies · **Usos em `src/`:** 0 | Coluna | Tipo | Nulo | Padrão | |---|---|:-:|---| @@ -1328,7 +1330,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/`:** 60 +**Domínio:** Financeiro · apuração e DRE · **RLS:** ✅ 2 policies · **Usos em `src/`:** 59 | Coluna | Tipo | Nulo | Padrão | |---|---|:-:|---| @@ -1476,7 +1478,7 @@ _Tabelas do produto Lite — namespace próprio, não se mistura com o principal > Output da Análise #5 (Ponto de Equilíbrio). Calculada por BreakevenService. -**Domínio:** Financeiro · análises derivadas · **RLS:** ✅ 2 policies · **Usos em `src/`:** 4 +**Domínio:** Financeiro · análises derivadas · **RLS:** ✅ 2 policies · **Usos em `src/`:** 3 | Coluna | Tipo | Nulo | Padrão | |---|---|:-:|---| @@ -1732,7 +1734,7 @@ _Tabelas do produto Lite — namespace próprio, não se mistura com o principal ### module_analyses -**Domínio:** Financeiro · análises derivadas · **RLS:** ✅ 4 policies · **Usos em `src/`:** 30 +**Domínio:** Financeiro · análises derivadas · **RLS:** ✅ 4 policies · **Usos em `src/`:** 19 | Coluna | Tipo | Nulo | Padrão | |---|---|:-:|---| @@ -2186,7 +2188,7 @@ _Tabelas do produto Lite — namespace próprio, não se mistura com o principal > Snapshots do Plano de Voo gerados a partir das 30 análises consolidadas -**Domínio:** Plano de Voo e metas · **RLS:** ✅ 4 policies · **Usos em `src/`:** 29 +**Domínio:** Plano de Voo e metas · **RLS:** ✅ 4 policies · **Usos em `src/`:** 32 | Coluna | Tipo | Nulo | Padrão | |---|---|:-:|---| @@ -2359,7 +2361,7 @@ _Tabelas do produto Lite — namespace próprio, não se mistura com o principal ### cockpit_actions -**Domínio:** HTR · hipótese, teste, resultado · **RLS:** ✅ 3 policies · **Usos em `src/`:** 7 +**Domínio:** HTR · hipótese, teste, resultado · **RLS:** ✅ 3 policies · **Usos em `src/`:** 6 | Coluna | Tipo | Nulo | Padrão | |---|---|:-:|---| @@ -2467,7 +2469,7 @@ _Tabelas do produto Lite — namespace próprio, não se mistura com o principal > Sócios pessoa física de um cliente. Alimentado pelo QSA da Receita (já guardado em companies.dados_receita) ou à mão — sócio de fato não registrado é comum em PME. Usado pelo detector de partes relacionadas para reconhecer movimento de sócio no extrato. -**Domínio:** Comercial · CRM e leads · **RLS:** ✅ 4 policies · **Usos em `src/`:** 1 +**Domínio:** Comercial · CRM e leads · **RLS:** ✅ 4 policies · **Usos em `src/`:** 4 | Coluna | Tipo | Nulo | Padrão | |---|---|:-:|---| @@ -2824,7 +2826,7 @@ _Tabelas do produto Lite — namespace próprio, não se mistura com o principal ### project_deliverables -**Domínio:** Gestão e operação · **RLS:** ✅ 4 policies · **Usos em `src/`:** 0 +**Domínio:** Gestão e operação · **RLS:** ✅ 3 policies · **Usos em `src/`:** 0 | Coluna | Tipo | Nulo | Padrão | |---|---|:-:|---| @@ -2848,7 +2850,7 @@ _Tabelas do produto Lite — namespace próprio, não se mistura com o principal ### project_meetings -**Domínio:** Gestão e operação · **RLS:** ✅ 2 policies · **Usos em `src/`:** 0 +**Domínio:** Gestão e operação · **RLS:** ✅ 1 policies · **Usos em `src/`:** 0 | Coluna | Tipo | Nulo | Padrão | |---|---|:-:|---| @@ -2896,7 +2898,7 @@ _Tabelas do produto Lite — namespace próprio, não se mistura com o principal ### project_modules -**Domínio:** Gestão e operação · **RLS:** ✅ 2 policies · **Usos em `src/`:** 22 +**Domínio:** Gestão e operação · **RLS:** ✅ 1 policies · **Usos em `src/`:** 19 | Coluna | Tipo | Nulo | Padrão | |---|---|:-:|---| @@ -2920,7 +2922,7 @@ _Tabelas do produto Lite — namespace próprio, não se mistura com o principal ### project_tasks -**Domínio:** Gestão e operação · **RLS:** ✅ 2 policies · **Usos em `src/`:** 0 +**Domínio:** Gestão e operação · **RLS:** ✅ 1 policies · **Usos em `src/`:** 0 | Coluna | Tipo | Nulo | Padrão | |---|---|:-:|---| @@ -2977,7 +2979,7 @@ _Tabelas do produto Lite — namespace próprio, não se mistura com o principal ### time_entries -**Domínio:** Gestão e operação · **RLS:** ✅ 2 policies · **Usos em `src/`:** 0 +**Domínio:** Gestão e operação · **RLS:** ✅ 1 policies · **Usos em `src/`:** 0 | Coluna | Tipo | Nulo | Padrão | |---|---|:-:|---| @@ -3187,7 +3189,7 @@ _Tabelas do produto Lite — namespace próprio, não se mistura com o principal ### company_ai_dossiers -**Domínio:** IA · **RLS:** ✅ 4 policies · **Usos em `src/`:** 4 +**Domínio:** IA · **RLS:** ✅ 4 policies · **Usos em `src/`:** 3 | Coluna | Tipo | Nulo | Padrão | |---|---|:-:|---| @@ -3319,6 +3321,7 @@ _Tabelas do produto Lite — namespace próprio, não se mistura com o principal |---|:-:| | `client_project_access` | ❌ | | `client_project_summary` | ❌ | +| `project_access` | ❌ | | `projects_with_client` | ❌ | | `v_batch_coverage` | ❌ | | `v_cohort_metrics` | ❌ | @@ -3352,7 +3355,7 @@ _Tabelas do produto Lite — namespace próprio, não se mistura com o principal | `get_abc_buckets_despesas` | — | — | 2 | | `get_abc_buckets_despesas_grupo` | — | — | 1 | | `get_category_breakdown` | — | — | 0 | -| `get_dre_readiness` | ✅ | ✅ | 3 | +| `get_dre_readiness` | ✅ | ✅ | 1 | | `get_dre_summary` | — | — | 0 | | `get_exercicios_fiscais` | — | — | 1 | | `get_lancamentos_unicos` | ✅ | ✅ | 5 | diff --git a/db/snapshot/SNAPSHOT.md b/db/snapshot/SNAPSHOT.md index 56a7dbcd..a0a8cb9e 100644 --- a/db/snapshot/SNAPSHOT.md +++ b/db/snapshot/SNAPSHOT.md @@ -4,12 +4,13 @@ | Campo | Valor | |---|---| -| **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**. | +| **Status** | 🔴 **OBSOLETO desde 2026-08-20** — TRÊS migrations posteriores a este dump, **todas já aplicadas no remoto**. Duas aplicadas DEPOIS deste dump (`20260820010000` readiness para de ler a escada morta · `20260820020000` a view perde o placar das 30 análises). **Não tratar `schema.sql` como verdade até o re-dump.** O que mudou: `get_dre_readiness` deriva `publish_status` da contagem de meses fechados; `vw_company_context` perdeu `analyses_completed` e `analyses_total`. Antes disso era 🟢 AUTORITATIVO — inclui as 6 migrations de 18/ago: `categorization_rules.match_type`, as 3 fatias do **SaaS sem consultor** e os 2 lotes do **fechamento do vazamento multi-tenant**. Todas aplicadas no SQL Editor e conferidas neste dump. | +| Dump gerado em | **2026-08-18** (`supabase db dump --linked`, `--role-only` e `--data-only`, projeto `tdlxqqgechxhkygdmsxq`, CLI v2.84.0) | +| Commit git no dump | `feat/partes-relacionadas-e-regra-token` `6bfcadd` + working tree (as **6** migrations estão aplicadas no remoto e **ainda não commitadas** — decisão: consertar o que está aberto antes de commitar) | +| `ultima_migration_incluida` | **`20260818050000`** | +| `ultima_migration_no_repo` | **`20260820030000`** ⚠️ **> incluída ⇒ obsoleto** | +| **`20260820030000` (E3-B)** | ✅ **aplicada no remoto em 2026-08-20 e conferida no banco vivo.** Amplia o CHECK de `dre_detalhamento.modo` com `'por_taxa'`. Prova não-destrutiva (insert com `project_id` inexistente, lendo o código do erro): `modo='por_taxa'` → `23503` (FK — passou pelo CHECK) e, como controle, `modo='modo_que_nao_existe'` → `23514` (recusado). Nenhuma linha escrita. | +| **Veredito** | 🔴 **obsoleto** (era 🟢 até 18/ago) — `schema.sql` **15.054 linhas**; `roles.sql` **13** (diff vazio); `seed.sql` **25.138**. Gate: `npx tsx scripts/saas/verificar-dump.ts`. | ## A regra, depois da baseline de 2026-08-11 @@ -26,6 +27,91 @@ 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-18b, fechamento do vazamento multi-tenant) + +Migrations `20260818040000` (escopa 9 tabelas de projeto) e `20260818050000` (`projects`, +`profiles`, `class_access`). Fecham as policies que liberavam acesso por **estar logado** +(`auth.role() = authenticated`, `USING (true)`) — que, somando-se por OR, anulavam o escopo por +vínculo das vizinhas. + +| Objeto | Antes | Depois | Δ | +|---|--:|--:|--:| +| Tabelas | 113 | **113** | 0 | +| Views | 13 | **13** | 0 | +| Funções · Triggers · Índices | 73 · 92 · 257 | **iguais** | 0 | +| **Policies** | 362 | **353** | **−9** | +| Tabelas com RLS | 113 | **113 de 113** | 0 | + +`schema.sql` 15.043 → **15.054 linhas**. `roles.sql` diff vazio. `seed.sql` **25.138**. + +**O −9 foi previsto antes do dump e bateu exato**: −7 no lote 1 (sete tabelas trocaram 2 policies +abertas por 1 escopada; `dre_subcategories` e `activity_log` saíram no zero a zero) e −2 no lote 2 +(`projects` perdeu 3 e ganhou 1). Prever o delta antes é o que separa "a migration rodou" de "a +migration fez o que eu quis". + +**Gate contra a realidade** (`npx tsx scripts/saas/verificar-dump.ts`, lê este dump): + +- **0** policies abertas por estar logado fora de catálogo global — das 8 que restam, todas são + SELECT em tabela sem chave de tenant (`dre_categories`, `financial_benchmarks`, `class_*`, + `library_content`, `subcategories`, `categorization_patterns`): conhecimento compartilhado da + casa, não dado de cliente; +- **108** policies na view `project_access` (eram 97 depois das fatias 1–4). + +**A armadilha que a policy de `profiles` teve de desviar.** `project_members` tem RLS own-row +(`project_members_self`), então subselect nela dentro de policy roda como o usuário e procurar o +vínculo de OUTRA pessoa volta **vazio, sem erro**. A policy de colega-de-projeto usa a view +`project_access` (roda como owner) de propósito. Escrita com a tabela, passaria no review e negaria +tudo em silêncio. `projects_members` pode usar a tabela porque filtra `pm.user_id = auth.uid()`. + +**Ninguém foi trancado do lado de fora.** A conferência de quem perdeu o bypass por papel global +voltou **vazia**: não existe nenhum `profiles.role` em `(consultor,sdr)` no banco. O bypass que +aparecia em 120 lugares não protegia uma única pessoa — o que, retroativamente, confirma a decisão +do SaaS sem consultor. + +### Validação do re-dump (2026-08-18, SaaS sem consultor — fatias 1 a 4) + +Quatro migrations aplicadas no SQL Editor: `20260818000000` (`categorization_rules.match_type`, +da leva de regra por token) e as três do reposicionamento de acesso — +`20260818010000` (o dono gera e trava a própria meta), `20260818020000` (view `project_access` ++ 139 policies) e `20260818030000` (o dono conserta o próprio cadastro). + +| Objeto | Antes | Depois | Δ | +|---|--:|--:|--:| +| Tabelas | 113 | **113** | 0 | +| **Views** | 12 | **13** | **+1** | +| Funções | 73 | **73** | 0 | +| Triggers | 92 | **92** | 0 | +| **Policies** | 362 | **362** | **0** | +| Tabelas com RLS | 113 | **113 de 113** | 0 | +| Índices | 257 | **257** | 0 | + +`roles.sql`: diff vazio. `schema.sql` **15.206 → 15.043 linhas** — encolheu porque 139 policies +perderam um disjunto inteiro. `seed.sql` 25.130 → **25.138**. + +**O número que prova a leva é `Policies +0`.** Foram 146 pares `DROP`+`CREATE` e o saldo é zero: +nenhuma policy se perdeu no caminho, nenhuma virou duplicata. Numa troca mecânica desse tamanho é +exatamente o que pode dar errado sem ninguém ver — policy dropada em tabela com RLS ligada não dá +erro, só passa a negar tudo em silêncio. + +**O dump confirma a intenção, não só a migration.** `npx tsx scripts/saas/verificar-dump.ts` lê +`schema.sql` e fecha as contas contra a realidade: + +- **0** policies ainda apontando pra `client_project_access`; +- **97** policies passaram a usar `project_access` (era a previsão exata do transformador); +- **4** policies ainda mencionam `consultor` — e são as 4 previstas (`commercial_*` × 3 e + `leads_owner_insert`), staff-only por desenho, deixadas de fora porque remover o bypass delas + não deixaria nenhum disjunto, ou seja, negariam tudo; +- a view `project_access` existe, **não** filtra `role = cliente` (senão excluiria o consultor) e + **não** tem `security_invoker` — roda como owner, igual à irmã, senão as 97 policies passariam a + enxergar só o próprio vínculo e quebrariam em silêncio. + +No `ESTRUTURA.md` o diff é de 6 linhas: views 12→13, `project_access` entra na lista de views sem +`security_invoker` (deliberado) e `project_members` cai de 33 para 32 usos em `src/` — é o `.from()` +que sumiu quando `canOperateProject` passou a delegar pra `canAccessProject`. + +Os 4 `.from()` para tabela inexistente (`budget_items`, `budgets`, `pulse_checks`, +`system_notifications`) seguem abertos desde o marco zero — não são desta leva. + ### Validação do re-dump (2026-08-17, `giro_monthly_snapshots` — G2) Migration `20260817000000_giro_monthly_snapshots.sql`, aplicada no SQL Editor. Cria a diff --git a/db/snapshot/schema.sql b/db/snapshot/schema.sql index 15cd5936..6b82357f 100644 --- a/db/snapshot/schema.sql +++ b/db/snapshot/schema.sql @@ -4338,7 +4338,8 @@ CREATE TABLE IF NOT EXISTS "public"."categorization_rules" ( "is_seed" boolean DEFAULT false NOT NULL, "linha_id" "uuid", CONSTRAINT "categorization_rules_direction_check" CHECK (("direction" = ANY (ARRAY['credit'::"text", 'debit'::"text"]))), - CONSTRAINT "categorization_rules_match_type_check" CHECK (("match_type" = ANY (ARRAY['exact'::"text", 'contains'::"text", 'starts_with'::"text", 'regex'::"text"]))) + CONSTRAINT "categorization_rules_match_type_check" CHECK (("match_type" = ANY (ARRAY['exact'::"text", 'contains'::"text", 'starts_with'::"text", 'regex'::"text"]))), + CONSTRAINT "categorization_rules_match_type_chk" CHECK (("match_type" = ANY (ARRAY['exact'::"text", 'contains'::"text"]))) ); @@ -4349,6 +4350,10 @@ COMMENT ON COLUMN "public"."categorization_rules"."direction" IS 'Direção do d +COMMENT ON COLUMN "public"."categorization_rules"."match_type" IS 'Como o pattern casa com transactions.lancamento_tratado: exact = igualdade (padrão histórico) | contains = ILIKE %pattern%. Regra contains só se aplica a linhas SEM contraparte detectada (contraparte_tipo IS NULL) — ver filtroDeRegra() em financial-document-service.ts.'; + + + COMMENT ON COLUMN "public"."categorization_rules"."is_seed" IS 'Modo Demo: true = regras pré-calibradas pela curadoria (preservar para garantir auto-categorização perfeita); false = aprendidas durante a demo.'; @@ -7093,6 +7098,23 @@ COMMENT ON CONSTRAINT "profiles_role_check" ON "public"."profiles" IS '5 roles: +CREATE OR REPLACE VIEW "public"."project_access" AS + SELECT "id", + "user_id", + "project_id", + "role", + "granted_by", + "created_at" AS "granted_at" + FROM "public"."project_members"; + + +ALTER VIEW "public"."project_access" OWNER TO "postgres"; + + +COMMENT ON VIEW "public"."project_access" IS 'Vínculo usuário×projeto SEM filtro de papel — cliente (dono) e consultor (convidado) têm o mesmo acesso (SaaS sem consultor, 18/ago/2026). Substitui client_project_access nas policies. NÃO usar para inserções — use project_members.'; + + + CREATE TABLE IF NOT EXISTS "public"."project_deliverables" ( "id" "uuid" DEFAULT "extensions"."uuid_generate_v4"() NOT NULL, "project_id" "uuid" NOT NULL, @@ -11317,7 +11339,9 @@ CREATE POLICY "Admins podem gerenciar subcategorias" ON "public"."subcategories" -CREATE POLICY "Atualização de subcategorias personalizadas" ON "public"."dre_subcategories" FOR UPDATE USING ((("auth"."role"() = 'authenticated'::"text") AND ("is_custom" = true))); +CREATE POLICY "Atualização de subcategorias personalizadas" ON "public"."dre_subcategories" FOR UPDATE USING ((("is_custom" = true) AND ("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))))); @@ -11381,11 +11405,15 @@ CREATE POLICY "Consultor project access" ON "public"."supplier_analyses" USING ( -CREATE POLICY "Criação de subcategorias personalizadas" ON "public"."dre_subcategories" FOR INSERT WITH CHECK ((("auth"."role"() = 'authenticated'::"text") AND ("is_custom" = true))); +CREATE POLICY "Criação de subcategorias personalizadas" ON "public"."dre_subcategories" FOR INSERT WITH CHECK ((("is_custom" = true) AND ("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))))); -CREATE POLICY "Leitura de subcategorias" ON "public"."dre_subcategories" FOR SELECT USING ((("is_system" = true) OR ("project_id" IS NULL) OR ("auth"."role"() = 'authenticated'::"text"))); +CREATE POLICY "Leitura de subcategorias" ON "public"."dre_subcategories" FOR SELECT USING ((("is_system" = true) OR ("project_id" IS NULL) OR "public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); @@ -11393,10 +11421,6 @@ CREATE POLICY "Leitura pública de categorias" ON "public"."dre_categories" FOR -CREATE POLICY "Sistema pode inserir logs" ON "public"."activity_log" FOR INSERT WITH CHECK (true); - - - CREATE POLICY "Users can insert company" ON "public"."lite_companies" FOR INSERT WITH CHECK (("auth"."uid"() IS NOT NULL)); @@ -11439,78 +11463,10 @@ CREATE POLICY "Users can view own profile" ON "public"."lite_users" FOR SELECT U -CREATE POLICY "Usuários autenticados podem atualizar projetos" ON "public"."projects" FOR UPDATE USING (("auth"."role"() = 'authenticated'::"text")); - - - -CREATE POLICY "Usuários autenticados podem gerenciar entregáveis" ON "public"."project_deliverables" USING (("auth"."role"() = 'authenticated'::"text")); - - - -CREATE POLICY "Usuários autenticados podem gerenciar módulos" ON "public"."project_modules" USING (("auth"."role"() = 'authenticated'::"text")); - - - -CREATE POLICY "Usuários autenticados podem gerenciar regras" ON "public"."categorization_rules" USING (("auth"."role"() = 'authenticated'::"text")); - - - -CREATE POLICY "Usuários autenticados podem gerenciar regras" ON "public"."learned_rules" USING (("auth"."role"() = 'authenticated'::"text")); - - - -CREATE POLICY "Usuários autenticados podem gerenciar reuniões" ON "public"."project_meetings" USING (("auth"."role"() = 'authenticated'::"text")); - - - -CREATE POLICY "Usuários autenticados podem gerenciar tarefas" ON "public"."project_tasks" USING (("auth"."role"() = 'authenticated'::"text")); - - - -CREATE POLICY "Usuários autenticados podem inserir projetos" ON "public"."projects" FOR INSERT WITH CHECK (("auth"."role"() = 'authenticated'::"text")); - - - -CREATE POLICY "Usuários autenticados podem ver entregáveis" ON "public"."project_deliverables" FOR SELECT USING (("auth"."role"() = 'authenticated'::"text")); - - - -CREATE POLICY "Usuários autenticados podem ver logs" ON "public"."activity_log" FOR SELECT USING (("auth"."role"() = 'authenticated'::"text")); - - - -CREATE POLICY "Usuários autenticados podem ver módulos" ON "public"."project_modules" FOR SELECT USING (("auth"."role"() = 'authenticated'::"text")); - - - -CREATE POLICY "Usuários autenticados podem ver projetos" ON "public"."projects" FOR SELECT USING (("auth"."role"() = 'authenticated'::"text")); - - - -CREATE POLICY "Usuários autenticados podem ver registros de horas" ON "public"."time_entries" FOR SELECT USING (("auth"."role"() = 'authenticated'::"text")); - - - -CREATE POLICY "Usuários autenticados podem ver regras" ON "public"."categorization_rules" FOR SELECT USING (("auth"."role"() = 'authenticated'::"text")); - - - -CREATE POLICY "Usuários autenticados podem ver regras" ON "public"."learned_rules" FOR SELECT USING (("auth"."role"() = 'authenticated'::"text")); - - - -CREATE POLICY "Usuários autenticados podem ver reuniões" ON "public"."project_meetings" FOR SELECT USING (("auth"."role"() = 'authenticated'::"text")); - - - CREATE POLICY "Usuários autenticados podem ver subcategorias" ON "public"."subcategories" FOR SELECT USING (("auth"."role"() = 'authenticated'::"text")); -CREATE POLICY "Usuários autenticados podem ver tarefas" ON "public"."project_tasks" FOR SELECT USING (("auth"."role"() = 'authenticated'::"text")); - - - CREATE POLICY "Usuários gerenciam próprias configurações" ON "public"."settings" USING (("auth"."uid"() = "user_id")); @@ -11529,10 +11485,6 @@ CREATE POLICY "Usuários podem gerenciar próprios registros" ON "public"."time_ -CREATE POLICY "Usuários podem ver todos os perfis" ON "public"."profiles" FOR SELECT USING (true); - - - CREATE POLICY "Usuários veem próprias configurações" ON "public"."settings" FOR SELECT USING ((("auth"."uid"() = "user_id") OR ("user_id" IS NULL))); @@ -11552,6 +11504,14 @@ CREATE POLICY "acoes_via_lead" ON "public"."lead_acoes" USING ((EXISTS ( SELECT ALTER TABLE "public"."activity_log" ENABLE ROW LEVEL SECURITY; +CREATE POLICY "activity_log_insert_own" ON "public"."activity_log" FOR INSERT WITH CHECK ((("user_id" = "auth"."uid"()) OR "public"."is_admin"("auth"."uid"()))); + + + +CREATE POLICY "activity_log_select_own" ON "public"."activity_log" FOR SELECT USING ((("user_id" = "auth"."uid"()) OR "public"."is_admin"("auth"."uid"()))); + + + CREATE POLICY "admin_full_access_sdr_metas" ON "public"."sdr_metas" USING ((EXISTS ( SELECT 1 FROM "public"."profiles" WHERE (("profiles"."id" = "auth"."uid"()) AND ("profiles"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text"])))))); @@ -11687,19 +11647,15 @@ CREATE POLICY "balance_anchors_delete" ON "public"."bank_account_balance_anchors -CREATE POLICY "balance_anchors_insert" ON "public"."bank_account_balance_anchors" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "balance_anchors_insert" ON "public"."bank_account_balance_anchors" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "balance_anchors_select" ON "public"."bank_account_balance_anchors" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "balance_anchors_select" ON "public"."bank_account_balance_anchors" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); @@ -11709,55 +11665,51 @@ ALTER TABLE "public"."bank_account_balance_anchors" ENABLE ROW LEVEL SECURITY; ALTER TABLE "public"."bank_accounts" ENABLE ROW LEVEL SECURITY; -CREATE POLICY "bank_accounts_cliente_insert" ON "public"."bank_accounts" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("company_id" IN ( SELECT "co"."id" +CREATE POLICY "bank_accounts_cliente_insert" ON "public"."bank_accounts" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("company_id" IN ( SELECT "co"."id" FROM (("public"."companies" "co" JOIN "public"."projects" "pr" ON (("pr"."client_id" = "co"."client_id"))) - JOIN "public"."client_project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) WHERE ("pa"."user_id" = "auth"."uid"()))))); -CREATE POLICY "bank_accounts_cliente_update" ON "public"."bank_accounts" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("company_id" IN ( SELECT "co"."id" +CREATE POLICY "bank_accounts_cliente_update" ON "public"."bank_accounts" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR ("company_id" IN ( SELECT "co"."id" FROM (("public"."companies" "co" JOIN "public"."projects" "pr" ON (("pr"."client_id" = "co"."client_id"))) - JOIN "public"."client_project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) - WHERE ("pa"."user_id" = "auth"."uid"()))))) WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("company_id" IN ( SELECT "co"."id" + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + WHERE ("pa"."user_id" = "auth"."uid"()))))) WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("company_id" IN ( SELECT "co"."id" + FROM (("public"."companies" "co" + JOIN "public"."projects" "pr" ON (("pr"."client_id" = "co"."client_id"))) + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + WHERE ("pa"."user_id" = "auth"."uid"()))))); + + + +CREATE POLICY "bank_accounts_delete" ON "public"."bank_accounts" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR ("company_id" IN ( SELECT "co"."id" FROM (("public"."companies" "co" JOIN "public"."projects" "pr" ON (("pr"."client_id" = "co"."client_id"))) - JOIN "public"."client_project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) WHERE ("pa"."user_id" = "auth"."uid"()))))); -CREATE POLICY "bank_accounts_delete" ON "public"."bank_accounts" FOR DELETE USING ("public"."is_admin"("auth"."uid"())); +COMMENT ON POLICY "bank_accounts_delete" ON "public"."bank_accounts" IS 'SaaS sem consultor: cadastro é do dono — conta bancária cadastrada errado ele mesmo remove. Dado financeiro apurado (transactions) segue admin-only.'; -CREATE POLICY "bank_accounts_insert" ON "public"."bank_accounts" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))))); +CREATE POLICY "bank_accounts_insert" ON "public"."bank_accounts" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"())); -CREATE POLICY "bank_accounts_select" ON "public"."bank_accounts" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("company_id" IN ( SELECT "co"."id" +CREATE POLICY "bank_accounts_select" ON "public"."bank_accounts" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR ("company_id" IN ( SELECT "co"."id" FROM (("public"."companies" "co" JOIN "public"."projects" "pr" ON (("pr"."client_id" = "co"."client_id"))) - JOIN "public"."client_project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) WHERE ("pa"."user_id" = "auth"."uid"()))))); -CREATE POLICY "bank_accounts_update" ON "public"."bank_accounts" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))))); +CREATE POLICY "bank_accounts_update" ON "public"."bank_accounts" FOR UPDATE USING ("public"."is_admin"("auth"."uid"())); @@ -11770,7 +11722,7 @@ CREATE POLICY "bank_balances_delete" ON "public"."bank_balances" FOR DELETE USIN CREATE POLICY "bank_balances_insert" ON "public"."bank_balances" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 FROM "public"."project_members" "pm" - WHERE (("pm"."project_id" = "bank_balances"."project_id") AND ("pm"."user_id" = "auth"."uid"()) AND ("pm"."role" = 'consultor'::"text")))))); + WHERE (("pm"."project_id" = "bank_balances"."project_id") AND ("pm"."user_id" = "auth"."uid"())))))); @@ -11786,35 +11738,27 @@ ALTER TABLE "public"."breakeven_analyses" ENABLE ROW LEVEL SECURITY; ALTER TABLE "public"."card_acquirer_rates" ENABLE ROW LEVEL SECURITY; -CREATE POLICY "card_rates_delete" ON "public"."card_acquirer_rates" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "card_rates_delete" ON "public"."card_acquirer_rates" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "card_rates_insert" ON "public"."card_acquirer_rates" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "card_rates_insert" ON "public"."card_acquirer_rates" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "card_rates_select" ON "public"."card_acquirer_rates" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "card_rates_select" ON "public"."card_acquirer_rates" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "card_rates_update" ON "public"."card_acquirer_rates" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "card_rates_update" ON "public"."card_acquirer_rates" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); @@ -11830,7 +11774,7 @@ CREATE POLICY "cash_flow_params_delete" ON "public"."cash_flow_params" FOR DELET CREATE POLICY "cash_flow_params_insert" ON "public"."cash_flow_params" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 FROM "public"."project_members" "pm" - WHERE (("pm"."project_id" = "cash_flow_params"."project_id") AND ("pm"."user_id" = "auth"."uid"()) AND ("pm"."role" = 'consultor'::"text")))))); + WHERE (("pm"."project_id" = "cash_flow_params"."project_id") AND ("pm"."user_id" = "auth"."uid"())))))); @@ -11842,7 +11786,7 @@ CREATE POLICY "cash_flow_params_select" ON "public"."cash_flow_params" FOR SELEC CREATE POLICY "cash_flow_params_update" ON "public"."cash_flow_params" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 FROM "public"."project_members" "pm" - WHERE (("pm"."project_id" = "cash_flow_params"."project_id") AND ("pm"."user_id" = "auth"."uid"()) AND ("pm"."role" = 'consultor'::"text")))))); + WHERE (("pm"."project_id" = "cash_flow_params"."project_id") AND ("pm"."user_id" = "auth"."uid"())))))); @@ -11864,9 +11808,7 @@ CREATE POLICY "categorization_patterns_delete" ON "public"."categorization_patte -CREATE POLICY "categorization_patterns_insert" ON "public"."categorization_patterns" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))))); +CREATE POLICY "categorization_patterns_insert" ON "public"."categorization_patterns" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"())); @@ -11874,22 +11816,28 @@ CREATE POLICY "categorization_patterns_select" ON "public"."categorization_patte -CREATE POLICY "categorization_patterns_update" ON "public"."categorization_patterns" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))))); +CREATE POLICY "categorization_patterns_update" ON "public"."categorization_patterns" FOR UPDATE USING ("public"."is_admin"("auth"."uid"())); ALTER TABLE "public"."categorization_rules" ENABLE ROW LEVEL SECURITY; +CREATE POLICY "categorization_rules_membro" ON "public"."categorization_rules" USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))) WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); + + + ALTER TABLE "public"."chh_analyses" ENABLE ROW LEVEL SECURITY; ALTER TABLE "public"."class_access" ENABLE ROW LEVEL SECURITY; -CREATE POLICY "class_access_insert" ON "public"."class_access" FOR INSERT WITH CHECK (true); +CREATE POLICY "class_access_insert" ON "public"."class_access" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"())); @@ -11951,7 +11899,7 @@ CREATE POLICY "client_documents_delete" ON "public"."client_documents" FOR DELET CREATE POLICY "client_documents_insert" ON "public"."client_documents" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("client_id" IN ( SELECT "p"."client_id" FROM ("public"."projects" "p" JOIN "public"."project_members" "pm" ON (("pm"."project_id" = "p"."id"))) - WHERE (("pm"."user_id" = "auth"."uid"()) AND ("pm"."role" = 'consultor'::"text")))))); + WHERE ("pm"."user_id" = "auth"."uid"()))))); @@ -11965,7 +11913,7 @@ CREATE POLICY "client_documents_select" ON "public"."client_documents" FOR SELEC CREATE POLICY "client_documents_update" ON "public"."client_documents" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR ("client_id" IN ( SELECT "p"."client_id" FROM ("public"."projects" "p" JOIN "public"."project_members" "pm" ON (("pm"."project_id" = "p"."id"))) - WHERE (("pm"."user_id" = "auth"."uid"()) AND ("pm"."role" = 'consultor'::"text")))))); + WHERE ("pm"."user_id" = "auth"."uid"()))))); @@ -11979,7 +11927,7 @@ CREATE POLICY "client_onboarding_delete" ON "public"."client_onboarding" FOR DEL CREATE POLICY "client_onboarding_insert" ON "public"."client_onboarding" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("client_id" IN ( SELECT "p"."client_id" FROM ("public"."projects" "p" JOIN "public"."project_members" "pm" ON (("pm"."project_id" = "p"."id"))) - WHERE (("pm"."user_id" = "auth"."uid"()) AND ("pm"."role" = 'consultor'::"text")))))); + WHERE ("pm"."user_id" = "auth"."uid"()))))); @@ -12003,28 +11951,38 @@ ALTER TABLE "public"."client_project_access_deprecated" ENABLE ROW LEVEL SECURIT ALTER TABLE "public"."client_socios" ENABLE ROW LEVEL SECURITY; -CREATE POLICY "client_socios_delete" ON "public"."client_socios" FOR DELETE USING ("public"."is_admin"("auth"."uid"())); +CREATE POLICY "client_socios_delete" ON "public"."client_socios" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR ("client_id" IN ( SELECT "pr"."client_id" + FROM ("public"."projects" "pr" + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + WHERE ("pa"."user_id" = "auth"."uid"()))))); -CREATE POLICY "client_socios_insert" ON "public"."client_socios" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))))); +CREATE POLICY "client_socios_insert" ON "public"."client_socios" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("client_id" IN ( SELECT "pr"."client_id" + FROM ("public"."projects" "pr" + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + WHERE ("pa"."user_id" = "auth"."uid"()))))); -CREATE POLICY "client_socios_select" ON "public"."client_socios" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("client_id" IN ( SELECT "pr"."client_id" +COMMENT ON POLICY "client_socios_insert" ON "public"."client_socios" IS 'SaaS sem consultor: o dono do projeto cadastra os próprios sócios (é ele quem contrata e cadastra). Vínculo por client_id → projects → project_access.'; + + + +CREATE POLICY "client_socios_select" ON "public"."client_socios" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR ("client_id" IN ( SELECT "pr"."client_id" FROM ("public"."projects" "pr" - JOIN "public"."client_project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) WHERE ("pa"."user_id" = "auth"."uid"()))))); -CREATE POLICY "client_socios_update" ON "public"."client_socios" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))))); +CREATE POLICY "client_socios_update" ON "public"."client_socios" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR ("client_id" IN ( SELECT "pr"."client_id" + FROM ("public"."projects" "pr" + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + WHERE ("pa"."user_id" = "auth"."uid"()))))) WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("client_id" IN ( SELECT "pr"."client_id" + FROM ("public"."projects" "pr" + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + WHERE ("pa"."user_id" = "auth"."uid"()))))); @@ -12049,7 +12007,7 @@ CREATE POLICY "clients_select" ON "public"."clients" FOR SELECT USING (("public" CREATE POLICY "clients_update" ON "public"."clients" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR ("id" IN ( SELECT "p"."client_id" FROM ("public"."projects" "p" JOIN "public"."project_members" "pm" ON (("pm"."project_id" = "p"."id"))) - WHERE (("pm"."user_id" = "auth"."uid"()) AND ("pm"."role" = 'consultor'::"text")))))); + WHERE ("pm"."user_id" = "auth"."uid"()))))); @@ -12058,7 +12016,11 @@ ALTER TABLE "public"."cockpit_actions" ENABLE ROW LEVEL SECURITY; CREATE POLICY "cockpit_actions_insert" ON "public"."cockpit_actions" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 FROM "public"."project_members" "pm" - WHERE (("pm"."project_id" = "cockpit_actions"."project_id") AND ("pm"."user_id" = "auth"."uid"()) AND ("pm"."role" = 'consultor'::"text")))))); + WHERE (("pm"."project_id" = "cockpit_actions"."project_id") AND ("pm"."user_id" = "auth"."uid"())))))); + + + +COMMENT ON POLICY "cockpit_actions_insert" ON "public"."cockpit_actions" IS 'SaaS sem consultor: qualquer MEMBRO do projeto grava as ações do plano aprovado.'; @@ -12138,152 +12100,123 @@ CREATE POLICY "communication_log update service_role only" ON "public"."communic ALTER TABLE "public"."companies" ENABLE ROW LEVEL SECURITY; -CREATE POLICY "companies_cliente_insert" ON "public"."companies" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("client_id" IN ( SELECT "pr"."client_id" +CREATE POLICY "companies_cliente_insert" ON "public"."companies" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("client_id" IN ( SELECT "pr"."client_id" FROM ("public"."projects" "pr" - JOIN "public"."client_project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) WHERE ("pa"."user_id" = "auth"."uid"()))))); -CREATE POLICY "companies_cliente_update" ON "public"."companies" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("client_id" IN ( SELECT "pr"."client_id" +CREATE POLICY "companies_cliente_update" ON "public"."companies" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR ("client_id" IN ( SELECT "pr"."client_id" FROM ("public"."projects" "pr" - JOIN "public"."client_project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) - WHERE ("pa"."user_id" = "auth"."uid"()))))) WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("client_id" IN ( SELECT "pr"."client_id" + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + WHERE ("pa"."user_id" = "auth"."uid"()))))) WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("client_id" IN ( SELECT "pr"."client_id" + FROM ("public"."projects" "pr" + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + WHERE ("pa"."user_id" = "auth"."uid"()))))); + + + +CREATE POLICY "companies_delete" ON "public"."companies" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR ("client_id" IN ( SELECT "pr"."client_id" FROM ("public"."projects" "pr" - JOIN "public"."client_project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) WHERE ("pa"."user_id" = "auth"."uid"()))))); -CREATE POLICY "companies_delete" ON "public"."companies" FOR DELETE USING ("public"."is_admin"("auth"."uid"())); +COMMENT ON POLICY "companies_delete" ON "public"."companies" IS 'SaaS sem consultor: cadastro é do dono — CNPJ cadastrado errado ele mesmo remove. Multi-CNPJ: remover a empresa não remove transações (transactions_delete segue admin-only).'; -CREATE POLICY "companies_insert" ON "public"."companies" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))))); +CREATE POLICY "companies_insert" ON "public"."companies" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"())); -CREATE POLICY "companies_select" ON "public"."companies" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("client_id" IN ( SELECT "pr"."client_id" +CREATE POLICY "companies_select" ON "public"."companies" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR ("client_id" IN ( SELECT "pr"."client_id" FROM ("public"."projects" "pr" - JOIN "public"."client_project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) WHERE ("pa"."user_id" = "auth"."uid"()))))); -CREATE POLICY "companies_update" ON "public"."companies" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))))); +CREATE POLICY "companies_update" ON "public"."companies" FOR UPDATE USING ("public"."is_admin"("auth"."uid"())); ALTER TABLE "public"."company_ai_dossiers" ENABLE ROW LEVEL SECURITY; -CREATE POLICY "company_ai_dossiers_delete" ON "public"."company_ai_dossiers" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))))); +CREATE POLICY "company_ai_dossiers_delete" ON "public"."company_ai_dossiers" FOR DELETE USING ("public"."is_admin"("auth"."uid"())); -CREATE POLICY "company_ai_dossiers_insert" ON "public"."company_ai_dossiers" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "company_ai_dossiers_insert" ON "public"."company_ai_dossiers" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "company_ai_dossiers_select" ON "public"."company_ai_dossiers" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "company_ai_dossiers_select" ON "public"."company_ai_dossiers" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "company_ai_dossiers_update" ON "public"."company_ai_dossiers" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "company_ai_dossiers_update" ON "public"."company_ai_dossiers" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); ALTER TABLE "public"."consultant_memories" ENABLE ROW LEVEL SECURITY; -CREATE POLICY "consultant_memories_delete" ON "public"."consultant_memories" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))))); +CREATE POLICY "consultant_memories_delete" ON "public"."consultant_memories" FOR DELETE USING ("public"."is_admin"("auth"."uid"())); -CREATE POLICY "consultant_memories_insert" ON "public"."consultant_memories" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "consultant_memories_insert" ON "public"."consultant_memories" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "consultant_memories_select" ON "public"."consultant_memories" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "consultant_memories_select" ON "public"."consultant_memories" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "consultant_memories_update" ON "public"."consultant_memories" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "consultant_memories_update" ON "public"."consultant_memories" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); ALTER TABLE "public"."contas_pagar" ENABLE ROW LEVEL SECURITY; -CREATE POLICY "contas_pagar_delete" ON "public"."contas_pagar" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "contas_pagar_delete" ON "public"."contas_pagar" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "contas_pagar_insert" ON "public"."contas_pagar" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "contas_pagar_insert" ON "public"."contas_pagar" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "contas_pagar_select" ON "public"."contas_pagar" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "contas_pagar_select" ON "public"."contas_pagar" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "contas_pagar_update" ON "public"."contas_pagar" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "contas_pagar_update" ON "public"."contas_pagar" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); @@ -12334,7 +12267,7 @@ CREATE POLICY "cost_audit_reports_delete" ON "public"."cost_audit_reports" FOR D CREATE POLICY "cost_audit_reports_insert" ON "public"."cost_audit_reports" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 FROM "public"."project_members" "pm" - WHERE (("pm"."project_id" = "cost_audit_reports"."project_id") AND ("pm"."user_id" = "auth"."uid"()) AND ("pm"."role" = 'consultor'::"text")))))); + WHERE (("pm"."project_id" = "cost_audit_reports"."project_id") AND ("pm"."user_id" = "auth"."uid"())))))); @@ -12346,71 +12279,55 @@ CREATE POLICY "cost_audit_reports_select" ON "public"."cost_audit_reports" FOR S CREATE POLICY "cost_audit_reports_update" ON "public"."cost_audit_reports" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 FROM "public"."project_members" "pm" - WHERE (("pm"."project_id" = "cost_audit_reports"."project_id") AND ("pm"."user_id" = "auth"."uid"()) AND ("pm"."role" = 'consultor'::"text")))))); + WHERE (("pm"."project_id" = "cost_audit_reports"."project_id") AND ("pm"."user_id" = "auth"."uid"())))))); -CREATE POLICY "dal_delete" ON "public"."dre_detalhamento_antecipacao_linha" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "dal_delete" ON "public"."dre_detalhamento_antecipacao_linha" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "dal_insert" ON "public"."dre_detalhamento_antecipacao_linha" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "dal_insert" ON "public"."dre_detalhamento_antecipacao_linha" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "dal_select" ON "public"."dre_detalhamento_antecipacao_linha" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "dal_select" ON "public"."dre_detalhamento_antecipacao_linha" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "dal_update" ON "public"."dre_detalhamento_antecipacao_linha" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "dal_update" ON "public"."dre_detalhamento_antecipacao_linha" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "ddc_delete" ON "public"."dre_detalhamento_contrato" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "ddc_delete" ON "public"."dre_detalhamento_contrato" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "ddc_insert" ON "public"."dre_detalhamento_contrato" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "ddc_insert" ON "public"."dre_detalhamento_contrato" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "ddc_select" ON "public"."dre_detalhamento_contrato" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "ddc_select" ON "public"."dre_detalhamento_contrato" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "ddc_update" ON "public"."dre_detalhamento_contrato" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "ddc_update" ON "public"."dre_detalhamento_contrato" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); @@ -12429,35 +12346,27 @@ ALTER TABLE "public"."dre_detalhamento_antecipacao_linha" ENABLE ROW LEVEL SECUR ALTER TABLE "public"."dre_detalhamento_contrato" ENABLE ROW LEVEL SECURITY; -CREATE POLICY "dre_detalhamento_delete" ON "public"."dre_detalhamento" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "dre_detalhamento_delete" ON "public"."dre_detalhamento" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "dre_detalhamento_insert" ON "public"."dre_detalhamento" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "dre_detalhamento_insert" ON "public"."dre_detalhamento" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "dre_detalhamento_select" ON "public"."dre_detalhamento" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "dre_detalhamento_select" ON "public"."dre_detalhamento" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "dre_detalhamento_update" ON "public"."dre_detalhamento" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "dre_detalhamento_update" ON "public"."dre_detalhamento" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); @@ -12483,7 +12392,7 @@ CREATE POLICY "dre_reports_delete" ON "public"."dre_reports" FOR DELETE USING (" CREATE POLICY "dre_reports_insert" ON "public"."dre_reports" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 FROM "public"."project_members" "pm" - WHERE (("pm"."project_id" = "dre_reports"."project_id") AND ("pm"."user_id" = "auth"."uid"()) AND ("pm"."role" = 'consultor'::"text")))))); + WHERE (("pm"."project_id" = "dre_reports"."project_id") AND ("pm"."user_id" = "auth"."uid"())))))); @@ -12495,7 +12404,7 @@ CREATE POLICY "dre_reports_select" ON "public"."dre_reports" FOR SELECT USING (( CREATE POLICY "dre_reports_update" ON "public"."dre_reports" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 FROM "public"."project_members" "pm" - WHERE (("pm"."project_id" = "dre_reports"."project_id") AND ("pm"."user_id" = "auth"."uid"()) AND ("pm"."role" = 'consultor'::"text")))))); + WHERE (("pm"."project_id" = "dre_reports"."project_id") AND ("pm"."user_id" = "auth"."uid"())))))); @@ -12505,22 +12414,16 @@ ALTER TABLE "public"."dre_subcategories" ENABLE ROW LEVEL SECURITY; ALTER TABLE "public"."employees" ENABLE ROW LEVEL SECURITY; -CREATE POLICY "employees_cliente_insert" ON "public"."employees" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "cpa"."project_id" - FROM "public"."client_project_access" "cpa" +CREATE POLICY "employees_cliente_insert" ON "public"."employees" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" WHERE ("cpa"."user_id" = "auth"."uid"()))))); -CREATE POLICY "employees_cliente_update" ON "public"."employees" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "cpa"."project_id" - FROM "public"."client_project_access" "cpa" - WHERE ("cpa"."user_id" = "auth"."uid"()))))) WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "cpa"."project_id" - FROM "public"."client_project_access" "cpa" +CREATE POLICY "employees_cliente_update" ON "public"."employees" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" + WHERE ("cpa"."user_id" = "auth"."uid"()))))) WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" WHERE ("cpa"."user_id" = "auth"."uid"()))))); @@ -12529,59 +12432,45 @@ CREATE POLICY "employees_delete" ON "public"."employees" FOR DELETE USING ("publ -CREATE POLICY "employees_insert" ON "public"."employees" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "employees_insert" ON "public"."employees" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "employees_select" ON "public"."employees" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "employees_select" ON "public"."employees" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "employees_update" ON "public"."employees" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "employees_update" ON "public"."employees" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "endiv_contratos_delete" ON "public"."endividamento_contratos" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "endiv_contratos_delete" ON "public"."endividamento_contratos" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "endiv_contratos_insert" ON "public"."endividamento_contratos" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "endiv_contratos_insert" ON "public"."endividamento_contratos" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "endiv_contratos_select" ON "public"."endividamento_contratos" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "endiv_contratos_select" ON "public"."endividamento_contratos" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "endiv_contratos_update" ON "public"."endividamento_contratos" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "endiv_contratos_update" ON "public"."endividamento_contratos" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); @@ -12589,19 +12478,15 @@ CREATE POLICY "endiv_snap_delete" ON "public"."endividamento_snapshots" FOR DELE -CREATE POLICY "endiv_snap_insert" ON "public"."endividamento_snapshots" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "endiv_snap_insert" ON "public"."endividamento_snapshots" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "endiv_snap_select" ON "public"."endividamento_snapshots" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "endiv_snap_select" ON "public"."endividamento_snapshots" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); @@ -12626,35 +12511,27 @@ CREATE POLICY "enrichment_logs_insert" ON "public"."assertiva_enrichment_logs" F ALTER TABLE "public"."estoque_produtos" ENABLE ROW LEVEL SECURITY; -CREATE POLICY "estoque_produtos_delete" ON "public"."estoque_produtos" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "estoque_produtos_delete" ON "public"."estoque_produtos" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "estoque_produtos_insert" ON "public"."estoque_produtos" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "estoque_produtos_insert" ON "public"."estoque_produtos" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "estoque_produtos_select" ON "public"."estoque_produtos" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "estoque_produtos_select" ON "public"."estoque_produtos" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "estoque_produtos_update" ON "public"."estoque_produtos" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "estoque_produtos_update" ON "public"."estoque_produtos" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); @@ -12683,7 +12560,7 @@ CREATE POLICY "file_uploads_delete" ON "public"."file_uploads" FOR DELETE USING CREATE POLICY "file_uploads_insert" ON "public"."file_uploads" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 FROM "public"."project_members" "pm" - WHERE (("pm"."project_id" = "file_uploads"."project_id") AND ("pm"."user_id" = "auth"."uid"()) AND ("pm"."role" = 'consultor'::"text")))))); + WHERE (("pm"."project_id" = "file_uploads"."project_id") AND ("pm"."user_id" = "auth"."uid"())))))); @@ -12695,7 +12572,7 @@ CREATE POLICY "file_uploads_select" ON "public"."file_uploads" FOR SELECT USING CREATE POLICY "file_uploads_update" ON "public"."file_uploads" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 FROM "public"."project_members" "pm" - WHERE (("pm"."project_id" = "file_uploads"."project_id") AND ("pm"."user_id" = "auth"."uid"()) AND ("pm"."role" = 'consultor'::"text")))))); + WHERE (("pm"."project_id" = "file_uploads"."project_id") AND ("pm"."user_id" = "auth"."uid"())))))); @@ -12709,29 +12586,21 @@ CREATE POLICY "financial_documents_admin" ON "public"."financial_documents" USIN -CREATE POLICY "financial_documents_cliente_insert" ON "public"."financial_documents" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "cpa"."project_id" - FROM "public"."client_project_access" "cpa" +CREATE POLICY "financial_documents_cliente_insert" ON "public"."financial_documents" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" WHERE ("cpa"."user_id" = "auth"."uid"()))))); -CREATE POLICY "financial_documents_cliente_update" ON "public"."financial_documents" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "cpa"."project_id" - FROM "public"."client_project_access" "cpa" - WHERE ("cpa"."user_id" = "auth"."uid"()))))) WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "cpa"."project_id" - FROM "public"."client_project_access" "cpa" +CREATE POLICY "financial_documents_cliente_update" ON "public"."financial_documents" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" + WHERE ("cpa"."user_id" = "auth"."uid"()))))) WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" WHERE ("cpa"."user_id" = "auth"."uid"()))))); -CREATE POLICY "financial_documents_insert" ON "public"."financial_documents" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (( SELECT "profiles"."role" - FROM "public"."profiles" - WHERE ("profiles"."id" = "auth"."uid"())) = ANY (ARRAY['consultor'::"text", 'sdr'::"text"])))); +CREATE POLICY "financial_documents_insert" ON "public"."financial_documents" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"())); @@ -12750,7 +12619,7 @@ CREATE POLICY "financial_goals_delete" ON "public"."financial_goals" FOR DELETE CREATE POLICY "financial_goals_insert" ON "public"."financial_goals" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 FROM "public"."project_members" "pm" - WHERE (("pm"."project_id" = "financial_goals"."project_id") AND ("pm"."user_id" = "auth"."uid"()) AND ("pm"."role" = 'consultor'::"text")))))); + WHERE (("pm"."project_id" = "financial_goals"."project_id") AND ("pm"."user_id" = "auth"."uid"())))))); @@ -12762,7 +12631,7 @@ CREATE POLICY "financial_goals_select" ON "public"."financial_goals" FOR SELECT CREATE POLICY "financial_goals_update" ON "public"."financial_goals" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 FROM "public"."project_members" "pm" - WHERE (("pm"."project_id" = "financial_goals"."project_id") AND ("pm"."user_id" = "auth"."uid"()) AND ("pm"."role" = 'consultor'::"text")))))); + WHERE (("pm"."project_id" = "financial_goals"."project_id") AND ("pm"."user_id" = "auth"."uid"())))))); @@ -12775,7 +12644,7 @@ CREATE POLICY "financial_snapshots_delete" ON "public"."financial_snapshots" FOR CREATE POLICY "financial_snapshots_insert" ON "public"."financial_snapshots" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 FROM "public"."project_members" "pm" - WHERE (("pm"."project_id" = "financial_snapshots"."project_id") AND ("pm"."user_id" = "auth"."uid"()) AND ("pm"."role" = 'consultor'::"text")))))); + WHERE (("pm"."project_id" = "financial_snapshots"."project_id") AND ("pm"."user_id" = "auth"."uid"())))))); @@ -12787,40 +12656,32 @@ CREATE POLICY "financial_snapshots_select" ON "public"."financial_snapshots" FOR CREATE POLICY "financial_snapshots_update" ON "public"."financial_snapshots" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 FROM "public"."project_members" "pm" - WHERE (("pm"."project_id" = "financial_snapshots"."project_id") AND ("pm"."user_id" = "auth"."uid"()) AND ("pm"."role" = 'consultor'::"text")))))); + WHERE (("pm"."project_id" = "financial_snapshots"."project_id") AND ("pm"."user_id" = "auth"."uid"())))))); ALTER TABLE "public"."folha_uploads" ENABLE ROW LEVEL SECURITY; -CREATE POLICY "folha_uploads_delete" ON "public"."folha_uploads" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))))); +CREATE POLICY "folha_uploads_delete" ON "public"."folha_uploads" FOR DELETE USING ("public"."is_admin"("auth"."uid"())); -CREATE POLICY "folha_uploads_insert" ON "public"."folha_uploads" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "folha_uploads_insert" ON "public"."folha_uploads" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "folha_uploads_select" ON "public"."folha_uploads" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "folha_uploads_select" ON "public"."folha_uploads" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "folha_uploads_update" ON "public"."folha_uploads" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "folha_uploads_update" ON "public"."folha_uploads" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); @@ -12842,7 +12703,7 @@ ALTER TABLE "public"."health_scores" ENABLE ROW LEVEL SECURITY; CREATE POLICY "health_scores_insert" ON "public"."health_scores" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 FROM "public"."project_members" "pm" - WHERE (("pm"."project_id" = "health_scores"."project_id") AND ("pm"."user_id" = "auth"."uid"()) AND ("pm"."role" = 'consultor'::"text")))))); + WHERE (("pm"."project_id" = "health_scores"."project_id") AND ("pm"."user_id" = "auth"."uid"())))))); @@ -12897,35 +12758,27 @@ CREATE POLICY "import_logs_own" ON "public"."import_logs" FOR SELECT USING (("us ALTER TABLE "public"."ingestion_documents" ENABLE ROW LEVEL SECURITY; -CREATE POLICY "ingestion_documents_delete" ON "public"."ingestion_documents" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "ingestion_documents_delete" ON "public"."ingestion_documents" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "ingestion_documents_insert" ON "public"."ingestion_documents" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "ingestion_documents_insert" ON "public"."ingestion_documents" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "ingestion_documents_select" ON "public"."ingestion_documents" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "ingestion_documents_select" ON "public"."ingestion_documents" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "ingestion_documents_update" ON "public"."ingestion_documents" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "ingestion_documents_update" ON "public"."ingestion_documents" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); @@ -12936,35 +12789,27 @@ CREATE POLICY "invites_admin" ON "public"."invites" USING ("public"."is_admin"(" -CREATE POLICY "lcpo_delete" ON "public"."loan_contract_pmt_overrides" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "lcpo_delete" ON "public"."loan_contract_pmt_overrides" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "lcpo_insert" ON "public"."loan_contract_pmt_overrides" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "lcpo_insert" ON "public"."loan_contract_pmt_overrides" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "lcpo_select" ON "public"."loan_contract_pmt_overrides" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "lcpo_select" ON "public"."loan_contract_pmt_overrides" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "lcpo_update" ON "public"."loan_contract_pmt_overrides" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "lcpo_update" ON "public"."loan_contract_pmt_overrides" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); @@ -13008,6 +12853,16 @@ CREATE POLICY "leads_sdr" ON "public"."leads" USING (("assigned_to" = "auth"."ui ALTER TABLE "public"."learned_rules" ENABLE ROW LEVEL SECURITY; +CREATE POLICY "learned_rules_membro" ON "public"."learned_rules" USING (("public"."is_admin"("auth"."uid"()) OR ("client_id" IN ( SELECT "pr"."client_id" + FROM ("public"."projects" "pr" + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + WHERE ("pa"."user_id" = "auth"."uid"()))))) WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("client_id" IN ( SELECT "pr"."client_id" + FROM ("public"."projects" "pr" + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + WHERE ("pa"."user_id" = "auth"."uid"()))))); + + + ALTER TABLE "public"."library_content" ENABLE ROW LEVEL SECURITY; @@ -13046,38 +12901,28 @@ CREATE POLICY "library_content_update_policy" ON "public"."library_content" FOR ALTER TABLE "public"."linhas_operacionais" ENABLE ROW LEVEL SECURITY; -CREATE POLICY "linhas_operacionais_delete" ON "public"."linhas_operacionais" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "cpa"."project_id" - FROM "public"."client_project_access" "cpa" +CREATE POLICY "linhas_operacionais_delete" ON "public"."linhas_operacionais" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" WHERE ("cpa"."user_id" = "auth"."uid"()))))); -CREATE POLICY "linhas_operacionais_insert" ON "public"."linhas_operacionais" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "cpa"."project_id" - FROM "public"."client_project_access" "cpa" +CREATE POLICY "linhas_operacionais_insert" ON "public"."linhas_operacionais" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" WHERE ("cpa"."user_id" = "auth"."uid"()))))); -CREATE POLICY "linhas_operacionais_select" ON "public"."linhas_operacionais" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "cpa"."project_id" - FROM "public"."client_project_access" "cpa" +CREATE POLICY "linhas_operacionais_select" ON "public"."linhas_operacionais" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" WHERE ("cpa"."user_id" = "auth"."uid"()))))); -CREATE POLICY "linhas_operacionais_update" ON "public"."linhas_operacionais" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "cpa"."project_id" - FROM "public"."client_project_access" "cpa" - WHERE ("cpa"."user_id" = "auth"."uid"()))))) WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "cpa"."project_id" - FROM "public"."client_project_access" "cpa" +CREATE POLICY "linhas_operacionais_update" ON "public"."linhas_operacionais" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" + WHERE ("cpa"."user_id" = "auth"."uid"()))))) WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" WHERE ("cpa"."user_id" = "auth"."uid"()))))); @@ -13106,35 +12951,27 @@ ALTER TABLE "public"."loan_contract_pmt_overrides" ENABLE ROW LEVEL SECURITY; ALTER TABLE "public"."loan_contracts" ENABLE ROW LEVEL SECURITY; -CREATE POLICY "loan_contracts_delete" ON "public"."loan_contracts" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "loan_contracts_delete" ON "public"."loan_contracts" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "loan_contracts_insert" ON "public"."loan_contracts" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "loan_contracts_insert" ON "public"."loan_contracts" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "loan_contracts_select" ON "public"."loan_contracts" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "loan_contracts_select" ON "public"."loan_contracts" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "loan_contracts_update" ON "public"."loan_contracts" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "loan_contracts_update" ON "public"."loan_contracts" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); @@ -13147,7 +12984,7 @@ CREATE POLICY "margin_analyses_delete" ON "public"."margin_analyses" FOR DELETE CREATE POLICY "margin_analyses_insert" ON "public"."margin_analyses" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 FROM "public"."project_members" "pm" - WHERE (("pm"."project_id" = "margin_analyses"."project_id") AND ("pm"."user_id" = "auth"."uid"()) AND ("pm"."role" = 'consultor'::"text")))))); + WHERE (("pm"."project_id" = "margin_analyses"."project_id") AND ("pm"."user_id" = "auth"."uid"())))))); @@ -13159,7 +12996,7 @@ CREATE POLICY "margin_analyses_select" ON "public"."margin_analyses" FOR SELECT CREATE POLICY "margin_analyses_update" ON "public"."margin_analyses" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 FROM "public"."project_members" "pm" - WHERE (("pm"."project_id" = "margin_analyses"."project_id") AND ("pm"."user_id" = "auth"."uid"()) AND ("pm"."role" = 'consultor'::"text")))))); + WHERE (("pm"."project_id" = "margin_analyses"."project_id") AND ("pm"."user_id" = "auth"."uid"())))))); @@ -13173,7 +13010,7 @@ CREATE POLICY "margin_items_delete" ON "public"."margin_items" FOR DELETE USING CREATE POLICY "margin_items_insert" ON "public"."margin_items" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 FROM ("public"."margin_analyses" "ma" JOIN "public"."project_members" "pm" ON (("pm"."project_id" = "ma"."project_id"))) - WHERE (("ma"."id" = "margin_items"."analysis_id") AND ("pm"."user_id" = "auth"."uid"()) AND ("pm"."role" = 'consultor'::"text")))))); + WHERE (("ma"."id" = "margin_items"."analysis_id") AND ("pm"."user_id" = "auth"."uid"())))))); @@ -13187,7 +13024,7 @@ CREATE POLICY "margin_items_select" ON "public"."margin_items" FOR SELECT USING CREATE POLICY "margin_items_update" ON "public"."margin_items" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 FROM ("public"."margin_analyses" "ma" JOIN "public"."project_members" "pm" ON (("pm"."project_id" = "ma"."project_id"))) - WHERE (("ma"."id" = "margin_items"."analysis_id") AND ("pm"."user_id" = "auth"."uid"()) AND ("pm"."role" = 'consultor'::"text")))))); + WHERE (("ma"."id" = "margin_items"."analysis_id") AND ("pm"."user_id" = "auth"."uid"())))))); @@ -13204,7 +13041,7 @@ CREATE POLICY "module_analyses_delete" ON "public"."module_analyses" FOR DELETE CREATE POLICY "module_analyses_insert" ON "public"."module_analyses" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 FROM ("public"."project_modules" "pmod" JOIN "public"."project_members" "pm" ON (("pm"."project_id" = "pmod"."project_id"))) - WHERE (("pmod"."id" = "module_analyses"."module_id") AND ("pm"."user_id" = "auth"."uid"()) AND ("pm"."role" = 'consultor'::"text")))))); + WHERE (("pmod"."id" = "module_analyses"."module_id") AND ("pm"."user_id" = "auth"."uid"())))))); @@ -13218,7 +13055,7 @@ CREATE POLICY "module_analyses_select" ON "public"."module_analyses" FOR SELECT CREATE POLICY "module_analyses_update" ON "public"."module_analyses" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 FROM ("public"."project_modules" "pmod" JOIN "public"."project_members" "pm" ON (("pm"."project_id" = "pmod"."project_id"))) - WHERE (("pmod"."id" = "module_analyses"."module_id") AND ("pm"."user_id" = "auth"."uid"()) AND ("pm"."role" = 'consultor'::"text")))))); + WHERE (("pmod"."id" = "module_analyses"."module_id") AND ("pm"."user_id" = "auth"."uid"())))))); @@ -13232,59 +13069,45 @@ CREATE POLICY "notifications_own" ON "public"."notifications" USING (("user_id" ALTER TABLE "public"."partner_policies" ENABLE ROW LEVEL SECURITY; -CREATE POLICY "pending_ofx_cliente_delete" ON "public"."pending_ofx_distributions" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("client_id" IN ( SELECT "pr"."client_id" +CREATE POLICY "pending_ofx_cliente_delete" ON "public"."pending_ofx_distributions" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR ("client_id" IN ( SELECT "pr"."client_id" FROM ("public"."projects" "pr" - JOIN "public"."client_project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) WHERE ("pa"."user_id" = "auth"."uid"()))))); -CREATE POLICY "pending_ofx_cliente_update" ON "public"."pending_ofx_distributions" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("client_id" IN ( SELECT "pr"."client_id" +CREATE POLICY "pending_ofx_cliente_update" ON "public"."pending_ofx_distributions" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR ("client_id" IN ( SELECT "pr"."client_id" FROM ("public"."projects" "pr" - JOIN "public"."client_project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) - WHERE ("pa"."user_id" = "auth"."uid"()))))) WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("client_id" IN ( SELECT "pr"."client_id" + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + WHERE ("pa"."user_id" = "auth"."uid"()))))) WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("client_id" IN ( SELECT "pr"."client_id" FROM ("public"."projects" "pr" - JOIN "public"."client_project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) WHERE ("pa"."user_id" = "auth"."uid"()))))); -CREATE POLICY "pending_ofx_delete" ON "public"."pending_ofx_distributions" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))))); +CREATE POLICY "pending_ofx_delete" ON "public"."pending_ofx_distributions" FOR DELETE USING ("public"."is_admin"("auth"."uid"())); ALTER TABLE "public"."pending_ofx_distributions" ENABLE ROW LEVEL SECURITY; -CREATE POLICY "pending_ofx_insert" ON "public"."pending_ofx_distributions" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("client_id" IN ( SELECT "pr"."client_id" +CREATE POLICY "pending_ofx_insert" ON "public"."pending_ofx_distributions" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("client_id" IN ( SELECT "pr"."client_id" FROM ("public"."projects" "pr" - JOIN "public"."client_project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) WHERE ("pa"."user_id" = "auth"."uid"()))))); -CREATE POLICY "pending_ofx_select" ON "public"."pending_ofx_distributions" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("client_id" IN ( SELECT "pr"."client_id" +CREATE POLICY "pending_ofx_select" ON "public"."pending_ofx_distributions" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR ("client_id" IN ( SELECT "pr"."client_id" FROM ("public"."projects" "pr" - JOIN "public"."client_project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) WHERE ("pa"."user_id" = "auth"."uid"()))))); -CREATE POLICY "pending_ofx_update" ON "public"."pending_ofx_distributions" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))))); +CREATE POLICY "pending_ofx_update" ON "public"."pending_ofx_distributions" FOR UPDATE USING ("public"."is_admin"("auth"."uid"())); @@ -13297,7 +13120,11 @@ CREATE POLICY "plano_voo_snapshots_delete" ON "public"."plano_voo_snapshots" FOR CREATE POLICY "plano_voo_snapshots_insert" ON "public"."plano_voo_snapshots" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 FROM "public"."project_members" "pm" - WHERE (("pm"."project_id" = "plano_voo_snapshots"."project_id") AND ("pm"."user_id" = "auth"."uid"()) AND ("pm"."role" = 'consultor'::"text")))))); + WHERE (("pm"."project_id" = "plano_voo_snapshots"."project_id") AND ("pm"."user_id" = "auth"."uid"())))))); + + + +COMMENT ON POLICY "plano_voo_snapshots_insert" ON "public"."plano_voo_snapshots" IS 'SaaS sem consultor: qualquer MEMBRO do projeto gera a meta — o dono inclusive. O gate de qualidade (12 meses da janela fechados) é do app (plano-voo-gate.ts), não da RLS.'; @@ -13317,27 +13144,21 @@ CREATE POLICY "pmu_delete" ON "public"."project_module_unification" FOR DELETE U -CREATE POLICY "pmu_insert" ON "public"."project_module_unification" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "pmu_insert" ON "public"."project_module_unification" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "pmu_select" ON "public"."project_module_unification" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "pmu_select" ON "public"."project_module_unification" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "pmu_update" ON "public"."project_module_unification" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "pmu_update" ON "public"."project_module_unification" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); @@ -13350,7 +13171,7 @@ CREATE POLICY "products_services_delete" ON "public"."products_services" FOR DEL CREATE POLICY "products_services_insert" ON "public"."products_services" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 FROM "public"."project_members" "pm" - WHERE (("pm"."project_id" = "products_services"."project_id") AND ("pm"."user_id" = "auth"."uid"()) AND ("pm"."role" = 'consultor'::"text")))))); + WHERE (("pm"."project_id" = "products_services"."project_id") AND ("pm"."user_id" = "auth"."uid"())))))); @@ -13362,7 +13183,7 @@ CREATE POLICY "products_services_select" ON "public"."products_services" FOR SEL CREATE POLICY "products_services_update" ON "public"."products_services" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 FROM "public"."project_members" "pm" - WHERE (("pm"."project_id" = "products_services"."project_id") AND ("pm"."user_id" = "auth"."uid"()) AND ("pm"."role" = 'consultor'::"text")))))); + WHERE (("pm"."project_id" = "products_services"."project_id") AND ("pm"."user_id" = "auth"."uid"())))))); @@ -13381,12 +13202,39 @@ CREATE POLICY "profiles_own_update" ON "public"."profiles" FOR UPDATE USING (("a +CREATE POLICY "profiles_select_colega_de_projeto" ON "public"."profiles" FOR SELECT USING ((EXISTS ( SELECT 1 + FROM ("public"."project_access" "meu" + JOIN "public"."project_access" "dele" ON (("dele"."project_id" = "meu"."project_id"))) + WHERE (("meu"."user_id" = "auth"."uid"()) AND ("dele"."user_id" = "profiles"."id"))))); + + + +COMMENT ON POLICY "profiles_select_colega_de_projeto" ON "public"."profiles" IS 'Terceira perna do SELECT de profiles (as outras são profiles_own_select e profiles_admin_all): enxerga-se quem compartilha projeto. Usa a view project_access de propósito — project_members tem RLS own-row e o subselect voltaria vazio em silêncio.'; + + + ALTER TABLE "public"."project_deliverables" ENABLE ROW LEVEL SECURITY; +CREATE POLICY "project_deliverables_membro" ON "public"."project_deliverables" USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))) WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); + + + ALTER TABLE "public"."project_meetings" ENABLE ROW LEVEL SECURITY; +CREATE POLICY "project_meetings_membro" ON "public"."project_meetings" USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))) WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); + + + ALTER TABLE "public"."project_members" ENABLE ROW LEVEL SECURITY; @@ -13404,6 +13252,14 @@ ALTER TABLE "public"."project_module_unification" ENABLE ROW LEVEL SECURITY; ALTER TABLE "public"."project_modules" ENABLE ROW LEVEL SECURITY; +CREATE POLICY "project_modules_membro" ON "public"."project_modules" USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))) WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); + + + ALTER TABLE "public"."project_notifications" ENABLE ROW LEVEL SECURITY; @@ -13439,7 +13295,7 @@ CREATE POLICY "project_scenarios_delete" ON "public"."project_scenarios" FOR DEL CREATE POLICY "project_scenarios_insert" ON "public"."project_scenarios" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 FROM "public"."project_members" "pm" - WHERE (("pm"."project_id" = "project_scenarios"."project_id") AND ("pm"."user_id" = "auth"."uid"()) AND ("pm"."role" = 'consultor'::"text")))))); + WHERE (("pm"."project_id" = "project_scenarios"."project_id") AND ("pm"."user_id" = "auth"."uid"())))))); @@ -13451,19 +13307,27 @@ CREATE POLICY "project_scenarios_select" ON "public"."project_scenarios" FOR SEL CREATE POLICY "project_scenarios_update" ON "public"."project_scenarios" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 FROM "public"."project_members" "pm" - WHERE (("pm"."project_id" = "project_scenarios"."project_id") AND ("pm"."user_id" = "auth"."uid"()) AND ("pm"."role" = 'consultor'::"text")))))); + WHERE (("pm"."project_id" = "project_scenarios"."project_id") AND ("pm"."user_id" = "auth"."uid"())))))); ALTER TABLE "public"."project_tasks" ENABLE ROW LEVEL SECURITY; +CREATE POLICY "project_tasks_membro" ON "public"."project_tasks" USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))) WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); + + + ALTER TABLE "public"."project_transitions" ENABLE ROW LEVEL SECURITY; CREATE POLICY "project_transitions_insert" ON "public"."project_transitions" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 FROM "public"."project_members" "pm" - WHERE (("pm"."project_id" = "project_transitions"."project_id") AND ("pm"."user_id" = "auth"."uid"()) AND ("pm"."role" = 'consultor'::"text")))))); + WHERE (("pm"."project_id" = "project_transitions"."project_id") AND ("pm"."user_id" = "auth"."uid"())))))); @@ -13476,35 +13340,27 @@ CREATE POLICY "project_transitions_select" ON "public"."project_transitions" FOR ALTER TABLE "public"."projection_scenarios" ENABLE ROW LEVEL SECURITY; -CREATE POLICY "projection_scenarios_delete" ON "public"."projection_scenarios" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "projection_scenarios_delete" ON "public"."projection_scenarios" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "projection_scenarios_insert" ON "public"."projection_scenarios" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "projection_scenarios_insert" ON "public"."projection_scenarios" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "projection_scenarios_select" ON "public"."projection_scenarios" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "projection_scenarios_select" ON "public"."projection_scenarios" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "projection_scenarios_update" ON "public"."projection_scenarios" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "projection_scenarios_update" ON "public"."projection_scenarios" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); @@ -13529,6 +13385,18 @@ CREATE POLICY "projects_members" ON "public"."projects" FOR SELECT USING ((EXIST +CREATE POLICY "projects_membro_update" ON "public"."projects" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR ("id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))) WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); + + + +COMMENT ON POLICY "projects_membro_update" ON "public"."projects" IS 'SaaS sem consultor: membro do projeto edita o próprio projeto. Substitui a policy que liberava UPDATE para qualquer autenticado — que permitia mexer no data_inicio (a janela de 12m da meta) de qualquer cliente.'; + + + CREATE POLICY "projects_select_client" ON "public"."projects" FOR SELECT USING ((("id" IN ( SELECT "client_project_access_deprecated"."project_id" FROM "public"."client_project_access_deprecated" WHERE ("client_project_access_deprecated"."user_id" = "auth"."uid"()))) OR (EXISTS ( SELECT 1 @@ -13548,35 +13416,27 @@ CREATE POLICY "projects_update_admin" ON "public"."projects" FOR UPDATE USING (( ALTER TABLE "public"."recebivel_parcelas" ENABLE ROW LEVEL SECURITY; -CREATE POLICY "recebivel_parcelas_delete" ON "public"."recebivel_parcelas" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "recebivel_parcelas_delete" ON "public"."recebivel_parcelas" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "recebivel_parcelas_insert" ON "public"."recebivel_parcelas" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "recebivel_parcelas_insert" ON "public"."recebivel_parcelas" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "recebivel_parcelas_select" ON "public"."recebivel_parcelas" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "recebivel_parcelas_select" ON "public"."recebivel_parcelas" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); -CREATE POLICY "recebivel_parcelas_update" ON "public"."recebivel_parcelas" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "client_project_access"."project_id" - FROM "public"."client_project_access" - WHERE ("client_project_access"."user_id" = "auth"."uid"()))))); +CREATE POLICY "recebivel_parcelas_update" ON "public"."recebivel_parcelas" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"()))))); @@ -13608,7 +13468,7 @@ CREATE POLICY "service_composition_delete" ON "public"."service_composition" FOR CREATE POLICY "service_composition_insert" ON "public"."service_composition" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 FROM ("public"."products_services" "ps" JOIN "public"."project_members" "pm" ON (("pm"."project_id" = "ps"."project_id"))) - WHERE (("ps"."id" = "service_composition"."service_id") AND ("pm"."user_id" = "auth"."uid"()) AND ("pm"."role" = 'consultor'::"text")))))); + WHERE (("ps"."id" = "service_composition"."service_id") AND ("pm"."user_id" = "auth"."uid"())))))); @@ -13658,38 +13518,28 @@ ALTER TABLE "public"."time_entries" ENABLE ROW LEVEL SECURITY; ALTER TABLE "public"."transacao_linha_split" ENABLE ROW LEVEL SECURITY; -CREATE POLICY "transacao_linha_split_delete" ON "public"."transacao_linha_split" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "cpa"."project_id" - FROM "public"."client_project_access" "cpa" +CREATE POLICY "transacao_linha_split_delete" ON "public"."transacao_linha_split" FOR DELETE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" WHERE ("cpa"."user_id" = "auth"."uid"()))))); -CREATE POLICY "transacao_linha_split_insert" ON "public"."transacao_linha_split" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "cpa"."project_id" - FROM "public"."client_project_access" "cpa" +CREATE POLICY "transacao_linha_split_insert" ON "public"."transacao_linha_split" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" WHERE ("cpa"."user_id" = "auth"."uid"()))))); -CREATE POLICY "transacao_linha_split_select" ON "public"."transacao_linha_split" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "cpa"."project_id" - FROM "public"."client_project_access" "cpa" +CREATE POLICY "transacao_linha_split_select" ON "public"."transacao_linha_split" FOR SELECT USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" WHERE ("cpa"."user_id" = "auth"."uid"()))))); -CREATE POLICY "transacao_linha_split_update" ON "public"."transacao_linha_split" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "cpa"."project_id" - FROM "public"."client_project_access" "cpa" - WHERE ("cpa"."user_id" = "auth"."uid"()))))) WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "cpa"."project_id" - FROM "public"."client_project_access" "cpa" +CREATE POLICY "transacao_linha_split_update" ON "public"."transacao_linha_split" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" + WHERE ("cpa"."user_id" = "auth"."uid"()))))) WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" WHERE ("cpa"."user_id" = "auth"."uid"()))))); @@ -13701,22 +13551,16 @@ CREATE POLICY "transactions_admin" ON "public"."transactions" USING ("public"."i -CREATE POLICY "transactions_cliente_insert" ON "public"."transactions" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "cpa"."project_id" - FROM "public"."client_project_access" "cpa" +CREATE POLICY "transactions_cliente_insert" ON "public"."transactions" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" WHERE ("cpa"."user_id" = "auth"."uid"()))))); -CREATE POLICY "transactions_cliente_update" ON "public"."transactions" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "cpa"."project_id" - FROM "public"."client_project_access" "cpa" - WHERE ("cpa"."user_id" = "auth"."uid"()))))) WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 - FROM "public"."profiles" "p" - WHERE (("p"."id" = "auth"."uid"()) AND ("p"."role" = ANY (ARRAY['owner'::"text", 'admin'::"text", 'consultor'::"text"]))))) OR ("project_id" IN ( SELECT "cpa"."project_id" - FROM "public"."client_project_access" "cpa" +CREATE POLICY "transactions_cliente_update" ON "public"."transactions" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" + WHERE ("cpa"."user_id" = "auth"."uid"()))))) WITH CHECK (("public"."is_admin"("auth"."uid"()) OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" WHERE ("cpa"."user_id" = "auth"."uid"()))))); @@ -13727,13 +13571,11 @@ CREATE POLICY "transactions_delete" ON "public"."transactions" FOR DELETE USING CREATE POLICY "transactions_insert" ON "public"."transactions" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 FROM "public"."project_members" "pm" - WHERE (("pm"."project_id" = "transactions"."project_id") AND ("pm"."user_id" = "auth"."uid"()) AND ("pm"."role" = 'consultor'::"text")))))); + WHERE (("pm"."project_id" = "transactions"."project_id") AND ("pm"."user_id" = "auth"."uid"())))))); -CREATE POLICY "transactions_insert_consultor" ON "public"."transactions" FOR INSERT WITH CHECK (("public"."is_admin"("auth"."uid"()) OR (( SELECT "profiles"."role" - FROM "public"."profiles" - WHERE ("profiles"."id" = "auth"."uid"())) = ANY (ARRAY['consultor'::"text", 'sdr'::"text"])))); +CREATE POLICY "transactions_insert_consultor" ON "public"."transactions" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"())); @@ -13751,7 +13593,7 @@ CREATE POLICY "transactions_select" ON "public"."transactions" FOR SELECT USING CREATE POLICY "transactions_update" ON "public"."transactions" FOR UPDATE USING (("public"."is_admin"("auth"."uid"()) OR (EXISTS ( SELECT 1 FROM "public"."project_members" "pm" - WHERE (("pm"."project_id" = "transactions"."project_id") AND ("pm"."user_id" = "auth"."uid"()) AND ("pm"."role" = 'consultor'::"text")))))); + WHERE (("pm"."project_id" = "transactions"."project_id") AND ("pm"."user_id" = "auth"."uid"())))))); @@ -14965,6 +14807,12 @@ GRANT ALL ON TABLE "public"."profiles" TO "service_role"; +GRANT ALL ON TABLE "public"."project_access" TO "anon"; +GRANT ALL ON TABLE "public"."project_access" TO "authenticated"; +GRANT ALL ON TABLE "public"."project_access" TO "service_role"; + + + GRANT ALL ON TABLE "public"."project_deliverables" TO "anon"; GRANT ALL ON TABLE "public"."project_deliverables" TO "authenticated"; GRANT ALL ON TABLE "public"."project_deliverables" TO "service_role"; diff --git a/docs/atros-v3/handoff-2026-08-18-valor-e-saas.md b/docs/atros-v3/handoff-2026-08-18-valor-e-saas.md new file mode 100644 index 00000000..710a53f6 --- /dev/null +++ b/docs/atros-v3/handoff-2026-08-18-valor-e-saas.md @@ -0,0 +1,180 @@ +# Handoff 2026-08-18 — trilha de Valor, pente fino, e a virada para SaaS + +**Onde a próxima sessão pega.** Leia este arquivo antes de qualquer coisa. + +--- + +## 1. O arco desta sessão + +Começou com uma pergunta de negócio: **qual é o ganho real de uma empresa que usa o ATR OS?** +Virou três frentes, depois um pente fino, depois código, e no fim uma virada de arquitetura. + +| Frente | O que é | Onde | +|---|---|---| +| **F1** | O cenário macro do Brasil e como ele aterrissa numa empresa de R$ 100k–3M/mês | [`docs/valor/frente-1-cenario-brasil-2026.md`](../valor/frente-1-cenario-brasil-2026.md) | +| **F2** | O que o ATR OS de fato faz, verificado no código com `arquivo:linha` | [`docs/valor/frente-2-o-que-o-atros-faz.md`](../valor/frente-2-o-que-o-atros-faz.md) | +| **F3** | A oferta de valor que liga os dois | [`docs/valor/frente-3-oferta-de-valor.md`](../valor/frente-3-oferta-de-valor.md) | +| **Pente fino** | A jornada real de um cliente simulado, tela por tela | [`docs/valor/pente-fino/`](../valor/pente-fino/) | +| **Backlog** | G-01 a G-10 | [`docs/valor/gaps-backlog.md`](../valor/gaps-backlog.md) | + +As duas peças de arquitetura que saíram e que valem reusar em qualquer material: + +1. **Só existem três buracos** — margem · tempo · custo do dinheiro — e **os três aparecem idênticos + no extrato**. Por isso "olhar o banco" não resolve. A DRE nomeia ONDE, não QUAL. +2. **Não são 11 telas, são 7 perguntas em ordem obrigatória** (verdade → onde → por quê → quando → + qual alavanca → pra onde → o que faço), e a ordem é imposta por **gate no código**. + +--- + +## 2. O pente fino — 9 etapas, 3 fechadas + +Cliente simulado: **Casa Pilar**, atacado de material de construção, R$ 520 mil/mês, 32 meses de +extrato, ~20 mil transações. Persona completa em [E0](../valor/pente-fino/00-cliente-e-regua.md). + +| # | Etapa | Estado | +|---|---|---| +| E0 | O cliente e a régua | ✅ | +| E1 | A porta: ingestão | ✅ ~2 h de cliente | +| E2 | A verdade: categorização | ✅ 4 a 29 h, conforme o perfil | +| **E3** | **O fechamento dos 32 meses** | ⏳ **é aqui que se retoma** | +| E4 | ONDE + POR QUÊ (Resultado · Giro · Endividamento · Concentração) | ⏳ | +| E5 | QUANDO + QUAL ALAVANCA (Caixa · Projeção · Simulador) | ⏳ | +| E6 | PRA ONDE + O QUE FAÇO (Plano · Cockpit · Iniciativas · chat × sistema) | ⏳ | +| E7 | A cadência (dia · semana · mês · trimestre) | ⏳ | +| E8 | Veredito de maturidade + backlog priorizado | ⏳ | + +**O rito de cada etapa:** + +``` +simular o uso → perguntar ao código → selar ✅/🟡/🔴 → medir o tempo do CLIENTE +``` + +Nenhuma afirmação sobre o sistema entra sem `arquivo:linha`. Estimativa é marcada como estimativa e +traz a premissa junto. + +--- + +## 3. ⚠️ A virada: reler tudo com cabeça de SaaS + +**Decisão do Lucas em 18/ago: o ATR OS é SaaS de uso próprio. O consultor não é etapa do fluxo.** +Lei completa em [`saas-sem-consultor.md`](saas-sem-consultor.md). + +Isso chegou **no fim** da sessão. Ou seja: **F2, F3 e o pente fino foram escritos com o consultor +como premissa silenciosa.** Não estão errados nos fatos — foram todos verificados no código — mas +estão errados no **julgamento**, porque toleravam como normal um passo que agora não tem dono. + +### O que está contaminado, item por item + +| Documento | O que precisa ser relido | Por quê | +|---|---|---| +| **F1** | ✅ **nada** | Cenário macro independe do modelo de entrega | +| **F2 §5.3** | O veredito de C7 ("termina em ação") | Tratei o gate de aprovação do Plano como desenho legítimo. Sob SaaS, `canOperateProject` barra o dono: **C7 é 🔴, não ⚠️** | +| **F2 §7** | A linha do tempo do valor | O último marco diz "histórico + aprovação". Quem aprova? | +| **F3 §6** | A oferta em 4 marcos | Mesmo problema: o marco final depende de um papel que não existe | +| **F3 §8** | "O que NÃO prometer" | Precisa de item novo: hoje o cliente **não consegue** travar a própria meta | +| **E0** | ✅ **já corrigido** | A régua perdeu "tempo-consultor"; todo 🟡 virou defeito | +| **E1** | O 🟡 da distribuição de conta nova | A RLS **permite** ao cliente (`pending_ofx_cliente_update`) — falta checar se a **UI** expõe. Se não expõe, é 🔴 | +| **E2** | Os 🟡 e a premissa de quem trabalha | Sob SaaS, as 4–29 h de categorização são **inteiramente do cliente**. Ninguém da ATR ajuda | + +### A consequência mais dura, e ela é de produto + +Sob SaaS, **as 4 a 29 horas de categorização da E2 são do cliente, sozinho, antes de ver qualquer +valor.** Não há consultor para absorver isso no onboarding. + +Isso muda o status do **G-07** (regra por token contido): deixa de ser otimização e vira +**existencial para a oferta**. Já está implementado nesta sessão — mas a E2 precisa ser relida com +essa lente, e a F3 precisa refletir o número real de horas *do cliente*. + +### A pergunta nova que o modelo SaaS levanta e a trilha nunca fez + +> **O cliente sozinho consegue interpretar o que vê?** + +A régua antiga media *"a cliente sabe o que fazer sem alguém traduzindo"* como item 4 e nunca o +testou de verdade — porque havia um consultor implícito para traduzir. **Nas etapas E4 a E6 esse +vira o critério principal**, não um acessório: se a tela precisa de alguém explicando, ela falhou. + +--- + +## 4. Estado do código + +**Branch:** `feat/partes-relacionadas-e-regra-token` · 4 commits · **não mergeada** + +``` +0b2a1f0 docs(atros-v3): SaaS sem consultor — o cliente é a autoridade do próprio projeto +ef50279 feat(categorizacao): regra por token contido — 1.358 decisões viram 1 +0f00745 feat(partes-relacionadas): liga o QSA no cadastro de sócios e varre a contraparte +a48c5e5 docs(valor): trilha de valor F1-F3 e pente fino E0-E2, com os diagnósticos +``` + +### Já aplicado em PRODUÇÃO (não é só código) + +- **Migration `20260818000000`** (`categorization_rules.match_type`) — aplicada pelo Lucas. Verificado + ao vivo: 151 regras, todas `exact`. +- **Varredura de contraparte executada** em dado real: `socio` 5 → **795**, `empresa_grupo` 9 → **193**, + `propria` 2 → **57**. `contraparte_porta` seguiu em 9 — nada foi decidido sozinho. +- **Sync de sócios executado**: 8 já em dia, 0 criados (os 8 semeados à mão batem com o QSA). + +> ⚠️ **O banco está à frente do `db/snapshot/`.** O snapshot não foi refeito depois da migration nem +> da varredura. Antes de qualquer afirmação sobre o estado do banco, **re-dump** — a regra do +> `CLAUDE.md` vale integralmente aqui. + +### Ferramentas READ-ONLY que ficaram em `scripts/` + +| Script | Serve para | +|---|---| +| `diag-grupos-por-transacao.ts` | **Estimar o onboarding ANTES de vender** — mede linhas/grupo do cliente | +| `diag-tokens-cauda.ts` | Achar o token que colapsa a cauda (é o protótipo da sugestão automática) | +| `diag-cauda-normalizacao.ts` | Separar cardinalidade real de bug de normalização | +| `diag-materialidade-cauda.ts` | Quanto dinheiro tem na cauda | +| `diag-manually-reviewed-null.ts` | Dimensiona o E2-D | +| `dry-run-sincronizar-socios.ts` | `--executar` para gravar | +| `dry-run-varredura-contraparte.ts` | `--executar` para gravar | + +### Falta na feature (pequeno) + +Botão de **reprocessar contraparte** para quando o cliente cadastrar um sócio no mês 3. Hoje só via +script. O motor está pronto (`aplicarContrapartesNoProjeto`). + +--- + +## 5. Perguntas abertas que travam trabalho + +1. **Consultor lê ou também escreve?** (bloqueia a primeira policy da re-arquitetura — muda de 1 para + 2 policies × 47 tabelas). Ver [`saas-sem-consultor.md` §6](saas-sem-consultor.md). +2. **Se o cliente trava a própria meta, qual passa a ser o gate de qualidade?** Hoje a aprovação do + consultor é o que impede meta calibrada em dado ruim. O `consultorPronto` (12 meses da janela + fechados) provavelmente basta — **mas é decisão**. + +--- + +## 6. Duas ordens possíveis para retomar + +**A · Arquitetura primeiro** — executar `saas-sem-consultor.md` (inverter `canOperateProject` → view +`project_access` → colapsar as 104 policies), depois voltar ao pente fino já sob o modelo certo. +*A favor:* não se audita duas vezes. E destrava as perguntas 6 e 7 da cadeia. + +**B · Pente fino primeiro** — seguir E3→E8 com a lente SaaS aplicada, terminar o mapa de maturidade, +e só então mexer na arquitetura com a lista completa na mão. +*A favor:* a re-arquitetura sai mais informada; E3–E5 quase não tocam em papel de usuário. + +**Recomendação:** **A**, e só por um motivo — E6 (PRA ONDE + O QUE FAÇO) é justamente a etapa que a +mudança de papéis reescreve. Auditá-la antes seria auditar código que vai mudar. + +--- + +## 7. Faxina anotada, sem pressa + +- **G-10** — 40 problemas de lint pré-existentes em `src/features/dre` (`dreService.ts`, + `calculator.ts`, `periodSelection.ts`). Nada das levas desta sessão, que fecharam com zero. +- **G-08** — remover `ai-categorizer.ts` (código morto; a categorização é determinística por decisão + do Lucas) e avaliar o DROP de `learned_rules`. +- **G-04** — rota órfã `api/ai/htr/suggest`, sem caller no front. +- **G-05** — dois comentários de gate dizendo "≥3 meses" quando o gate real é 12. + +**Dois gotchas descobertos na marra, para não repetir:** + +1. **`npm run build` type-checa `scripts/` junto com `src/`** — o `tsc -p tsconfig.json` não pega. + Script novo tem de compilar. +2. **Lintar `scripts/` também.** O husky bloqueou um commit por 5 erros que eu não tinha visto por só + ter lintado `src/`. E o hook demora: **dê mais de 2 minutos de timeout ao `git commit`**, senão ele + morre no meio e deixa um stash de backup pendurado. diff --git a/docs/atros-v3/handoff-2026-08-18b-saas-executado-e-vazamento.md b/docs/atros-v3/handoff-2026-08-18b-saas-executado-e-vazamento.md new file mode 100644 index 00000000..dd62751d --- /dev/null +++ b/docs/atros-v3/handoff-2026-08-18b-saas-executado-e-vazamento.md @@ -0,0 +1,343 @@ +# Handoff 2026-08-18b — o SaaS executado, e a retomada do pente fino + +> **⚠️ SUPERSEDIDO em 20/ago.** O pente fino fechou (E0–E8). O handoff canônico agora é +> [`handoff-2026-08-20-pente-fino-fechado.md`](handoff-2026-08-20-pente-fino-fechado.md), que traz o +> veredito e a **recomendação de por onde começar**. Este arquivo fica como registro da sessão de +> 18/ago — a execução da virada SaaS e o vazamento multi-tenant. + +**Onde a próxima sessão pega:** ver o handoff de 20/ago (link acima). + +*Atualizado em 20/ago: o pente fino fechou (E0–E8). **Veredito: o produto não está imaturo — está +desconectado.** 52 achados; dos 24 críticos, 10 são fiação (peça existe, lê a fonte errada) e 12 são +construção. A cadeia das 7 perguntas quebra em quatro elos, e o item 3 do critério de maturidade — +"nenhum elo exige alguém da ATR" — **reprova**, corrigindo a avaliação de 18/ago. A E5b (Caixa & +Projeção) é a única pergunta sem veredito, adiada até o redesenho.* + +--- + +Continua o [handoff anterior](handoff-2026-08-18-valor-e-saas.md), que deixou a lei do SaaS decidida +e **não executada**. Agora ela está em produção, e o pente fino pode rodar sob o modelo certo. + +--- + +## 1. A retomada, em uma tela + +**Trilha:** o pente fino da jornada real de um cliente simulado — **Casa Pilar**, atacado de material +de construção, R$ 520 mil/mês, 32 meses de extrato, ~20 mil transações. Persona e régua completas em +[`pente-fino/00-cliente-e-regua.md`](../valor/pente-fino/00-cliente-e-regua.md). + +**Pergunta da trilha:** o sistema está maduro para a promessa dos **3 buracos** e das **7 perguntas**, +ou o que falta? + +**O rito de cada etapa — não mude:** + +``` +simular o uso → perguntar ao código → selar ✅/🟡/🔴 → medir o tempo do CLIENTE +``` + +Nenhuma afirmação sobre o sistema entra sem `arquivo:linha`. Estimativa é marcada como estimativa e +traz a premissa junto. + +**A régua, agora sem o meio-termo:** + +| Selo | Significa | +|---|---| +| ✅ **Pronto** | a cliente faz sozinha, dentro do sistema | +| 🟡 **Assistido** | ~~funciona, mas exige alguém da ATR~~ → **é defeito.** Sob SaaS não existe "alguém da ATR": 🟡 é trabalho sem dono | +| 🔴 **Falta** | não existe | + +**A pergunta nova, que vira o critério principal de E4 a E6:** + +> **O cliente sozinho consegue interpretar o que vê?** +> +> A régua antiga media isso como item 4 e nunca o testou de verdade, porque havia um consultor +> implícito para traduzir. Se a tela precisa de alguém explicando, ela falhou. + +--- + +## 2. O pente fino — CONCLUÍDO (E0–E8); só a E5b fica devendo + +| # | Etapa | Estado | Tempo-cliente | +|---|---|---|---| +| [E0](../valor/pente-fino/00-cliente-e-regua.md) | O cliente e a régua | ✅ | — | +| [E1](../valor/pente-fino/01-ingestao.md) | A porta: ingestão dos 32 meses | ✅ | ~2 h | +| [E2](../valor/pente-fino/02-categorizacao.md) | A verdade: categorização das ~20 mil transações | ✅ | 4–5 h | +| [E3](../valor/pente-fino/03-fechamento.md) | O fechamento: 32 meses apurados | ✅ | ~5 h | +| [E4](../valor/pente-fino/04-onde-e-porque.md) | ONDE + POR QUÊ (Resultado · Giro · Endividamento · Concentração) | ✅ **fechada 19/ago** | ~1 h 35 | +| [E5](../valor/pente-fino/05-simulador.md) | QUAL ALAVANCA (Simulador · Mesa de Decisão) | ✅ **fechada 19/ago** | ~35 min | +| E5b | QUANDO (Caixa · Projeção) | ⏸️ **adiada** — redesenho (decisão Lucas, 19/ago) | | +| [E6](../valor/pente-fino/06-pra-onde-e-o-que-faco.md) | PRA ONDE + O QUE FAÇO (Plano · Cockpit · Iniciativas · chat) | ✅ **fechada 19/ago — a cadeia quebra aqui** | ~5 min | +| [E7](../valor/pente-fino/07-cadencia.md) | A cadência de uso (dia · semana · mês · trimestre) | ✅ **fechada 19/ago — R9 passa, R6 reprova** | <1 h/mês | +| [E8](../valor/pente-fino/08-veredito.md) | Veredito de maturidade + backlog priorizado | ✅ **fechada 20/ago** | — | + +### Por que parou em 3 de 9 — foi escolha, não atraso + +O handoff anterior recomendou **arquitetura primeiro**, porque a **E6** audita exatamente o código +que a mudança de papéis ia reescrever. Auditar antes seria auditar código que ia mudar. A +arquitetura foi executada nesta sessão (§4); E3–E8 agora rodam sob o modelo definitivo. + +### O que mudou na régua depois da execução + +O E0 dizia: *"Estado deste critério hoje: **reprovado no item 1 e no 3**"* — porque as perguntas 6 e +7 ("pra onde vou" / "o que eu faço") estavam trancadas atrás de `canOperateProject`. + +**Isso caiu.** O dono gera e trava a própria meta; as Iniciativas destravam pelo plano aprovado por +ele. Os itens 1 e 3 do critério de maturidade passam — **no código**. Falta o check ao vivo (§6). + +### Dois itens da "contaminação" já resolvidos, com evidência + +O handoff anterior listou o que precisava ser relido sob a lente SaaS. Dois fecharam: + +1. **E1 — o 🟡 da distribuição de conta nova cai para ✅.** A dúvida era "a RLS permite, mas a UI + expõe ao cliente?". **Expõe:** `CasaDoGrupoShell` → `IngestaoCasaTab:139` → `GroupIngestionTab`, + que renderiza o `DistribuirContaPendenteModal` (`:292`) com o botão "Distribuir" (`:376`). O + diretório `forense/` engana — é pasta de componentes, **não existe rota forense**; esses + componentes são consumidos pela Casa do cliente. Na RLS o caminho é + `pending_ofx_cliente_update`/`_delete` + `bank_accounts_cliente_insert`, todos via + `project_access`. *(Selado pelo código; o check ao vivo confirma o render.)* + + **E mais:** aquele 🟡 **nunca existiu.** A leitura original olhou `pending_ofx_update`/`_delete` + (staff-only) e não viu as irmãs `pending_ofx_cliente_*`, que já estavam na baseline. Policies se + somam por **OR** — ler uma isolada engana. É para isso que serve + `scripts/saas/policies-da-tabela.ts`. O E1 já está corrigido no arquivo. +2. **F2 §5.3 — o veredito de C7 ("termina em ação") deixa de ser 🔴.** O gate de aprovação não barra + mais o dono. + +### ~~🔴~~ ✅ O achado que a E4–E6 ia encontrar em cada tela — **fechado em `2425c6d`, ver §8** + +A pergunta nova é *"o cliente sozinho consegue interpretar o que vê?"*. Já há uma resposta parcial, e +é **não** — por um motivo banal e corrigível: **9 textos de tela mandam o cliente falar com um +consultor que o modelo removeu.** Sete estão em caminho vivo: + +| Onde | O texto | +|---|---| +| `plano-voo/atos/AtoVCaminho.tsx:121,167` | "recalibrar com o consultor" · "conduzido junto ao seu consultor" | +| `plano-voo/relatorio/PlanoVooRelatorio.tsx:574` | idem, no relatório impresso | +| `lib/pacing/pacing-core.ts:513` | "Vale sentar com o seu consultor para recalibrar" — a narrativa do Cockpit | +| `lib/ofx-scan-service.ts:460` | "Avise seu consultor" — na ingestão | +| `actions/conta/membership.ts:67` | "Fale com o consultor." | +| `lib/email-service.ts:268` | e-mail ao cliente | + +As outras 2 estão em `ofx-import-service.ts:20,122` — **serviço sem nenhum caller**, caminho morto +(a própria E1 já o tinha marcado como armadilha). + +O sistema deixou o dono fazer tudo; **o texto ainda aponta para alguém que não existe.** Não confundir +com o "**Consultor IA**" (o chat) — esse é nome de feature e fica. + +### Segue contaminado, e não relido + +- **F2 §7** — a linha do tempo do valor termina em "histórico + aprovação". Quem aprova mudou. +- **F3 §6** — a oferta em 4 marcos, mesmo problema no marco final. +- **F3 §8** — "o que NÃO prometer" precisa de item novo. +- **E2** — as 4–5 h de categorização são **inteiramente do cliente**, sozinho, antes de ver qualquer + valor. Foi o que tornou o G-07 (regra por token) existencial, e não otimização. + +--- + +## 3. O placar do backlog G-01…G-10 + +Todos reverificados **no código vivo nesta sessão** — nenhum repassado da doc. + +| # | O quê | Estado | Evidência | +|---|---|---|---| +| **G-01** | Aging e risco de recebível | 🔴 aberto | grep em `src/lib` → **zero**. **O de maior valor da lista** | +| **G-02** | Vigilância da porta do crédito | 🔴 aberto (decisão de escopo) | único hit é `client-service.ts:60`, do CRM de leads | +| **G-03** | Ponteiro do Cockpit não consulta o gate | 🔴 aberto | `treasury/veredito-caixa.ts:290` segue `acaoNoHtr: semaforo !== "verde"` | +| **G-04** | Rota órfã `api/ai/htr/suggest` | 🔴 aberto | zero callers no front | +| **G-05** | Comentários de gate mentindo | ✅ **fechado** | eram **8** lugares em 6 arquivos, não 2 — §5 | +| **G-06** | Partes relacionadas ligadas na tomada | 🟡 quase | falta reprocessar por UI: `aplicarContrapartesNoProjeto` só tem caller em `scripts/` | +| **G-07** | Regra por token contido | ✅ fechado | sessão anterior | +| **G-08** | Remover o categorizador por IA | 🟡 metade | código morto removido (−308 linhas); DROP de `learned_rules` **adiado por decisão** — SQL parqueado no G-08 do backlog | +| **G-09** | **SaaS sem consultor** | ✅ **fechado** | 3 migrations em produção — §4 | +| **G-10** | Débito de lint em `src/features/dre` | 🔴 aberto | 40 problemas (27 err, 13 warn), o mesmo número | +| **G-11** | **O dono não consegue APROVAR (travar) a própria meta** | 🔴 **aberto — achado no check ao vivo, 18/ago** | `documento/page.tsx:57` redireciona quem não é `PRIVILEGED_ROLES`, e é a **única** página que monta o `WorkflowControls` — o único caller de `approvePlanoVooV2Action`. Gerar passa; travar não. Sem plano aprovado, **Iniciativas trancam** (`iniciativas-gate.ts:57`) e o **Cockpit devolve zero ações** (`cockpit/route.ts:41`). Parqueado por decisão do Lucas: resolver depois. | + +--- + +## 4. O que esta sessão fez + +**6 commits, 5 migrations aplicadas em produção**, snapshot re-dumpado e autoritativo, suíte verde +(181 arquivos / 1.885 testes), build `EXIT=0`. + +``` +2850f91 docs(banco) re-dump + handoff +66771e8 chore(faxina) G-05 e G-08 +d4d3129 fix(cockpit) lê pelo plano vigente +22a9816 fix(seguranca) vazamento multi-tenant +96e9bbb feat(saas) autoridade pelo vínculo +263a2f4 fix(build) ProjetoRef +``` + +### 4.1 G-09 — o SaaS executado + +**A lei subestimava o terreno.** Ela mapeou 3 padrões de bypass; existem **cinco**. O que faltava — +**36 policies em 22 tabelas fixando `pm.role = 'consultor'`** — é invisível ao grep dela e era +justamente o que tornaria a inversão no app **inerte**: o dono passaria no app e apanharia da RLS, +em silêncio. Números corrigidos: **367** policies (não 348), **120** bypasses (não 104), **97** a +repointar (não 242 — a doc contou linhas). + +| Migration | O quê | +|---|---| +| `20260818010000` | os 2 INSERTs do caminho de escrita do Plano de Voo | +| `20260818020000` | view `project_access` + **139 policies** reescritas | +| `20260818030000` | o dono cadastra e conserta o próprio cadastro | + +No app: `canOperateProject` invertida (vínculo, não papel) e **o gate de qualidade movido para o +caminho de escrita** — `planoVooDestravado` só existia no render de `page.tsx`; as actions e a rota +aceitavam qualquer chamada. + +**Decisões travadas, não re-perguntar:** consultor escreve igual ao cliente (1 policy por tabela); +DELETE de dado financeiro apurado segue admin-only, cadastro abre pro dono. + +**O fato que fecha a decisão por outro caminho:** não existe **um único** `profiles.role` em +`('consultor','sdr')` no banco. O bypass que aparecia em 120 lugares não protegia uma pessoa. + +### 4.2 🔴 O vazamento multi-tenant — não estava em lista nenhuma + +**31 policies liberavam acesso por simplesmente ESTAR LOGADO.** Como policies permissivas se somam +por **OR**, elas anulavam o escopo por vínculo das vizinhas. Com o signup self-service em produção, +virou vazamento entre clientes: qualquer cliente logado listava **todos os projetos da plataforma** e +dava UPDATE em qualquer um — inclusive no `data_inicio`, que define a janela de 12 meses da meta. + +Consertado em 2 migrations (`…040000`, `…050000`): **31 → 8**. As 8 restantes são SELECT em catálogo +global sem chave de tenant — desenho correto, não vazamento. + +### 4.3 Os outros três defeitos + +1. **O build desta branch estava quebrado** — `ProjetoRef` nunca declarado nos 3 `scripts/diag-*.ts` + commitados na sessão anterior. +2. **O Cockpit misturava planos** (latente): lia por `project_id`, e a limpeza no approve mirava o + snapshot errado *e* era inerte (sem policy de DELETE, o `.delete()` apagava zero linhas sem erro). + Conserto: ler pelo **snapshot vigente**, não apagar — preserva o progresso já marcado. +3. **O gate de 12 meses não estava no caminho de escrita** — era a pergunta aberta nº 2 do handoff + anterior, que a supunha resolvida. + +--- + +## 5. As duas armadilhas que valem mais que o conserto + +### 5.1 O subselect que nega tudo em silêncio + +`project_members` tem RLS **own-row** (`project_members_self`): subselect nela dentro de policy roda +como o usuário, então procurar o vínculo de **outra** pessoa volta **vazio, sem erro**. + +A policy de `profiles` precisa enxergar o colega de projeto — por isso usa a view `project_access`, +que roda como owner e atravessa. `projects_members` pode usar a tabela porque filtra +`pm.user_id = auth.uid()` (a própria linha). + +> **Regra:** precisa ver vínculo de terceiro ⇒ use a view, nunca a tabela. + +### 5.2 O número na prosa só é erro se o literal discordar + +No G-05, dois candidatos óbvios de "≥3 → 12" estavam **certos** — descrevem outra regra, com um 3 +real: `auto-conclusion-service.ts:91` é literalmente `snapshotsTotal >= 3`, e `module-service.ts:51` ++ `dre-publishing-service.ts:6` são a escada de publicação da DRE (`rascunho <3 → preliminar 3-11 → +consolidada 12+`). Um replace varrendo o repo teria corrompido os dois. + +Em compensação, três dos comentários errados propagavam **decisão revogada** — "folha é gate" +(revogada em 2026-06-05), "gate D-3" (a própria revogada) e "o ato explícito do consultor". Não era +cosmético: são os arquivos que alguém abre para entender o gate. + +--- + +## 6. O que falta, na ordem + +1. ⚠️ **Check ao vivo — FEITO em 18/ago, e derrubou a premissa (ver G-11).** O roteiro abaixo é + intestável na Vertímetal: ela já tem documento (o Hero com o botão de gerar não renderiza), já + tem 12 meses (a recusa do server não dispara) e **aprovar o dono não alcança** — o ato que trava + a meta é staff-only por um gate *diferente* do que a virada SaaS inverteu (`resolveRole` + + `PRIVILEGED_ROLES` + redirect, não `canOperateProject`). Grep de um não acha o outro. Roteiro + original, para quando o G-11 fechar: + 1. chega no Plano de Voo e o botão de gerar não está mais barrado por papel; + 2. com menos de 12 meses fechados, o **server** recusa com a mensagem de prontidão (não mais + "Apenas consultor/admin") — é o gate novo no caminho de escrita; + 3. aprovar grava as 12 semanas e o Cockpit mostra **12, não 24**. + + Isso fecha a fatia 1 de verdade: o `metodo.md` diz que o gate É o check ao vivo, e até aqui a + prova é toda estática. + +2. **Pente fino E4 → E8**, com a pergunta nova (§1) como critério principal. A **E3 fechou** — o + veredito é que o motor de fechamento é sólido e o caminho do cliente não: o gate pede o **valor de + face** da antecipação, que não vem no OFX e é justamente o número que a cliente veio buscar; nada + é herdado entre meses (32× a mesma pergunta); não há fechamento em lote; e **refechar o histórico + quando o catálogo/motor mudam só existe como script** — medido: o histórico da Vertímetal foi + refechado em massa 4 vezes em 2 meses. Achados E3-A…E3-G em + [`03-fechamento.md`](../valor/pente-fino/03-fechamento.md). + +3. ~~**Os 9 textos que citam o consultor** (§2)~~ — ✅ **feito** em `2425c6d` (§8). Eram **21**, não 9. + +4. **G-03** — o ponteiro do Cockpit que manda o cliente para uma tela travada. Pequeno e visível, e + fere o R4 (termina-em-ação-raiz). + +5. **G-06** — botão de reprocessar contraparte (hoje só via script). + +6. **G-01** — aging de recebível. O maior valor da lista inteira: a F1 mediu que a dívida que estoura + no Brasil é **comercial, não bancária**, e é exatamente onde o produto não olha. + +**Dívida anotada:** `projects_select_client` ainda lê `client_project_access_deprecated`. Hoje +redundante com `projects_members`, mas só o dado prova que todo mundo da tabela morta está em +`project_members` — derrubar sem conferir troca vazamento por bloqueio. + +--- + +## 7. Estado do banco e ferramental + +Snapshot **autoritativo** — `ultima_migration_incluida = 20260818050000`. Views 12 → **13** +(`project_access`), policies 362 → **353**; tabelas, funções, triggers e índices iguais. **Os dois +deltas foram previstos antes do dump e bateram exato** — é o que separa "a migration rodou" de "a +migration fez o que eu quis". + +Ferramental novo, read-only, em `scripts/saas/`: + +| Script | Serve para | +|---|---| +| `verificar-dump.ts` | **o gate contra a realidade** — lê o dump, não a migration | +| `auditar-acesso-do-dono.ts [--sem-saas] [--vazamento]` | por (tabela × comando), o dono tem caminho? Com e sem a flag dá o diff | +| `policies-da-tabela.ts` | o conjunto de policies de uma tabela — olhar uma a uma engana, elas se somam por OR | +| `transformar-policies.ts` · `gerar-migration-fatia2.ts` | o transformador e o gerador das 139 policies. **Nunca editar o SQL à mão** | + +**Gotchas de ambiente que custaram tempo:** `npm run build` type-checa `scripts/` junto com `src/`; +o husky compara HEAD × working (débito antigo não bloqueia, regressão sim) e demora — dê mais de 2 +minutos de timeout ao `git commit`; e `node -e '...'` com aspas simples no bash **come as aspas** — +use arquivo de script. + +--- + +## 8. A varredura de copy do consultor — feita (`2425c6d`, 18/ago) + +Eram **21 lugares**, não 9: a leitura anterior grepou frases prontas ("seu consultor"), e não pegou +a família de **rótulos** — que é onde estava o pior caso. + +**O pior caso, e por que ele reescreve a regra:** os blocos rotulados **"Consultor responsável"** (a +**capa** do Plano de Voo, o fecho do relatório e o do PDF) não mostram um consultor: mostram +`fetchConsultor(userId)` — **quem gerou o documento**. Sob SaaS, esse alguém é **o próprio dono**. Ou +seja, a capa do plano do cliente ia dizer "Consultor responsável: ". Trocar o rótulo +para "Suporte ATR" teria mentido do mesmo jeito, só que ao contrário. O rótulo honesto nos dois +mundos — plano gerado por staff na era da consultoria, plano gerado pelo dono agora — é **"Gerado por"**. + +> **A regra que fica:** antes de reescrever um texto que cita um papel, olhe **de onde vem o dado que +> ele exibe**. Rótulo errado sobre dado certo não se conserta trocando o rótulo por outro nome de gente. + +Varridos, por classe: veredito de pacing do Cockpit (`pacing-core`, 2 variantes) · legenda "atraso +crítico" (relatório + ato V) · a meta "calibrada pelo consultor" · o dia a dia "conduzido junto ao seu +consultor" → agora Cockpit + Iniciativas · erro de distribuição de OFX · erro de acesso ao grupo → +agora aponta para **Minha Conta → Empresa → Pessoas** (`inviteToGroupAction` existe: o dono convida) · +rodapé de e-mail · Minha Conta → Suporte · "nova rodada de consultoria" nos próximos passos · parecer +automático da análise 8. + +**Deixado de propósito — e o motivo importa:** + +| O quê | Por que não é copy | +|---|---| +| **"Parecer do consultor"** | campo humano real, escrito no `WorkflowControls` e escondido do dono por padrão (`AnalysisPageShell:59`). Decisão de produto, não texto. | +| `email-service.ts:216` | "seu consultor responsável é X" — X vem de `project.created_by`, que num signup self-service **é o próprio cliente**. O defeito é a **fonte da identidade**; remendar a frase esconderia o bug. | +| `ofx-import-service.ts:20,122` | serviço sem nenhum caller — código morto (a E1 já marcou). | +| **`COMPROMISSO_ATR`** | **decisão do Lucas (18/ago): FICA.** O fecho promete acompanhamento humano ("o time ATR … a cada mês fechado revisamos"). Sob SaaS parece defeito, **mas é oferta** — a consultoria de 90d segue vendida à parte. **Não re-levantar na E6.** | + +**Prova:** eslint **0 erros novos** (os 3 hits em linhas tocadas são `text-[var(--…)]` que já existiam +no HEAD — só o texto dentro do elemento mudou) · `tsc --noEmit` **EXIT=0** · suíte **181/1.885 verde**. +`next build` **não** rodado: o dev server estava no ar em :3000, e o build derruba as rotas dele. + +**A branch foi empurrada** (`origin/feat/partes-relacionadas-e-regra-token`). Antes disso os 12 +commits existiam só na máquina do Lucas, com as 6 migrations **já aplicadas em produção** — banco à +frente do código, e o código sem cópia em lugar nenhum. diff --git a/docs/atros-v3/handoff-2026-08-20-pente-fino-fechado.md b/docs/atros-v3/handoff-2026-08-20-pente-fino-fechado.md new file mode 100644 index 00000000..a4cf622e --- /dev/null +++ b/docs/atros-v3/handoff-2026-08-20-pente-fino-fechado.md @@ -0,0 +1,240 @@ +# Handoff 2026-08-20 — o pente fino fechado, e por onde começar + +> **⚠️ SUPERSEDIDO em 20/ago (sessão b).** A A1 do plano de correção foi executada: o Bloco 0 +> está quase inteiro em `main`, e a corrente vai do extrato à meta travada. O handoff canônico agora é +> [`handoff-2026-08-20b-a1-executada.md`](handoff-2026-08-20b-a1-executada.md), que traz **onde pegar +> (E3-B, com a conta e a armadilha)** e o roteiro do check ao vivo. Este arquivo fica como registro do +> veredito do pente fino. + + +**Onde a próxima sessão pega: a Fatia A da §3 — "o dono chega até a ação".** Três itens, todos +pequenos, e o único caminho que produz o **check ao vivo** que a trilha inteira ficou devendo. + +Continua o [handoff 18b](handoff-2026-08-18b-saas-executado-e-vazamento.md), que deixou a virada SaaS +em produção e o pente fino em 3 de 9 etapas. **Agora ele está fechado: E0–E8.** + +**O relatório é o documento canônico daqui pra frente:** +[`docs/valor/pente-fino/RELATORIO-TECNICO.md`](../valor/pente-fino/RELATORIO-TECNICO.md) — +autocontido. O detalhe por etapa está nos `00-…` a `08-…` da mesma pasta. + +--- + +## 1. O veredito, em uma tela + +> **O produto não está imaturo. Está desconectado.** + +A promessa é uma **cadeia de 7 perguntas em ordem obrigatória**. A varredura percorreu a cadeia +inteira como a cliente percorreria. **Ela quebra em quatro dos sete elos** — na entrada, no meio e no +fim. E o fim é o que mata, porque tudo antes dele existe para chegar lá. + +| # | Pergunta | Veredito | +|---|---|---| +| 1 | *"Isso aqui é verdade?"* | 🔴 trava — o gate pede a face da antecipação, que o extrato não traz | +| 2 | *"Deu lucro? Pra onde foi?"* | 🔴 responde diferente de si mesmo | +| 3 | *"Por que o buraco existe?"* | 🟡 2 de 3 (Giro ✅ · Endividamento ✅ · Clientes 🔴) | +| 4 | *"Isso me mata? Quando?"* | ⏸️ **não auditada** — Caixa & Projeção, em redesenho | +| 5 | *"O que eu mexo primeiro?"* | 🟡 a melhor peça do produto, e sem saída | +| 6 | *"Pra onde eu vou?"* | 🔴 a dona gera e **não consegue travar** | +| 7 | *"Estou indo? O que faço?"* | 🔴 **não abre** | + +**52 achados** — 24 🔴 · 15 🟠 · 9 🔵 · 2 🟢 · 2 ✅. **Dos 24 críticos, 10 são fiação:** a peça +existe, está correta, e lê a fonte errada — ou não está ligada em nada. **Três consertos já estão +escritos no repositório** e nunca foram repontados (`snapshotAprovadoVigente`, `consultorPronto`, o +padrão de paginação `.range()`). + +**Custo medido da jornada:** ~13 h 40 de entrada, < 1 h/mês depois. **Nenhum dos 4 critérios de +maturidade passa** — inclusive o item 3 ("nenhum elo exige alguém da ATR"), que o handoff de 18/ago +dava como aprovado e a E6 derrubou. + +--- + +## 2. O que esta sessão fez + +**Nenhuma linha de `src/` foi tocada.** Foi varredura, não conserto — por decisão do Lucas +("auditar antes de consertar", 19/ago). + +| Entregue | O quê | +|---|---| +| E4 · E5 · E6 · E7 · E8 | cinco etapas novas do pente fino, fechadas contra o código vivo | +| `RELATORIO-TECNICO.md` | o compilado autocontido (724 linhas) | +| `README.md` da pasta | porta de entrada e mapa | +| Anexos de evidência | tabela de `arquivo:linha` em E1–E7, para reauditar sem refazer | +| Decisão E3-B registrada | o switch da antecipação, com a fórmula e os invariantes (§5) | +| Correções | 3 citações erradas e 1 achado desatualizado (§6) | + +**E5b (Caixa & Projeção) ficou de fora**, por decisão: a tela vai ser redesenhada, e auditar código +que será reescrito é desperdício. **É a única pergunta da cadeia sem veredito** — dívida declarada. + +--- + +## 3. A recomendação: por onde começar + +### 3.1 A recomendação em uma frase + +> **Comece pela Fatia A — a cadeia inteira, provada ao vivo na Vertímetal — e não pelo achado mais +> grave.** + +O item de maior impacto comercial é o **E3-B** (o switch da antecipação), porque ele trava o +onboarding de todo cliente que desconta duplicata. **Mesmo assim, não é por onde eu começaria.** + +**Três razões:** + +1. **A Vertímetal já tem 31 meses fechados.** Com a Fatia A, dá para percorrer as perguntas 1→7 de + ponta a ponta **hoje**, com dado real. O E3-B, por definição, só se prova num cliente em + onboarding — que hoje não existe na mesa. +2. **Paga a dívida de método da trilha.** Toda a prova deste pente fino é **estática**. O + [`metodo.md`](metodo.md) diz que o gate **É** o check ao vivo. A Fatia A é o menor caminho até + esse gate — e é o próprio percurso que valida os três consertos de uma vez. +3. **É o menor esforço do backlog inteiro.** Um dos três itens é literalmente um `if` em duas rotas. + +**A condição que inverte isso:** se houver **cliente esperando para entrar**, o E3-B sobe para +primeiro. Aí a ordem vira C → A → B. É a única variável que muda a recomendação, e é sua chamada. + +### 3.2 Fatia A — "o dono chega até a ação" + +**Objetivo:** a Marlene (ou a Vertímetal) sai do Cockpit, clica no único CTA de ação do produto, e +**chega em algum lugar**. + +| Ordem | Item | O que é | Tamanho | +|---|---|---|---| +| **A1** | **E6-D** — repontar o gate do Consultor IA | trocar `analysesCompleted >= 30` por `consultorPronto` em `api/ai/chat/consultor/route.ts:184,190` e `api/ai/htr/chat/route.ts:198,204`. A régua já está calculada e **carimbada no dossiê**; a flag está ON global desde maio | **1 `if` × 2 rotas** | +| **A2** | **E6-A** — a dona trava a própria meta | superfície client-first de aprovação no overview do Plano de Voo. Precisa de decisão sua antes (§4) | média | +| **A3** | **E6-B** — o CTA honesto | `veredito-caixa.ts:290` consulta `iniciativasDestravadas` antes de acender; o redirect do `htr/page.tsx:39-42` carrega o motivo (a sidebar já o tem em `useProjectSidebarBadges.ts:126-134`) | pequena | + +**Comece pelo A1.** É o melhor retorno por esforço da trilha inteira: hoje o Consultor IA conversa +sem enxergar a DRE, a timeline nem o plano do cliente — o teto estrutural de análises concluídas é +**10**, e o limiar é **30**. Um cliente com 12 meses fechados passa a ter as 10 ferramentas na hora. + +**O gate da Fatia A não é o commit — é o percurso ao vivo:** + +1. entrar como o **dono** (não staff) num projeto com 12+ meses fechados; +2. gerar o plano, **travar a meta** e ver as Iniciativas abrirem; +3. o Cockpit devolver **12 ações, não 24 nem 0**; +4. perguntar ao chat *"por que meu caixa apertou em março?"* e ele **usar uma ferramenta**. + +### 3.3 Fatia B — "o número não mente" + +Cinco repoints. **Nenhum precisa de decisão de produto** — são todos fiação, e três deles têm o +padrão pronto no repositório. + +| Ordem | Item | O conserto | +|---|---|---| +| B1 | **E5-A / E4-J** | paginar `simulador-dados/route.ts:70` e `retrato-service.ts:67`. O padrão `.range()` já existe em **11 leitores** | +| B2 | **E4-C** | Ponto de Equilíbrio: referência = último mês **fechado** (hoje é `hoje.getMonth()`), motor = MC canônica | +| B3 | **E5-B** | `lerMeta` usa `snapshotAprovadoVigente` — quatro consumidores hoje chamam rascunho de "meta travada" | +| B4 | **E4-B** | o Resultado lê o snapshot, como todo o resto | +| B5 | **E4-A** | a Curva ABC lê as linhas da DRE v3 e recebe o período do Shell | + +> **Por que B vem antes de C:** credibilidade sustenta tudo. Não adianta destravar o onboarding do +> próximo cliente se, ao chegar na tela, ele vê um Ponto de Equilíbrio calculado sobre 19 dias e uma +> concentração medida em mil linhas arbitrárias. + +### 3.4 Fatia C — "o próximo cliente consegue entrar" + +**E3-B, o switch da antecipação.** Decisão travada em 19/ago; o desenho, a fórmula e os invariantes +estão em [`03-fechamento.md` §5](../valor/pente-fino/03-fechamento.md). **Leia a armadilha antes de +codar** (§5 deste handoff). + +Junto dela, quando fizer sentido: **E3-A** (herança entre meses — mata 32 perguntas iguais de graça, +uma vez que o switch exista) e **E3-C** (fechamento em lote). + +### 3.5 Fatias D e E — depois + +**D — a decisão termina em ação (R4):** E5-C (cenário → iniciativa; hoje `projection_scenarios` só é +lido por quem o escreve) · E4-E (o veredito de cada tela aponta para a alavanca) · E4-D (a +concentração entra no Financeiro). + +**E — o sistema chama de volta:** E7-A (e-mail mensal) + E7-B (desempilhar os 4 bloqueios do +lembrete). **Depende da Fatia A:** chamar a cliente de volta antes de ela conseguir fechar o mês é +chamá-la para a mesma porta trancada. + +### 3.6 O que eu **não** faria agora, e por quê + +| O quê | Por quê esperar | +|---|---| +| **G-01 — aging de recebível** | Segue sendo o item de **maior valor** do backlog. Mas é feature nova numa cadeia que quebra em quatro pontos. Construir o 8º cômodo antes de destrancar a porta da frente é o anti-padrão que esta trilha inteira documenta. **Entra logo depois da Fatia B** | +| **E7-D — cadência trimestral** | 🔴 real, mas é decisão de produto antes de código | +| **Caixa & Projeção** | em redesenho — e a E5b fica devendo até lá | +| **G-10 — lint em `features/dre`** | 40 problemas estáveis, não afeta cliente | + +--- + +## 4. As duas decisões que precisam de você antes do código + +1. **Como é a superfície de aprovação do dono (A2/E6-A)?** O ato hoje se chama "aprovar documento" e + vive num deck de consultoria. Para a Marlene, o ato é **"travar minha meta"** — outra coisa, outro + lugar, outro texto. Sem essa definição, A2 vira chute. *(A1 e A3 não dependem disso e podem ir + antes.)* +2. **A escada de publicação da DRE (E3-F): religar ou aposentar?** Hoje os dois lados estão vivos e + desconectados — que é o pior caso. Ela é a **causa raiz** do E6-C: sem ela, as análises nunca + chegam a `concluido`. Se a Fatia A repontar o gate do chat (A1), a escada deixa de ser bloqueio e + vira só dívida — mas a decisão continua de pé. + +--- + +## 5. A armadilha do E3-B — leia antes de implementar + +O caminho "cliente informa a taxa" **já existe no motor** (Nível 1, `nivelGeral.taxa_media`) e está +**morto**: o hook passa `nivelGeral = null` (`useGateForm.ts:488`). + +**Religá-lo como está erra em silêncio, e para menos.** Sem face, `valorBrutoDe` cai em +`valor_bruto_real` (`antecipacao-multinivel.ts:92`), que o hook preenche com **o líquido do extrato** +(`useGateForm.ts:479`). Aí `decomporLinha` faz `deságio = líquido × taxa` e `líquido resolvido = +líquido − deságio` — receita subdeclarada, custo subdeclarado, e **o líquido deixa de bater com o +extrato**. + +**A implementação certa é a derivação inversa, não o forward:** + +``` +t_período = taxa_a.m. × prazo_dias / 30 (desconto comercial, como o banco cobra) +face = líquido / (1 − t_período) +deságio = face − líquido +``` + +**Invariante que o teste de regressão tem de travar:** o líquido **nunca** é recalculado — ele +continua sendo o crédito do extrato, ao centavo. + +--- + +## 6. As lições de método desta trilha + +### 6.1 Verificar a mutação não basta — verifique pelo caminho do usuário + +A virada SaaS inverteu `canOperateProject` e reescreveu 139 policies. **O gate que sobrou é outro** — +`resolveRole` + `PRIVILEGED_ROLES` + `redirect`. **Grep de um não acha o outro.** Foi assim que o +handoff de 18/ago pôde declarar o critério 3 aprovado enquanto o dono não conseguia travar a própria +meta. + +### 6.2 Sete dos oito achados mais graves são invisíveis por dentro do código + +`SUM(t.debito)` está certo como SQL. `analysesCompleted >= 30` está certo como código. **Só estão +errados como resposta a uma pergunta que alguém fez.** Eles só aparecem percorrendo a jornada. + +### 6.3 O relatório também mente se não for reverificado + +Na conferência final, um checador de existência de arquivo sobre as 89 referências citadas pegou +**três erros**, dois deles herdados dos docs originais e repetidos por mim: + +| O que | Correção | +|---|---| +| `GiroView.tsx:281-286` para o texto "meta travada" | linha errada → **`:265-268`** | +| `lib/partes-relacionadas/receita-ficha.ts` | **o arquivo não existe** — `extrairSocios` mora em `lib/receita-ficha.ts` (as linhas 35-46 estavam certas, o diretório não) | +| E2-A "código morto a remover" | **já removido** em `66771e8`; virou ✅, com a sobra registrada (o DROP de `learned_rules`, sem leitor vivo, adiado por decisão) | + +**Vale como rotina:** depois de escrever doc técnico, rode um checador de caminhos citados. Custa +segundos e pega o que a leitura não pega. + +--- + +## 7. Estado do repositório + +**Branch:** `feat/partes-relacionadas-e-regra-token`, 16 commits à frente de `origin/main`. +**Código:** intocado nesta sessão — só `docs/`. +**Banco:** snapshot autoritativo, `ultima_migration_incluida = 20260818050000`. Sem migration nova. + +**Fora do commit, por decisão sua:** `docs/business/programa-parceiros.md` e +`docs/business/proposta-cliente-final.md` seguem sem rastrear. + +**Ainda de pé, do handoff anterior:** o `projects_select_client` que lê +`client_project_access_deprecated` (redundante hoje, mas só o dado prova que todo mundo da tabela +morta está em `project_members`). diff --git a/docs/atros-v3/handoff-2026-08-20b-a1-executada.md b/docs/atros-v3/handoff-2026-08-20b-a1-executada.md new file mode 100644 index 00000000..0cd811a9 --- /dev/null +++ b/docs/atros-v3/handoff-2026-08-20b-a1-executada.md @@ -0,0 +1,233 @@ +# Handoff 2026-08-20b — a A1 executada: a corrente vai do extrato à meta travada + +Continua o [handoff do pente fino fechado](handoff-2026-08-20-pente-fino-fechado.md), que entregou o +veredito e o backlog. Esta sessão **executou o Bloco 0 quase inteiro** e reescreveu a ordem do +backlog a partir da jornada canônica do cliente. + +--- + +## 1. A retomada, em uma tela + +**Onde pega:** **E3-B — o switch da antecipação.** É o último item do Bloco 0, e o único que ainda +quebra a corrente. Decisão travada em 19/ago; spec e conta prontas; implementação aberta. **§5 tem +tudo que a próxima sessão precisa, incluindo a armadilha.** + +**A lei do trabalho:** [`docs/valor/pente-fino/PLANO-DE-CORRECAO.md`](../valor/pente-fino/PLANO-DE-CORRECAO.md) +— e dentro dele, o **§0: a jornada canônica em 14 passos**. Ela é a régua: o que não serve a jornada, +sai do backlog (foi assim que o E7-D morreu). + +**O que mudou de estado nesta sessão:** a corrente do cliente **vai hoje do extrato até a meta +travada**. O que ainda quebra no meio é a antecipação (E3-B) e o número errado em duas telas (A4). + +**A dívida que atravessa tudo:** **nada foi visto rodando.** Build limpo e 1906 testes verdes não são +o gate — o [`metodo.md`](metodo.md) diz que o gate É o check ao vivo. O Lucas vai criar um projeto +novo para isso. O roteiro está em §6. + +**Commits:** `2633ae8` (código, 41 arquivos) · `4401a8a` (o plano de correção). + +--- + +## 2. O que esta sessão fez + +### 2.1 D10 — o modelo de 30 análises sai do sistema + +O `analyses_completed` não estava só desatualizado: **ele pilotava a IA, e pilotava errado.** Três +camadas, uma dentro da outra: + +1. O contador é **zero em todo projeto real** — os únicos escritores (`/financeiro/concluir`, + `/financeiro/publicar`) não tinham caller no front. +2. Com zero, o primeiro `if` da máquina de fases vencia sempre ⇒ a fase ficava presa em `onboarding` + **mesmo com 12 meses fechados, plano aprovado e semana 5 em execução**. +3. O guardrail de `onboarding` manda a IA *"NÃO interpretar dados financeiros"*. + +O gate de tools (`>= 30`) que o pente fino achou era **o sintoma visível**; a mordaça de fase era a +causa, e é anterior e independente. A E6 não a viu porque auditou *o que o chat não tem* (ferramentas), +não *o que mandaram ele não fazer*. + +Conserto: a fase passa a vir da cobertura da janela de 12 meses; os 6 gates inline viram +`isModoConsultor()` (que já existia e já estava certo); o guardrail de `diagnostico` deixa de proibir +falar do número e passa a proibir **projetar sobre série curta**, que é o erro real. + +### 2.2 D3 — a escada de publicação aposentada (8 caminhos deletados) + +Confirmado no código vivo: o mês fecha por **`apurar → gate → preview → concluir`**, e quem grava o +snapshot é o `POST /dre/fechar-mes`. `/financeiro/publicar` e `/financeiro/concluir` eram caminho +**paralelo e morto**. + +Deletados: as 2 rotas · `PublishDREModal` · `ProjectStatusBadge` (zero callers) · +`dre-publishing-service` · `auto-conclusion-service` (+teste) · `ai-tools/get-analysis-diagnosis`. + +**O achado que estava no banco:** a tool de IA `getDreReadiness` tirava o `publish_status` de +`module_analyses` — o depósito da escada. Como nada publicava, era **sempre `'rascunho'`**, e a +descrição da tool ensina o modelo que rascunho é *"0-2 meses fechados, sistema bloqueado"*. Uma +cliente com 32 meses apurados perguntava ao chat e era informada de que o sistema estava travado. + +### 2.3 A cobrança dos 12 meses (passos 5 e 9 da jornada) + +`Readiness` passou a carregar **`mesesFaltantes: string[]`**. Antes, `consultorPronto` recebia a +contagem e **a lista era descartada na chamada** — a tela era estruturalmente incapaz de nomear os +meses. A faixa `CobrancaJanela12m` entra na **Apuração** (entre o header do ano e os 12 chips) e o +**mesmo componente** vira o sinal de destravamento ao chegar em 12/12. + +### 2.4 E6-A + D11 — a dona trava a própria meta (passos 10 e 11) + +`TravarMetaCard` no overview `/plano-voo`, **sem gate de papel**. O `/documento` continua ATR-only +(D2). Ajustar a receita cria **versão nova**, para o que o motor propôs continuar registrado ao lado +do que ela escolheu. + +--- + +## 3. As decisões travadas (não re-levantar) + +| # | Decisão | +|---|---| +| **D1** | **Autonomia total desde o dia 1.** Consultor é serviço opcional, nunca dependência. Inverteu a ordem do §9 do relatório | +| **D2** | E6-A: ato próprio no overview; `/documento` segue ATR-only | +| **D3** | Escada de publicação **aposentada** — mês fechado É a publicação | +| **D4** | Caixa & Projeção entra depois da A4; **Lucas desenha o visual** | +| **D10** | O modelo de 30 análises está morto — `analyses_completed` sai como sinal | +| **D11** | "Mexer na meta" = mexer na **receita**; o resto o modelo recalcula | + +**Correção à D11, achada no motor:** só o **resultado** segue a receita, e só na fase `aproximacao` +(`baseline × (1+g)`). Em `recuperacao` o resultado **mira o zero e ignora a receita**; em `fora_icp` +fica parado. **Margem e endividamento nunca seguem** — são metas de NÍVEL por lei do +`modelo-meta-pacing.md` §4. A tela diz o que não se move em vez de fingir. Derivar margem da receita +seria mudar a lei do modelo, não a tela. + +--- + +## 4. As armadilhas que valem mais que o conserto + +### 4.1 Verde não prova que compila + +A suíte passou **1887 testes** com um **erro de sintaxe** em dois arquivos: eu inseri texto com crases +dentro de um template literal. Nenhum teste importa aqueles arquivos. Quem pegou foi o `tsc`. + +### 4.2 Deletar tool sem tirar o anúncio quebra em runtime + +Os manifestos de tools vivem como **string dentro do prompt**, em arquivo diferente do registro. +Deletar `getAnalysisDiagnosis` sem tirar o anúncio faria a IA chamar ferramenta inexistente no meio +da conversa da cliente. Agora há um teste de paridade (`ai-tools/manifesto-paridade.test.ts`) — e ele +foi **provado por mutação**, não só escrito. + +### 4.3 `.next/dev/types/` não se regenera no build + +Eu descartei 4 erros de `tsc` como "artefatos que se regeneram". **Não se regeneram** — o `next build` +lê `.next/dev/types/validator.ts` da sessão de dev anterior e **falha** citando rotas deletadas. O +conserto é `rm -rf .next`. Depois de deletar rota, limpe o `.next` antes de builder. + +### 4.4 Campo obrigatório encontra o que o grep não encontra + +Trocar `ReadinessInputs` de contagem para lista (em vez de somar campo **opcional**) fez o compilador +achar sozinho **5 pontos** que montavam `Readiness` à mão — dois que eu não tinha mapeado. Campo +opcional teria apagado a cobrança em silêncio nesses dois. + +### 4.5 Classe Tailwind inexistente passa no lint + +Usei `text-alert-red`: não existe. A real é `text-status-error` (149 arquivos). Lint não pega; só o +grep contra o resto do código pega. **Confira todo token novo por uso existente.** + +### 4.6 O hook de pre-commit leva ~3 minutos + +`lint-staged-no-regression.mjs` compara eslint HEAD × working. Com 30 arquivos estourou um timeout de +2 min. Nada se perde (o backup do lint-staged é limpo sozinho), mas **dê 10 minutos ao commit**. + +--- + +## 5. O próximo item: E3-B — o switch da antecipação + +**Por que é o próximo:** ele decide se o check ao vivo pode usar uma empresa **real do ICP**. Quem +desconta duplicata não fecha **um** mês hoje. Testar com empresa sem antecipação faz a corrente passar +por sorte. + +**A decisão (19/ago, travada):** a face deixa de ser obrigatória e vira um **switch do cliente dentro +do gate** — *"tenho o borderô"* (face por operação, juros na vírgula) × *"sei a taxa que pago"* (o +sistema deriva a face do líquido). O campo pede **% ao mês**, como o banco cota, com o prazo fazendo +a conversão. + +**A conta:** + +``` +t_período = taxa_a.m. × prazo_dias / 30 (desconto comercial, como o banco cobra) +face = líquido / (1 − t_período) +deságio = face − líquido +``` + +Exemplo: entrou R$ 100.000, taxa 2% a.m., prazo 45 dias → face **R$ 103.092,78**, deságio +**R$ 3.092,78**, custo efetivo **~27,6% a.a.** + +**Os 3 invariantes que o teste de regressão deve travar:** + +1. **O líquido nunca é recalculado** — segue sendo o crédito do extrato, ao centavo. +2. No modo taxa, **cada linha deriva a própria face pelo próprio crédito** (o toggle agregado × + detalhado fica irrelevante). +3. Números derivados de taxa declarada entram com `FonteDado` de **estimativa** e são **rotulados na + tela**. + +> ⚠️ **A armadilha.** O caminho "cliente informa a taxa" **já existe no motor** (Nível 1, +> `nivelGeral.taxa_media`) e está **morto** — o hook passa `nivelGeral = null`. Religá-lo como está +> erra **em silêncio e para menos**: sem face, `valorBrutoDe` cai no `valor_bruto_real`, que o hook +> preenche com **o líquido do extrato** — e aí `deságio = líquido × taxa` e +> `líquido resolvido = líquido − deságio`, que não bate com o extrato. **A implementação certa é a +> derivação inversa, não o forward.** + +**Onde mexer:** `features/dre/hooks/useGateForm.ts` · `lib/dre-detalhamento/antecipacao-multinivel.ts` +· `api/…/dre/fechar-mes/route.ts`. + +--- + +## 6. Depois do E3-B — a ordem, e o roteiro do check ao vivo + +| Leva | O quê | Est. (sessões) | Bloqueio | +|---|---|---|---| +| **E3-B** | switch da antecipação | 1 | — | +| **A2** | fechar histórico sozinha: lote · herança · refechar sem service key · dizer a ordem | 2–3 | — | +| **A4** | o número certo: ABC · PE · Resultado lê snapshot · `lerMeta` · 2 paginações | 1–2 | — | +| **A3** | categorização barata: E2-B/H/J/L | 2 | **D5** | +| **A5** | Caixa & Projeção | ? | **desenho do Lucas** | +| **A6** | decisão termina em ação (R4) | 2 | — | +| **A7** | o sistema chamar de volta | 1 | — | +| **A8** | acabamento (15 🟠) | 1–2 | D8 | +| **B1** | comprar e entrar: checkout · webhook · `mode='saas'` | 3+ | **decisão comercial** | + +*Estimativa, não medição. Premissa: sessão = o ciclo entre um "pode seguir" e o relatório seguinte.* + +**As três coisas que só o Lucas resolve:** **D5** (o "resto imaterial" — 1.497 decisões para 20% das +linhas trava a A3) · **o desenho do Caixa & Projeção** · **o eixo B** (hoje não existe como comprar +nem cobrar). + +### O roteiro do check ao vivo, quando o projeto novo existir + +subir OFX → categorizar → **ver a faixa cobrar os meses pelo nome** → fechar um mês com antecipação → +**a faixa virar "Plano liberado"** → travar a meta **mexendo no número** (exercita o caminho da versão +nova) → o Cockpit popular → perguntar ao chat sobre o próprio caixa. + +> **Duas armadilhas do roteiro:** o dossiê tem **TTL de 24h** — feche um mês antes de perguntar ao +> chat, senão você lê a fase velha e acha que não funcionou. E **sem o E3-B**, empresa com antecipação +> trava no gate pedindo o valor de face. + +--- + +## 7. Estado do banco e ferramental + +| | | +|---|---| +| **Migrations aplicadas** | `20260820010000` (readiness para de ler a escada) · `20260820020000` (a view perde o placar) — **aplicadas no remoto pelo Lucas e conferidas no banco vivo** | +| **Prova do check** | `supabase gen types --linked` devolveu a view com **17 colunas**; `analyses_completed` e `analyses_total` ausentes | +| **`src/types/supabase.ts`** | **regerado** do banco vivo. Trouxe ~600 linhas de drift alheio (`client_socios`, `categorization_rules.linha_id`) — medi antes: `tsc` zero erros, suíte verde, então ficou | +| **`db/snapshot/`** | 🔴 **OBSOLETO** — `SNAPSHOT.md` marcado. O dump é de 18/ago; as 2 migrations são posteriores. **Precisa de re-dump (Docker, Lucas).** | +| **`db/ESTRUTURA.md`** | **não regerado de propósito** — é *dump × código*, e o dump está velho. Regerar agora descreveria a view antiga. Fazer **depois** do re-dump | +| **Não verificado** | o corpo do `get_dre_readiness`. A assinatura não mudou (para preservar GRANTs), então os tipos não provam. Só o check ao vivo prova | + +--- + +## 8. O que NÃO fazer + +| O quê | Por quê | +|---|---| +| **G-01 (aging de recebível)** | Maior valor do backlog, mas é **feature nova**. Entra depois da A4 | +| **Anunciar tool sem conferir o que ela devolve** | `getDreReadiness` derramava 4 colunas vestigiais de publicação para o modelo. Foi corrigido; a lição fica | +| **Mexer em `phase-detection.ts` / Command Center** | `ENABLE_COMMAND_CENTER: false`. É território da **D8** (ligar a tela ou matar o cron), não desta trilha | +| **Deletar `qualitative-gaps-extractor` / `completed_analyses`** | Vêm vazios com `MODULOS_EXTRAS` OFF, mas são **feature atrás de flag**, não fantasma. Voltam a valer se a flag ligar | +| **Deletar `PainelAnaliseSections` (E4-I)** | Parqueado de propósito para o G7. Anti-padrão 6 da lei de método | +| **`git add -A`** | A árvore tem arquivos não-versionados do Lucas (hoje: os 2 docs de negócio, com *"apresentação visual pendente"*). **Sempre listar caminho por caminho.** | diff --git a/docs/atros-v3/saas-sem-consultor.md b/docs/atros-v3/saas-sem-consultor.md new file mode 100644 index 00000000..eda6727c --- /dev/null +++ b/docs/atros-v3/saas-sem-consultor.md @@ -0,0 +1,125 @@ +# SaaS sem consultor — o cliente é a autoridade do próprio projeto + +**Decisão do Lucas · 18/ago/2026 · VIGENTE** +**Status:** decidida, **não executada**. Execução na próxima sessão. +**Origem:** [G-09](../valor/gaps-backlog.md) do pente fino — a cadeia das 7 perguntas quebrava no consultor. + +--- + +## 1. O modelo + +| Papel | O que é | +|---|---| +| **Cliente** | **Dono do projeto e autoridade dentro dele.** Contrata o sistema, cadastra a própria empresa e os próprios sócios, categoriza, fecha o mês, trava a própria meta. | +| **Consultor** | **Ferramenta externa, opcional.** Não é etapa do fluxo. Só enxerga um projeto se **o cliente o ligar**. Dentro do projeto **não tem nenhuma limitação diferente da do cliente** — mesmo acesso, nem mais nem menos. | +| **Owner / Admin** | Plataforma. Enxerga **todos** os projetos. | + +As três frases que governam tudo: + +1. **Todo o sistema é do cliente.** +2. **Dentro de um projeto, quem manda é o cliente.** +3. **O consultor entra por convite do cliente e não ganha poder por ser consultor.** + +## 2. O que isso inverte + +Hoje o modelo é hierárquico — `owner > admin > consultor > cliente` — e o consultor tem **mais** +poder que o cliente dentro do projeto. O novo modelo é **de posse**: a autoridade vem de ser dono do +projeto, não do degrau na hierarquia. Owner/admin ficam como plano de plataforma, fora dessa régua. + +A linha que resume a inversão está em +[`auth/project-access.ts:51`](../../src/lib/auth/project-access.ts#L51): + +```ts +if (!role || !PROJECT_OPERATOR_ROLES.includes(role)) return false; // cliente/sdr nunca operam +``` + +**"cliente nunca opera" passa a ser exatamente o contrário.** + +## 3. Raio de alcance, medido + +| | | +|---|---| +| Policies RLS no total | **348** | +| Policies que codificam o bypass `role IN ('owner','admin','consultor')` | **104** | +| Tabelas afetadas | **47** | +| Policies que já usam acesso por vínculo (`client_project_access`) | **242** | + +As 47 incluem o coração do produto: `transactions`, `companies`, `client_socios`, `bank_accounts`, +`loan_contracts`, `plano_voo_snapshots`, `cockpit_actions`, `financial_documents`, +`ingestion_documents`, `pending_ofx_distributions`, `dre_detalhamento`, `endividamento_*`. + +## 4. A boa notícia: o mecanismo já existe + +**`project_members` é exatamente o que o novo modelo pede** (baseline:4523): + +```sql +CREATE TABLE project_members ( + user_id, project_id, + role text CHECK (role IN ('consultor','cliente')), + granted_by uuid, -- ← quem concedeu: é o "só se o cliente quiser" + created_at +); +``` + +Já tem o vínculo por projeto, o papel dentro do projeto e **quem concedeu**. Não precisa inventar +tabela nova — precisa passar a confiar nela em vez do papel global. + +### A pegadinha que a execução vai encontrar + +A view que 242 policies consomem **filtra só cliente** (baseline:4541): + +```sql +CREATE VIEW client_project_access AS + SELECT ... FROM project_members WHERE role = 'cliente'; +``` + +No modelo novo, consultor e cliente têm o **mesmo** acesso — então essa view, do jeito que está, +**exclui o consultor de tudo que ela protege**. Provável saída: uma view irmã sem o filtro de papel +(`project_access`), e as policies passam a apontar para ela. É uma troca mecânica, mas são 242 +pontos: merece grep-de-prova, não confiança. + +## 5. O caminho provável (a validar na execução) + +1. **`canOperateProject`** — inverter: membro do projeto opera, independentemente do papel; + owner/admin operam em qualquer um. É uma função, e ela destrava as perguntas 6 e 7 da cadeia. +2. **View `project_access`** sem filtro de papel + repoint das policies que hoje usam + `client_project_access`. +3. **Colapsar o bypass de 3 papéis** nas 104 policies: `role IN ('owner','admin','consultor')` vira + `is_admin()`, e o consultor passa a entrar pelo vínculo — mesma porta do cliente. +4. **`client_socios` e `companies`** ganham caminho de escrita para o dono do projeto (é ele quem + cadastra empresa e sócios na contratação). +5. **Gate de qualidade da meta** — hoje a aprovação do Plano de Voo é o que impede meta calibrada em + dado ruim. Se o cliente aprova sozinho, esse gate precisa continuar existindo em outro lugar. O + `consultorPronto` (12 meses da janela fechados) já faz esse papel; provavelmente basta — **mas é + decisão, não dedução**. +6. **Telas `forense/`** — se o consultor não é etapa, elas viram ferramenta interna de suporte + (owner/admin), não parte do fluxo. Varredura própria. +7. **`PROJECT_OPERATOR_ROLES` e `ROLE_ROUTES`** — revisar em `constants/user-roles.ts`. + +**Ordem sugerida:** 1 → 2 → 3, porque 1 destrava a cadeia do produto e 2/3 são a fundação de +segurança. O resto depois. + +## 6. A pergunta que ficou aberta + +O Lucas escreveu duas coisas que podem divergir: + +> *"consultor apenas é uma **leitor**"* · *"usuário com **acesso igual do cliente**, não tem nada de +> diferente nas limitações"* + +**Minha leitura:** acesso igual — o consultor **escreve**, porque o caso de uso declarado é *"caso o +cliente contrate para ajudar no dia a dia"*, e ajudar no dia a dia é categorizar, conciliar, fechar. +"Leitor" me soa como ênfase em *não tem poder especial*, não em *não pode escrever*. + +**Por que importa e não dá para deduzir:** se for leitura pura, são duas políticas por tabela +(SELECT para membro, escrita só para o dono) em vez de uma — e a diferença aparece em 47 tabelas. +**Confirmar antes de escrever a primeira policy.** + +## 7. O que NÃO muda + +- Owner/admin continuam vendo todos os projetos. +- `project_members` continua sendo a fonte do vínculo. +- Nada disso mexe em motor financeiro, catálogo ou cascata da DRE — é camada de acesso. + +--- + +**Relacionados:** [G-09](../valor/gaps-backlog.md) · [pente fino E0](../valor/pente-fino/00-cliente-e-regua.md) · `metodo.md` (mudança estrutural sem quebrar — vale integralmente aqui: 104 policies é blast radius alto, repoint antes de deletar, grep-de-prova na hora). diff --git a/docs/valor/README.md b/docs/valor/README.md new file mode 100644 index 00000000..bdd72136 --- /dev/null +++ b/docs/valor/README.md @@ -0,0 +1,28 @@ +# Trilha Valor — o ganho real de quem usa o ATR OS + +Três frentes, escritas nesta ordem porque cada uma alimenta a seguinte. +Público-alvo travado: empresas de **R$ 100 mil a R$ 3 milhões de faturamento mensal**. + +| Doc | O que responde | Estado | +|---|---|---| +| [F1 — Cenário Brasil 2026](frente-1-cenario-brasil-2026.md) | Que país é esse, e como ele aterrissa numa empresa dessa faixa | ✅ fechada · fontes primárias, corte 17/ago/2026 | +| [F2 — O que o ATR OS faz](frente-2-o-que-o-atros-faz.md) | O que o produto de fato entrega, verificado no código com `arquivo:linha` | ✅ fechada | +| [F3 — A oferta de valor](frente-3-oferta-de-valor.md) | O sistema como uma máquina só, os arquétipos, o impacto em R$ e o que prometer | ✅ fechada | +| [Backlog de gaps](gaps-backlog.md) | O que o produto não faz e vale fazer (G-01…G-05) | 🔵 anotado, execução depois | + +## Os três fios que atravessam tudo + +1. **A tese (F1).** O choque não é de demanda nem de custo — é **custo do dinheiro sobre empresas que + não sabem quanto dinheiro têm**. O que mudou não foi o cenário; foi o **custo do erro**. +2. **A convergência (F2 §3).** O código já implementava essa análise antes de a pesquisa existir: + custo do giro em "% do lucro" na tela, a escada de modalidades com 10 detectores de descida, e a + decomposição que separa "cresci" de "meu ciclo piorou". +3. **A máquina (F3 §2).** Não são onze telas — são **sete perguntas em ordem obrigatória**, e a ordem + é imposta por gate no código, não por convenção. + +## Validade + +Os números macro da F1 valem até **~out/2026** sem re-checagem (Selic, IPCA, inadimplência e RJ mudam +todo mês). Os estruturais — mortalidade, gap de capacidade, os ~30% que não sabem se deu lucro — são +estáveis. As afirmações da F2/F3 sobre o produto valem enquanto o código não mudar; toda uma delas +tem `arquivo:linha` para re-verificação rápida. diff --git a/docs/valor/frente-1-cenario-brasil-2026.md b/docs/valor/frente-1-cenario-brasil-2026.md new file mode 100644 index 00000000..63b32863 --- /dev/null +++ b/docs/valor/frente-1-cenario-brasil-2026.md @@ -0,0 +1,480 @@ +# Frente 1 — O Brasil que o cliente do ATR OS está vivendo + +**Data de corte:** 17/ago/2026 · **Status:** pesquisa fechada, números verificados na fonte primária +**Faixa-alvo:** empresas de R$ 100 mil a R$ 3 milhões de faturamento **mensal** (R$ 1,2M a R$ 36M/ano) + +> Este documento é a Frente 1 de 3. +> F1 = o cenário e como ele aterrissa no cliente · F2 = o que o ATR OS de fato faz (a verificar +> no código vivo) · F3 = a ponte entre os dois, para comunicação. +> +> **Regra aplicada:** nenhum número aqui veio de memória. Tudo foi buscado na fonte em ago/2026 e +> reconciliado quando as fontes divergiam. As divergências que encontrei estão registradas na §8. + +--- + +## 1. A tese em cinco linhas + +O choque que está quebrando empresa no Brasil não é um choque de demanda, nem de custo isolado. +É um **choque de custo do dinheiro sobre empresas que não sabem quanto dinheiro têm**. + +A empresa de R$ 50M+ leva o mesmo choque e sobrevive — porque enxerga, mede e negocia. A empresa +de R$ 500 mil/mês leva o mesmo choque e quebra — porque descobre tarde, e em 2026 **não existe +segunda rodada de crédito barato para corrigir o erro**. + +O que mudou de 2019 para cá não foi só o cenário. Foi o **custo do erro**. E, com ele, o valor da +informação. + +--- + +## 2. As seis forças macro (com número e fonte) + +### F1 · Juros: a Selic cede na ponta do BC, não na ponta da empresa + +A Selic caiu — mas a taxa que a empresa paga **subiu**. + +| Indicador | Valor | Referência | +|---|---|---| +| Selic | **14,00% a.a.** (4º corte seguido de 0,25 p.p., decisão unânime) | Copom, 280ª reunião, 4–5/ago/2026 | +| Selic — pico do ciclo | 15,00% a.a. | ciclo 2025 | +| Selic — projeção Focus fim de 2026 | 13,75% a.a. | Focus | +| Taxa média PJ, recursos livres | **1,89%/mês = 25,19% a.a.** | BC, maio/2026 | +| — nível histórico dessa taxa | **maior desde julho/2017** | BC | +| Spread médio das novas contratações | 21,9 p.p. (+3,5 p.p. em 12 meses) | BC, jan/2026 | +| TLP (referência BNDES) | 8,21% a.a. — **maior desde 2018** | ago/2026 | + +O ponto que quase todo mundo lê errado: **a Selic caiu 1 ponto e o custo do crédito PJ subiu.** +Em maio/2025 a taxa média PJ era 1,82%/mês; em maio/2026, 1,89%/mês. O corte da Selic não chegou +na ponta porque o spread cresceu 3,5 p.p. em 12 meses — os bancos estão precificando **risco**, não +custo de captação. E risco, aqui, é a inadimplência da §F2. + +Para o dono da empresa isso significa uma coisa concreta e contraintuitiva: **esperar o juro cair +não é estratégia.** O juro caiu e o dinheiro dele ficou mais caro. + +### F2 · Inadimplência PJ: recorde atrás de recorde — e não é a dívida bancária + +Este é o indicador mais grave do cenário, e o menos comentado. + +| Indicador (jun/2026) | Valor | +|---|---| +| CNPJs negativados | **9,1 milhões** — recorde da série (iniciada em jan/2016) | +| Dívida total negativada | **R$ 232,9 bilhões** — recorde | +| Contas em atraso por empresa | 7,3 | +| Dívida média por CNPJ | R$ 25.508,96 | +| Ticket médio da pendência | R$ 3.515,77 | +| Micro e pequenas dentro do total | **8,6 milhões de CNPJs · R$ 201,4 bi** | +| Crescimento em 12 meses | **+1,5 milhão de empresas** | + +Trajetória de 2026, mês a mês: fev 8,8 mi → mar 8,9 mi → mai 9,0 mi (R$ 229,9 bi) → jun 9,1 mi +(R$ 232,9 bi). **Sobe todo mês.** + +Por setor: Serviços 55,7% · Comércio 32,2% · Indústria 8,0% · Primário 0,9%. + +**A leitura que muda tudo — a origem da dívida:** + +| Origem da dívida negativada | Participação | +|---|---| +| **Serviços (outras empresas)** | **31,6%** | +| Bancos e cartões | 19,5% | +| Cooperativas | 8,4% | + +Cruze isso com o dado do Banco Central: a inadimplência da **carteira bancária PJ** era de apenas +**2,6%** em jan/2026. O balanço dos bancos está calmo. A economia real, não. + +Conclusão: **a dívida que está estourando não é a bancária — é a comercial.** As empresas estão +financiando umas às outras, e é esse crédito, o que ninguém mede e ninguém provisiona, que está +quebrando. O fornecedor virou o banco do cliente sem cobrar spread, sem analisar risco e sem +saber que virou. + +Isso tem consequência direta de produto: **o risco mora no contas a receber, não no extrato.** + +### F3 · Recuperação judicial: o indicador atrasado (e por que ele engana) + +**Cuidado metodológico — este é o número que mais circula errado.** A Serasa relançou o indicador +em 2026 com metodologia nova, que passou a separar *processos* de *CNPJs envolvidos* (um processo +pode arrastar vários CNPJs de um mesmo grupo, e CNPJs podem ser incluídos depois do protocolo). +**Comparar 2026 com 2024 sem ajustar essa quebra produz número errado.** + +| Período | Processos | CNPJs envolvidos | +|---|---|---| +| 2025 (ano) | 977 (+5,5% vs 2024) | **2.466 (+13%) — maior da série desde 2012** | +| jan/2026 | 53 | 126 | +| mar/2026 | 76 | — | +| abr/2026 | 60 | — | + +Razão média em 2025: **≈2,5 CNPJs por processo.** É essa razão que subiu — os processos ficaram +maiores, envolvendo grupos econômicos inteiros, estruturas financeiras mais sofisticadas e +múltiplos credores. + +Perfil por porte: **73,7% dos pedidos vieram de micro e pequenas empresas** (2024–2025); em +abril/2025 chegaram a 79% do total. Por setor em jan/2026 (por CNPJ): Agropecuária 41 · Serviços 37 +· Comércio 27 · Indústria 21. + +Falências em 2025: 698 pedidos, **queda de 19%** — e isso **não é melhora**. Credores passaram a +preferir alongar dívida e reestruturar a pedir a quebra. A empresa não morre; ela vira zumbi. + +**O ponto analítico:** RJ é indicador *atrasado*. A própria Serasa aponta que a inadimplência +antecede o movimento de RJ. Os 9,1 milhões de negativados de hoje são a fila de 2027. Quem espera +o número de RJ explodir para reagir já está dentro da fila. + +### F4 · Margem: a compressão por dentro + +Pesquisa Serasa Experian com PMEs (fev–mar/2026, 867 respondentes): + +| Achado | % | +|---|---| +| Classificam a pressão de custos como alta ou muito alta (12 meses) | **47%** | +| **Perderam margem de lucro** | **49%** (26% queda significativa + 23% impacto parcial) | +| Conseguiram **aumentar** margem via repasse de preço | **14,7%** | + +Fatores da alta de custo: insumos e matéria-prima 37% · **folha de pagamento 36%** · tributos 32% · +aluguel 29%. + +Traduzindo: **cerca de 85% não conseguiram repassar.** Comeram a compressão na margem. + +Custos estruturais que subiram em 2026, para dar nome aos 36% da folha: +- Salário mínimo em **R$ 1.621** (jan/2026). Custo total de um funcionário no mínimo: + **R$ 2.210 a R$ 3.040/mês**, conforme regime tributário e benefícios. +- Energia elétrica: projeção de **+5,1% em 2026**, ~1,15 p.p. acima da inflação projetada. + +> ⚠️ **Viés amostral que eu não vou esconder:** essa amostra é 32% MEI, 19% ME e 12% EPP — ou seja, +> puxa para baixo da faixa-alvo do ATR OS. Os percentuais são **direcionais** para empresas de +> R$ 100k–3M/mês, não medidos nelas. A direção (pressão de custo alta, repasse baixo) é robusta; a +> magnitude exata, não. Ver §8. + +### F5 · Demanda: o consumidor não sumiu — ficou seletivo e endividado + +| Indicador | Valor | +|---|---| +| IPCA 12 meses (jul/2026) | 4,44% (desacelerando de 4,64%) — **quase no teto da meta** | +| IPCA no ano (jan–jul/2026) | 3,44% | +| PIB 1T26 | +1,0% t/t | +| PIB 2T26 (estimativa FGV) | **+0,3% t/t — desaceleração confirmada** | +| Prévia do PIB (junho) | **−0,6%** | +| PIB 2026 — Focus / Fazenda | 1,89% / 2,3% | +| Famílias endividadas (Peic/CNC) | **81,64%** — 5º mês seguido de alta, recorde histórico | +| Famílias com renda comprometida | ~30% | +| Pessoas negativadas (jul/2026) | 83,9 milhões | +| Vendas no varejo (jan/2026, ICVA) | −1,5% | + +O padrão de consumo mudou de natureza: com boa parte da renda comprometida com parcelas e +financiamentos, o consumidor migrou de desejo imediato para percepção de valor — cortou o +"secundário", reduziu compra por impulso e derrubou o ticket médio. + +Para a empresa isso não aparece como "vendi menos". Aparece como **"vendi parecido e sobrou menos"** +— mix pior, ticket menor, prazo maior, mais desconto para fechar. + +### F6 · Reforma tributária: 2026 é o ano-teste, e o custo não é o imposto + +2026 é a fase de teste: **CBS a 0,9% e IBS a 0,1%** destacados na nota fiscal, **compensáveis** — +ou seja, para quem cumpre a obrigação, o desembolso extra é próximo de zero. + +**O custo real não é tributário — é operacional:** +- ERP, PDV e emissores de NF precisam ser atualizados para IBS + CBS e split payment. +- Apuração de créditos muda, e com ela a relação com fornecedor. +- Contratos de fornecimento com cláusula de reajuste atrelada ao ICMS precisam ser renegociados. +- A janela é **2026–2028**. Deixar para 2027 significa disputar desenvolvedor com o Brasil inteiro + ao mesmo tempo. + +Para a faixa-alvo, o efeito prático em 2026 é **consumo de atenção da gestão** num ano em que a +atenção já está toda no caixa. É mais uma frente competindo pelo mesmo dono. + +### F7 (bônus) · Confiança: 15 meses no vermelho, e a pequena é a pior + +ICEI/CNI: abaixo dos 50 pontos por **15 meses consecutivos** até março/2026 (46,6) — a pior +sequência desde a recessão de 2015–2016. Recuperou para 47,2 em maio, mas segue abaixo da linha. + +**Pequenas indústrias: 44,6 pontos — o pior desempenho desde a pandemia**, com piora explícita da +situação financeira pressionada por juros altos e custo de insumo. + +Confiança baixa tem efeito material: a empresa para de investir, encurta o horizonte de decisão e +passa a operar em modo sobrevivência — o que, por sua vez, garante que ela não saia do buraco. + +--- + +## 3. Como isso aterrissa numa empresa de R$ 100k a R$ 3M/mês + +Aqui sai o noticiário e entra a conta. Esta seção é análise própria a partir dos números acima. + +### 3.1 A escada do funding: um fator de 44x entre o topo e o fundo + +Todas as taxas abaixo são de fonte primária (BC, maio/2026), com o equivalente anual composto +calculado a partir da taxa mensal: + +| Degrau | Taxa/mês | Equivalente a.a. | +|---|---|---| +| 1 · Crédito direcionado (TLP/BNDES, antes do spread) | ~0,66% | **8,21%** | +| 2 · Capital de giro contratado (média PJ, recursos livres) | 1,89% | **25,2%** | +| 3 · Antecipação de recebíveis (faixa de mercado) | 1,2% – 3,5% | **15,4% – 51,1%** | +| 4 · Capital de giro **rotativo** | 2,59% | **35,9%** | +| 5 · Conta garantida | 4,18% | **63,5%** | +| 6 · Cheque especial | 13,58% | **360,8%** | + +**Entre o degrau 1 e o degrau 6 há um fator de ~44x no custo anual.** + +E o essencial: **a empresa não decide descer a escada. Ela escorrega.** Ninguém contrata cheque +especial — ele já está lá, e é o que sobra quando o caixa fura no dia 20 e não há tempo de +estruturar nada melhor. A descida acontece por *ausência de antecedência*, não por escolha. + +### 3.2 A conta que mata (exemplo trabalhado) + +Empresa no meio da faixa-alvo: **R$ 500 mil/mês** de faturamento, **margem líquida de 6%** = +R$ 30 mil/mês de lucro. Necessidade de capital de giro de R$ 500 mil (≈1 mês de faturamento — +conservador para comércio/serviço com PMR de 30–45 dias). + +| Em que degrau o giro está fundeado | Custo mensal | % do lucro | +|---|---|---| +| Capital de giro contratado (1,89%) | R$ 9.450 | **32%** | +| Rotativo (2,59%) | R$ 12.950 | **43%** | +| Conta garantida (4,18%) | R$ 20.900 | **70%** | +| Cheque especial (13,58%) | R$ 67.900 | **prejuízo de R$ 37,9 mil/mês** | + +Mesma empresa. Mesma dívida. Mesmo faturamento. **A diferença entre lucrar e morrer é apenas em +qual instrumento o dinheiro está.** E é exatamente isso que o dono não consegue responder de +cabeça — porque o custo financeiro não chega até ele como uma linha: chega diluído em dezenas de +lançamentos de "tarifa", "juros", "IOF", "encargo" espalhados pelo extrato. + +### 3.3 O choque invisível: quando o cliente atrasa, quem paga é você + +Mesma empresa de R$ 500 mil/mês. O cliente atrasa 15 dias — o PMR sai de 30 para 45 dias. + +- Capital adicional imobilizado: R$ 500k × 15/30 = **R$ 250 mil** +- Custo a 2,59%/mês (rotativo): **R$ 6.475/mês** +- Impacto no lucro de R$ 30 mil: **−21,6%** + +A empresa não vendeu menos. Não gastou mais. Não errou em nada. **Perdeu um quinto do lucro porque +o cliente atrasou.** E, com 9,1 milhões de CNPJs negativados e o próprio setor de Serviços +respondendo por 31,6% da origem das dívidas — mais que os bancos —, **esse atraso é a norma, não a +exceção.** + +É por isso que a §F2 é a força mais grave do cenário: ela transfere a crise do balanço do devedor +para o **caixa do fornecedor**, que não tem como recusar a venda. + +### 3.4 O círculo vicioso da restrição cadastral (a porta que fecha por dentro) + +``` +inadimplência + → restrição cadastral (Serasa / Cadin / protesto) + → Pronampe, BNDES e linhas direcionadas BLOQUEADAS por regra + → sobra apenas crédito livre caro (degraus 4-6) + → custo financeiro sobe + → margem cai + → mais inadimplência ⟲ +``` + +Segundo o Sebrae, **67% dos pedidos de crédito são negados por bancos convencionais**, sobretudo +por exigência burocrática e falta de garantia. E restrição cadastral ativa **inviabiliza por regra** +o acesso às linhas baratas. + +Esse loop tem uma única porta de saída: **não entrar nele.** Uma vez dentro, o instrumento barato +está bloqueado por definição — e a empresa fica condenada a financiar a recuperação com o +instrumento mais caro que existe. Isso é o que transforma diagnóstico precoce de "bom ter" em +**única janela real**. + +Sinal de que a pressão é grande: a busca por crédito **cresceu 16,5% em junho/2026**. A demanda +está alta e a oferta, seletiva. + +### 3.5 O "prêmio da cegueira": por que descobrir tarde ficou terminal + +Numa empresa de margem 6% faturando R$ 500 mil/mês: + +- Uma erosão de **2 p.p. de margem** custa **R$ 10 mil/mês**. +- Descobrir em **6 meses** em vez de 1: **R$ 50 mil** queimados a mais. +- Esse buraco é fundeado com o instrumento disponível *na hora* — que, pela §3.1, raramente é o + barato. A R$ 2,59%/mês, carregar esse buraco por mais um ano custa outros ~R$ 18 mil. + +Em 2019, com Selic a 4,5% e crédito disponível, descobrir tarde custava caro e se corrigia. +Em 2026, com o dinheiro a 25%–63% a.a., 67% dos pedidos de crédito negados e a porta da §3.4 se +fechando por dentro, **descobrir tarde é terminal**. + +> **O produto não vende gestão financeira. Vende antecedência — num tempo em que perder tempo +> ficou caro como nunca.** + +--- + +## 4. O gap de capacidade: por que a grande sobrevive ao mesmo choque + +Este é o argumento central do posicionamento. Não é que a empresa grande sofra menos. É que ela +**enxerga**. + +| Capacidade | Empresa de R$ 50M+/ano | Empresa de R$ 1,2M–36M/ano | +|---|---|---| +| **Quem olha o número** | CFO + controller + FP&A | o dono, à noite, depois de fechar a loja | +| **Qual número olha** | DRE gerencial fechada | o saldo do extrato | +| **Quando** | D+5 | quando o contador manda — D+30 a D+60, e ainda assim **fiscal** | +| **Em que regime** | competência **e** caixa | nenhum dos dois, formalmente | +| **Ferramenta** | ERP + tesouraria + BI | planilha (quando existe) | +| **Custo de capital** | negociado, com rating, garantia e comitê | o que o gerente ofereceu | +| **Projeção** | cenários com 12 meses de horizonte | "acho que dá até dezembro" | +| **Decisão** | com dado | **com susto** | + +E o número que fecha o argumento: + +> **~30% das PMEs brasileiras não conseguem dizer se fecharam o mês com lucro ou prejuízo.** +> *(pesquisa Conta Azul, 400+ empresas em todas as regiões)* + +Isso não é preguiça do empresário. É uma falha estrutural de instrumento: **a contabilidade que ele +paga é fiscal.** Foi feita para o Fisco, em regime de competência, entregue com 30–60 dias de +atraso. A operação dele é em **caixa**, e é **hoje**. Ele paga por um número que não responde a +pergunta que ele tem. + +Corroborando pelo lado da mortalidade (Sebrae/IBGE): +- Causas de fechamento: **22% falta de capital de giro** · 20% baixo volume de vendas. As pesquisas + atribuem o núcleo do problema a deficiência de gestão, ausência de planejamento e falta de visão + estratégica. +- Mortalidade em 5 anos por setor: comércio 30,2% · indústria de transformação 27,3% · serviços 26,6%. +- Por porte: MEI 29% · microempresa 21,6% · pequeno porte 17%. + +E o quadro atual do dia a dia (Sebrae, *Pulso dos Pequenos Negócios* 12ª ed., coleta 19/02–18/03/2026): +- **26% das MPE relatam queda de faturamento** (20% entre MEI). +- Inadimplência atinge **~1 em cada 4** pequenos negócios. +- Entre as MPE endividadas, empréstimos e dívidas em atraso representam **~45% das despesas** + (~63% entre MEI). +- Parcela relevante **não sabe o que fazer** para reduzir o impacto negativo — falta de clareza + sobre a estratégia, não só de recurso. + +Esse último ponto é o mais importante do documento inteiro para efeito de produto: **o problema +declarado não é falta de dinheiro. É falta de clareza sobre o que fazer.** + +--- + +## 5. As sete dores, na fala do dono + +Tradução das forças macro para a frase que o cliente efetivamente diz. É este vocabulário que a +comunicação da Frente 3 precisa usar — não "DRE gerencial" nem "ciclo de conversão de caixa". + +| # | Como o dono descreve | O que é de verdade | Força de origem | +|---|---|---|---| +| 1 | *"Vendi mais e sobrou menos."* | Compressão de margem sem repasse | F4, F5 | +| 2 | *"Não sei em qual conta o dinheiro está sangrando."* | Sem DRE gerencial; custo financeiro diluído no extrato | §4, F1 | +| 3 | *"Todo dia 20 aperta."* | Descasamento PMR × PMP; NCG crescendo | §3.3 | +| 4 | *"Peguei empréstimo pra tapar buraco e agora tenho o buraco **e** o empréstimo."* | Escorregou na escada de funding (§3.1) | F1 | +| 5 | *"Meu cliente não paga e eu não posso parar de vender pra ele."* | Inadimplência comercial + concentração de carteira | F2 | +| 6 | *"O banco cortou meu limite."* | Restrição cadastral + crédito seletivo (§3.4) | F1, F2 | +| 7 | *"Não sei se eu aguento os próximos seis meses."* | Sem projeção; decisão por susto | §4, F7 | + +Note que **nenhuma delas é "quero um relatório"**. Todas são perguntas sobre sobrevivência e +próximo passo. Isso condiciona todo o resto: o sistema não pode terminar num gráfico — tem de +terminar numa ação. *(Isso é exatamente o R4 da Constituição — "termina-em-ação-raiz". O cenário de +mercado confirma a lei de produto, o que é um bom sinal.)* + +--- + +## 6. O que o cenário exige de um produto (critérios, não features) + +Derivado das seções acima. Estes são os critérios que a Frente 2 vai usar para avaliar o ATR OS — +e cada um sai de uma força macro específica, não de opinião: + +| # | Critério | Por que o cenário exige | Origem | +|---|---|---|---| +| C1 | **Dizer a verdade sobre o mês, em regime de caixa** | 30% não sabem se deu lucro; a contabilidade fiscal não responde | §4 | +| C2 | **Antecedência maior que o ciclo do erro** | O prêmio da cegueira é R$ 10k/mês por 2 p.p. não vistos | §3.5 | +| C3 | **Enxergar o custo financeiro como uma linha, não como ruído** | 32%–70% do lucro pode estar nessa linha, invisível | §3.1, §3.2 | +| C4 | **Medir o ciclo (PMR/PMP/NCG), não só o saldo** | 15 dias de atraso = 21,6% do lucro | §3.3 | +| C5 | **Tratar recebível como risco, não como certeza** | A dívida que estoura é comercial (31,6%), não bancária | F2 | +| C6 | **Vigiar a porta da restrição cadastral** | Uma vez dentro do loop, o crédito barato some por regra | §3.4 | +| C7 | **Terminar em decisão, não em relatório** | O dono declara não saber *o que fazer*, não falta de dado | §4, §5 | +| C8 | **Custar uma fração do que custa um controller** | A faixa-alvo não comporta CFO + ERP + FP&A | §4 | + +--- + +## 7. Ponte para a Frente 2 — hipóteses a testar no código vivo + +Estas são **hipóteses**, não afirmações. Regra da casa: nada sobre o estado do ATR OS entra num +documento sem verificação no artefato vivo (código via Grep/Read, banco via dump fresco). A Frente +2 vai confirmar ou derrubar cada uma. + +| Hipótese | Critério que atenderia | Como verificar na F2 | +|---|---|---| +| H1 · Categorização + DRE em regime de caixa entregam C1 | C1, C3 | Ler o motor de DRE v3 e a cascata; checar se o custo financeiro sai isolado | +| H2 · Capital de Giro (G1–G3) entrega C4 | C4 | Ler `capital-de-giro`; confirmar se PMR/PMP/NCG existem de fato e em quê régua | +| H3 · Endividamento entrega C3 e C6 | C3, C6 | Verificar se a tela mapeia **modalidade** e custo efetivo, não só saldo | +| H4 · Caixa & Projeção entrega C2 e C7 | C2, C7 | Ver horizonte real da projeção e se ela termina em ação | +| H5 · Carteira/recebíveis entrega C5 | C5 | Ver se há risco/concentração por sacado, ou só valor a receber | +| H6 · Cockpit + Plano de Voo entregam C7 | C7 | Auditar se o pacing devolve ação-raiz ou só semáforo | +| H7 · Nada no produto hoje atende C8 explicitamente | C8 | Questão de precificação/posicionamento, não de código | + +**Ordem sugerida para a Frente 2:** verificar primeiro H1 (é o alicerce — se o diagnóstico não for +confiável, nada acima dele vale), depois H2/H5 (as duas dores de caixa mais agudas do cenário), +depois H3/H4/H6. + +--- + +## 8. Limitações desta pesquisa (o que eu não vou fingir que sei) + +Registro honesto, para ninguém construir em cima de areia: + +1. **Viés amostral na pesquisa de margem.** A pesquisa Serasa PME (fev–mar/2026, 867 respondentes) + tem 32% MEI, 19% ME e 12% EPP — abaixo da faixa-alvo do ATR OS. Os percentuais de compressão de + margem são **direcionais**, não medidos na faixa de R$ 100k–3M/mês. + +2. **Quebra metodológica na série de RJ.** A Serasa relançou o indicador em 2026 separando + *processos* de *CNPJs*. Comparações diretas 2026 × 2024 são inválidas. Números de 2024 que + circulam na imprensa (ex.: "228 pedidos em julho") são da metodologia antiga — **verifiquei e + descartei** um desses durante a pesquisa. + +3. **Número descartado por inconsistência.** Uma fonte secundária reporta "5.931 pedidos de RJ no + 1T26" atribuídos ao *Monitor RGF*. É incompatível com a série Serasa (53–76 processos/mês) e + mede coisa diferente. **Não usei.** + +4. **Custo de crédito por modalidade.** Usei as taxas do BC de maio/2026 reportadas em fonte + secundária confiável. Para uso comercial (material de venda), vale puxar a série direto do + **SGS do Banco Central** — as séries são 20723 (capital de giro >365d), 20724 (capital de giro + rotativo) e 20715 (PJ total). São públicas e citáveis. + +5. **Tamanho do mercado-alvo: gap real.** **Não existe dado público confiável** de quantas empresas + faturam entre R$ 1,2M e R$ 36M/ano no Brasil. O CEMPRE/IBGE e a Receita não publicam desagregado + por faixa de faturamento nesse corte. Se dimensionar o TAM importar, isso precisa ser **comprado** + (Econodata, BigDataCorp ou similar). Referências de fronteira que existem: EPP no Simples vai até + R$ 4,8M/ano; o Simples cobre ~70% das empresas ativas; ~1,9 milhão de CNPJs estão fora das faixas + de micro e pequeno porte. + +6. **Prazo de validade.** Selic, IPCA, inadimplência e RJ mudam mensalmente. **Este documento vale + até ~out/2026** sem re-checagem. Os números estruturais (mortalidade, gap de capacidade, os 30% + que não sabem se deu lucro) são estáveis. + +--- + +## 9. Fontes + +**Juros e crédito** +- [Copom reduz a taxa Selic para 14,00% a.a. — Banco Central](https://www.bcb.gov.br/en) +- [Copom reduz taxa Selic pela quarta vez em 0,25 p.p. — O Tempo](https://www.otempo.com.br/economia/2026/8/5/copom-reduz-taxa-selic-pela-quarta-vez-em-0-25-p-p) +- [Juros subiram para famílias e empresas em janeiro — Agência Brasil/BC](https://agenciabrasil.ebc.com.br/economia/noticia/2026-02/juros-subiram-para-familias-e-empresas-em-janeiro-mostra-bc) +- [Juro do crédito empresarial atinge maior nível desde 2017 — Sindilojas-SP (03/07/2026)](https://sindilojas-sp.org.br/juro-do-credito-empresarial-atinge-maior-nivel-desde-2017/) +- [Taxa média de juros — capital de giro rotativo (série 20724) — Dados Abertos BCB](https://dadosabertos.bcb.gov.br/dataset/20724-taxa-media-de-juros-das-operacoes-de-credito-com-recursos-livres---pessoas-juridicas---capita) +- [Empréstimos para capital de giro no 2º semestre de 2026 (TLP 8,21%)](https://ubirataonline.com.br/emprestimos-para-capital-de-giro-exigem-planejamento-cuidado-no-segundo-semestre-de-2026/) +- [Antecipação de recebíveis: quando vale a pena e quando corrói a margem (2026)](https://bwolf.com.br/blog/antecipacao-de-recebiveis-quando-vale-a-pena-2026.html) + +**Inadimplência e recuperação judicial** +- [Inadimplência empresarial supera 9,1 milhões de CNPJs — Correio Braziliense (jun/2026)](https://www.correiobraziliense.com.br/economia/2026/08/7481382-inadimplencia-empresarial-atinge-recorde-e-supera-91-milhoes-de-cnpjs-no-brasil.html) +- [Inadimplência das empresas atingiu 8,9 milhões em março — Serasa Experian](https://www.serasaexperian.com.br/sala-de-imprensa/indicadores/inadimplencia-das-empresas-voltou-a-crescer-e-atingiu-89-milhoes-em-marco-revela-serasa-experian/) +- [Recuperações judiciais somaram 53 processos em janeiro/2026 — Serasa Experian](https://www.serasaexperian.com.br/sala-de-imprensa/indicadores/recuperacoes-judiciais-seguiram-em-patamar-elevado-no-inicio-de-2026-e-somaram-53-processos-solicitados-em-janeiro-aponta-serasa-experian/) +- [Recuperações judiciais atingem 2,5 mil empresas em 2025 — Serasa Experian](https://www.serasaexperian.com.br/sala-de-imprensa/indicadores/recuperacoes-judiciais-avancam-no-brasil-e-atingem-25-mil-empresas-em-2025-maior-nivel-da-serie-aponta-serasa-experian/) +- [Recuperação judicial é recorde: 73,7% são pequenas e médias — Terra](https://www.terra.com.br/economia/meu-negocio/recuperacao-judicial-bate-recorde-737-sao-pequenas-empresas,2a0948bdcdfc5f97b8c3c8ee4fec8fb5o3sdwmf4.html) +- [Micro e pequenas responderam por quase 80% dos pedidos de RJ em abril — Serasa Experian](https://www.serasaexperian.com.br/sala-de-imprensa/indicadores/micro-e-pequenas-empresas-foram-responsaveis-por-quase-80-dos-pedidos-de-recuperacao-judicial-em-abril-revela-serasa-experian/) +- [Crise silenciosa: recorde de RJ e inadimplência entre empresas — Fecomercio](https://www.fecomercio.com.br/noticia/crise-silenciosa-brasil-registra-recorde-de-recuperacoes-judiciais-e-inadimplencia-entre-empresas) + +**Margem, custos e PMEs** +- [Aumento de custos comprime margens de quase metade das PMEs — Serasa Experian (fev–mar/2026)](https://www.serasaexperian.com.br/sala-de-imprensa/pmes/aumento-de-custos-comprime-margens-de-quase-metade-das-pmes-no-brasil-revela-pesquisa-da-serasa-experian/) +- [Salário mínimo de R$ 1.621 em 2026 impactará custos das empresas — Contábeis](https://www.contabeis.com.br/noticias/73891/salario-minimo-de-r-1-621-em-2026-impactara-custos-das-empresas/) +- [Previsões indicam aumento no custo da energia em 2026 — ENGIE](https://www.alemdaenergia.engie.com.br/previsoes-indicam-aumento-no-custo-da-energia-em-2026/) + +**Gestão, mortalidade e comportamento** +- [Três em cada dez PMEs no Brasil não sabem se têm lucro ou prejuízo — CartaCapital / Conta Azul](https://www.cartacapital.com.br/do-micro-ao-macro/tres-em-cada-dez-pmes-no-brasil-nao-sabem-se-tem-lucro-ou-prejuizo/) +- [A taxa de sobrevivência das empresas no Brasil — Sebrae](https://sebrae.com.br/sites/PortalSebrae/artigos/a-taxa-de-sobrevivencia-das-empresas-no-brasil,d5147a3a415f5810VgnVCM1000001b00320aRCRD) +- [Falta de planejamento e de controle financeiro entre os principais desafios — Agência Sebrae](https://rr.agenciasebrae.com.br/cultura-empreendedora/falta-de-planejamento-e-de-controle-financeiro-estao-entre-os-principais-desafios-dos-pequenos-negocios/) +- [Inadimplência impacta um em cada quatro pequenos negócios — Agência Sebrae](https://agenciasebrae.com.br/economia-e-politica/inadimplencia-impacta-um-em-cada-quatro-pequenos-negocios-no-brasil/) +- [Pulso dos Pequenos Negócios — 12ª edição (coleta fev–mar/2026) — Sebrae](https://sebraepr.com.br/impulsiona/pesquisa-pulso-12a/) + +**Macro, demanda e confiança** +- [IPCA: inflação sobe 0,07% em julho de 2026 — InfoMoney/IBGE](https://www.infomoney.com.br/economia/ipca-inflacao-julho-2026-dados-ibge/) +- [FGV estima que PIB desacelerou e cresceu 0,3% no 2º trimestre — Agência Brasil](https://agenciabrasil.ebc.com.br/economia/noticia/2026-08/fgv-estima-que-pib-desacelerou-e-cresceu-03-no-segundo-trimestre) +- [Prévia do PIB recua 0,6% em junho — Metrópoles](https://www.metropoles.com/brasil/previa-do-pib-recua-06-em-junho-mas-avanca-02-no-trimestre) +- [Endividamento das famílias bate novo recorde histórico (Peic/CNC)](https://portaldocomercio.org.br/economia/cnc-endividamento-das-familias-bate-novo-recorde-historico-em-fevereiro/) +- [O consumidor não sumiu, ficou mais criterioso: o que muda no varejo em 2026 — Grupo Bittencourt](https://bittencourtconsultoria.com.br/artigos/consumidor-mais-criterioso-varejo-2026/) +- [ICEI volta a cair e empresários completam 15 meses sem confiança — CNI](https://noticias.portaldaindustria.com.br/noticias/economia/icei-volta-a-cair-e-empresarios-completam-15-meses-sem-confianca-revela-cni/) +- [Pequena indústria brasileira tem pior desempenho desde a pandemia — CNI](https://www.catureba.com.br/2026/05/14/pequena-industria-brasileira-tem-pior-desempenho-desde-a-pandemia-aponta-cni/) + +**Reforma tributária** +- [Reforma Tributária IBS e CBS: o que de fato muda para as empresas — Forbes (jul/2026)](https://forbes.com.br/forbes-money/2026/07/reforma-tributaria-ibs-cbs-o-que-muda-impactos-empresas/) +- [Reforma Tributária 2026: o que PMEs precisam fazer desde já — Pallotta Martins](https://pallottamartins.com.br/2026/04/02/reforma-tributaria-2026-cbs-ibs-pmes-simples-nacional/) diff --git a/docs/valor/frente-2-o-que-o-atros-faz.md b/docs/valor/frente-2-o-que-o-atros-faz.md new file mode 100644 index 00000000..43976ace --- /dev/null +++ b/docs/valor/frente-2-o-que-o-atros-faz.md @@ -0,0 +1,299 @@ +# Frente 2 — O que o ATR OS de fato faz + +**Data:** 17/ago/2026 · **Base:** código vivo em `main` (c59ffc7), lido via Read/Grep +**Insumo:** os 8 critérios (C1–C8) e 7 hipóteses (H1–H7) de [`frente-1-cenario-brasil-2026.md`](frente-1-cenario-brasil-2026.md) + +> **Método.** Nada aqui veio de doc, handoff ou memória. Cada afirmação tem `arquivo:linha`. +> Onde o código diverge do que a documentação diz, o código ganha e a divergência está registrada +> na §6. O que eu não verifiquei, eu digo que não verifiquei. +> +> **Escopo do que li:** engines em `src/lib/{giro,endividamento,treasury,cash-flow,dre,pacing,plano-voo,retrato,concentracao,dre-detalhamento}`, os gates, a Sidebar e os cards de veredito. +> **Não li:** o render completo das 22 telas, os testes, e o banco (nenhuma afirmação aqui depende de dump). + +--- + +## 1. A superfície: o que o cliente vê + +Onze itens, na ordem da barra lateral ([`Sidebar.tsx:164-190`](../../src/components/Sidebar.tsx#L164-L190)): + +| # | Tela | Pergunta que responde | +|---|---|---| +| 1 | Visão do Projeto | onde eu estou | +| 2 | **Apuração** | organizar e fechar o mês | +| 3 | **Cockpit** | estou na rota? | +| 4 | **Caixa & Projeção** | o caixa aguenta? até quando? | +| 5 | **Resultado** | deu lucro ou prejuízo? | +| 6 | **Capital de giro** | quanto dinheiro está preso, e o que ele me custa | +| 7 | **Endividamento** | quanto devo, a que custo, e como saio | +| 8 | Pessoas | folha | +| 9 | Simulador | e se eu mexer aqui? | +| 10 | Plano de Voo | qual é a meta dos 12 meses | +| 11 | Iniciativas (HTR) | **o que eu faço** | + +Comercial e Gestão estão atrás de flag e desligados ([`Sidebar.tsx:182-183`](../../src/components/Sidebar.tsx#L182-L183)). O produto voltado ao cliente **é o Financeiro** — o que bate com a prioridade da Constituição. + +--- + +## 2. Veredito por critério + +| # | Critério (da F1) | Veredito | Evidência-âncora | +|---|---|---|---| +| C1 | Verdade sobre o mês, em regime de caixa | ✅ **Atende** | `dre/cascata.ts`, regime de caixa afirmado em 8 engines | +| C2 | Antecedência maior que o ciclo do erro | ✅ **Atende** (12m, clamp 1–24) | `treasury/annual-projection.ts:46,155` | +| C3 | Custo financeiro como linha, não como ruído | ✅ **Atende — e é o ponto mais forte do produto** | `giro/custo.ts`, `endividamento/cmd.ts` | +| C4 | Medir o ciclo (PMR/PME/PMP/NCG) | ✅ **Atende — com uma peça que eu não esperava achar** | `giro/regua.ts:62-109` | +| C5 | Recebível como risco, não como certeza | ⚠️ **Parcial** — concentração sim, aging/risco por sacado não | `retrato/client-revenue-analysis.ts`, `concentracao/hhi.ts` | +| C6 | Vigiar a porta da restrição cadastral | ❌ **Não existe** | grep: zero ocorrência de Serasa/protesto/Cadin no domínio do cliente | +| C7 | Terminar em decisão, não em relatório | ⚠️ **Parcial — o elo final está travado** | `iniciativas-gate.ts:43-56` + `consultor-readiness.ts:62` | +| C8 | Custar fração de um controller | 🔵 Fora de código (preço/posicionamento) | — | + +--- + +## 3. As convergências: o produto já implementa a análise da F1 + +Isto é o achado mais importante da frente, e ele não é sobre features — é sobre **prova**. As três +peças analíticas que eu construí na F1 a partir dos dados macro **já existem no código**, feitas +antes e sem conhecer a pesquisa. Não é o produto se encaixando no discurso; é o discurso +descrevendo o que já está lá. + +### 3.1 A "conta que mata" (F1 §3.2) → `giro/custo.ts` + +Na F1 eu argumentei que a mesma dívida, em degraus diferentes, consome de 32% a 70% do lucro — e +que o dono não sabe responder isso porque o custo chega diluído no extrato. + +O produto calcula exatamente esse número, em três leituras +([`giro/custo.ts:102-113`](../../src/lib/giro/custo.ts#L102-L113)): R$/ano, % da receita e **% do +lucro**. O comentário do próprio arquivo diz por quê: *"é a terceira que dá a dimensão ('52% do seu +lucro')"*. + +E chega na tela: [`GiroView.tsx:132`](../../src/components/giro/GiroView.tsx#L132) renderiza +literalmente **"X% do lucro"**. + +Mais: [`custo.ts:121-124`](../../src/lib/giro/custo.ts#L121-L124) deriva a **taxa efetiva anual que +o dono realmente paga** — do que ele já gastou, não de tabela. É o degrau da escada da F1 §3.1, +medido na empresa dele em vez de estimado. + +### 3.2 A escada do funding (F1 §3.1) → modalidades + sinais de estresse + +A F1 dizia: existem degraus com 44x de diferença, e a empresa **escorrega** neles sem decidir. + +O produto cataloga 15 modalidades PJ como fonte única +([`loan-contracts-modalidades.ts:13-29`](../../src/lib/loan-contracts-modalidades.ts#L13-L29)) — e +elas são a escada da F1, nominalmente: `cheque_especial` ("Conta garantida / cheque especial PJ"), +`capital_giro`, `desconto_duplicatas`, `antecipacao_cartao`, e do lado barato `pronampe`, `finame`, +`cartao_bndes`, `fgi_fampe`. + +E há **10 detectores de estresse** ([`sinais-estresse/index.ts:59-81`](../../src/lib/endividamento/sinais-estresse/index.ts#L59-L81)) que vigiam a descida: + +| Sinal | O que detecta | Dor da F1 §5 | +|---|---|---| +| 02 · cheque especial | escorregou pro degrau caro | #4 | +| 03 · antecipação sistemática | antecipar virou dependência, não ponte | #4 | +| 04 · antecipação crescente | a dependência está piorando | #4 | +| 05 · **tampa-buraco** | empréstimo novo cobrindo buraco velho | **#4, literal** | +| 06 · fornecedor em atraso | virou devedor comercial | #5/#6 | +| 07 · imposto parcelado | usou o Fisco como banco | #6 | +| 01, 08, 09 | saldo zerado, pagamentos empilhados no fim do mês, saldo volátil | #3 | + +O sinal 05 chama-se `tampa-buraco`. A dor #4 da F1, escrita antes de eu abrir o código, é: *"peguei +um empréstimo pra tapar buraco e agora tenho o buraco e o empréstimo."* + +E o **CMD** ([`endividamento/cmd.ts:17-33`](../../src/lib/endividamento/cmd.ts#L17-L33)) calcula o +custo médio ponderado da dívida (a.m. e a.a. composto) — ou seja, **em que degrau médio a empresa +está**, que é a pergunta que a F1 §3.2 diz que o dono não consegue responder. + +### 3.3 "O cliente atrasou e quem paga é você" (F1 §3.3) → `giro/regua.ts` + +Esta foi a convergência que mais me surpreendeu. + +[`regua.ts:62-75`](../../src/lib/giro/regua.ts#L62-L75) mede o ciclo em **dias**, e abre por perna +(receber/estoque/pagar) **sempre contra a receita** — o comentário explica que medir contra CMV ou +compras "é o que faz PMR/PME/PMP não somarem". Detalhe de rigor que quase ninguém acerta. + +E [`regua.ts:91-109`](../../src/lib/giro/regua.ts#L91-L109) faz a **decomposição volume × +eficiência**: separa quanto da variação do capital preso veio de *crescer* ("notícia boa com fatura +anexa", nas palavras do código) de quanto veio do *ciclo piorar* ("cliente pagando devagar, estoque +encalhando"). + +Isso é precisamente o mecanismo da F1 §3.3 — a empresa que não vendeu menos, não gastou mais, e +perdeu 21,6% do lucro porque o cliente atrasou. O produto **separa as duas causas e nomeia a +segunda**. O termo cruzado é jogado no efeito eficiência de propósito, "para a piora parecer maior, +nunca menor" — errar para o lado que o dono precisa ver. + +### 3.4 A antecipação como insustentável (F1 §3.1) → `veredito-caixa.ts` + +[`treasury/veredito-caixa.ts:32-46`](../../src/lib/treasury/veredito-caixa.ts#L32-L46) classifica a +fonte que está cobrindo o caixa (antecipação / aporte / empréstimo) e marca a antecipação com +`insustentavel: true` — *"venda já feita, puxada com deságio"*. É a leitura da F1 de que antecipar +resolve hoje e corrói amanhã, implementada como flag de veredito. + +### 3.5 Um bug que prova o valor da tese + +[`giro/custo.ts:46-68`](../../src/lib/giro/custo.ts#L46-L68) documenta um achado do check ao vivo do +G2 (17/ago/2026): a spec mandava filtrar contratos de giro por `finalidade`, mas **`finalidade` está +NULL em 100% dos contratos de produção**. Filtrando só por ela, a Vertímetal media R$ 201.390/ano de +custo de giro quando o real era **R$ 474.194/ano** — dois contratos com R$ 272.804 de juros +invisíveis. **Erro de 58%, para menos.** + +Isso é a tese da F1 em miniatura, dentro do próprio produto: *o custo do dinheiro é o número que +some, e ele some para menos — a direção que ninguém percebe.* Vale como história de venda porque é +verificável e é sobre um cliente real. + +--- + +## 4. As hipóteses, uma a uma + +| Hipótese | Veredito | Evidência | +|---|---|---| +| **H1** · Categorização + DRE em caixa entregam C1/C3 | ✅ **Confirmada** | Cascata dirigida pelo catálogo, com L60 receita financeira e **L65 despesa financeira como linha própria** ([`dre/cascata.ts:41-56`](../../src/lib/dre/cascata.ts#L41-L56)). Regime de caixa afirmado e testado em 8 engines. A cascata sai das *linhas*, nunca de lista de `categoria_id` na mão — categoria nova entra sozinha. | +| **H2** · Capital de Giro entrega C4 | ✅ **Confirmada, com folga** | Régua em dias + decomposição volume×eficiência ([`regua.ts`](../../src/lib/giro/regua.ts)) + custo do giro em 3 leituras + taxa efetiva ([`custo.ts`](../../src/lib/giro/custo.ts)). | +| **H3** · Endividamento entrega C3 e C6 | ⚠️ **Metade** | **C3 sim** (CMD, custo financeiro, 15 modalidades, 10 sinais de estresse, dívida líquida, DL/EBITDA, what-if de renegociação, série Selic). **C6 não** — ver §5.2. | +| **H4** · Caixa & Projeção entrega C2 e C7 | ⚠️ **C2 sim, C7 pela metade** | Projeção de 12 meses (clamp 1–24) em [`annual-projection.ts:46,155`](../../src/lib/treasury/annual-projection.ts#L46). O veredito **já prescreve** in-line ([`veredito-caixa.ts:275`](../../src/lib/treasury/veredito-caixa.ts#L275): *"Cura barata — antecipar/encurtar o prazo de recebimento ou uma única operação de giro de R$ X, em vez de antecipar a cada mês"*), mas a ação-raiz formal mora no HTR, que está travado (§5.3). | +| **H5** · Carteira/recebíveis entrega C5 | ⚠️ **Parcial** | Ver §5.1. | +| **H6** · Cockpit + Plano de Voo entregam C7 | ⚠️ **Parcial** | O Cockpit tem veredito com causa-raiz e ponteiro de ação; o Plano de Voo existe (meta 12m, sazonalidade, bandas próprias, confiança, qualidade de dado). Mas o elo final é gated (§5.3). | +| **H7** · Nada atende C8 | ✅ **Confirmada** — é decisão de preço, não de código | — | + +--- + +## 5. Os três gaps reais + +### 5.1 · C5 — o recebível é medido como histórico, não como risco + +**O que existe:** +- Concentração de clientes por receita recebida, com HHI, top-3/5/10, regularidade + (recorrente/sazonal/pontual/irregular) e detecção de "quem sumiu" + ([`retrato/client-revenue-analysis.ts:38-44`](../../src/lib/retrato/client-revenue-analysis.ts#L38-L44)). +- HHI genérico reusável ([`concentracao/hhi.ts`](../../src/lib/concentracao/hhi.ts)), hoje aplicado a + **banco, fornecedor e estoque**. +- "Carteira viva": cronograma de parcelas de venda a prazo declarada no gate, com reconciliação + contra recebimentos reais ([`dre-detalhamento/recebivel-cronograma.ts`](../../src/lib/dre-detalhamento/recebivel-cronograma.ts)). + +**O que não existe:** +- **Aging de contas a receber.** Grep por `contas_receber|receivable|aging.*receb` em `src/lib` + retorna **zero**. Contas a **pagar** tem aging ([`treasury/contas-pagar-aging.ts`](../../src/lib/treasury/contas-pagar-aging.ts)); a receber, não. +- **Risco por sacado.** A concentração olha para trás (quem me pagou), não para frente (quem me deve + e há quanto tempo). +- O cliente é **inferido do descritivo do extrato**, não de cadastro — o próprio código ostenta isso + como proxy (`client-revenue-analysis.ts:6-8`). + +**Por que importa:** este é o gap de maior valor de todos, porque a F1 §F2 mostrou que **a dívida +que está quebrando o Brasil é comercial, não bancária** — Serviços responde por 31,6% da origem das +dívidas negativadas, acima dos bancos (19,5%), enquanto a inadimplência da carteira bancária PJ está +em só 2,6%. O risco mora exatamente onde o produto ainda não olha. + +### 5.2 · C6 — a porta da restrição cadastral não é vigiada + +Grep por `serasa|negativad|protesto|cadin|restricao_cadastral|score_credito` em todo o `src/` +retorna **um único arquivo**: `client-service.ts:60`, campo `score_serasa` — e esse é o +enriquecimento de **lead do CRM da ATR**, não monitoramento da situação cadastral do cliente. + +O produto vê a empresa escorregando na escada (sinais 02–07), mas não vê a **porta se fechando** — +que, pela F1 §3.4, é o ponto de não-retorno: uma vez com restrição ativa, Pronampe e BNDES ficam +bloqueados *por regra*, e só sobra o crédito caro. + +Ressalva honesta: isso pode ser deliberado (exige integração paga com bureau) e não é um bug. Mas é +uma lacuna de cobertura contra o cenário, e vale ser decisão explícita e não omissão. + +### 5.3 · C7 — o elo final está travado, e o Cockpit aponta pra ele mesmo assim + +Esta é a descoberta mais consequente da frente. A cadeia de ação, verificada de ponta a ponta: + +``` +Cockpit → veredito de caixa detecta aperto + → acaoNoHtr = true (veredito-caixa.ts:290 — "semaforo !== verde") + → card mostra "Ver o que fazer → abrir no HTR" (VeredictoCaixaCard.tsx:109-124) + → /projetos/[id]/htr + ↳ GATE: exige plano_voo_snapshots.status = 'aprovado' (iniciativas-gate.ts:43-56) + ↳ senão: redirect(`/projetos/[id]/plano-voo`) (htr/page.tsx:40-42) + ↳ GATE do Plano: os 12 meses da janela fechados (plano-voo-gate.ts:62-73) + ↳ consultorPronto exige snapshotsAtivos >= 12 (consultor-readiness.ts:62) + ↳ MESES_JANELA = 12 (plano-voo/janela-12m.ts:25) +``` + +Três consequências verificadas: + +1. **`acaoNoHtr` não consulta readiness nenhum.** É literalmente `semaforo !== "verde"` + ([`veredito-caixa.ts:290`](../../src/lib/treasury/veredito-caixa.ts#L290)). Uma empresa com aperto + real e 4 meses de histórico vê "Ver o que fazer", clica, e **é redirecionada pro Plano de Voo** — + que também está trancado. O ponteiro de ação existe; o destino, ainda não. + +2. **O gate são 12 de 12 meses da janela — mas a janela é RETROATIVA.** Este ponto é decisivo e é + fácil ler errado. A janela são os 12 meses que **antecedem a entrada** do cliente, não os 12 + meses seguintes ([`janela-12m.ts:56-63`](../../src/lib/plano-voo/janela-12m.ts#L56-L63)). O + comentário do arquivo é explícito: *"o gate é de **profundidade de histórico**, não de tempo de + casa. O cliente que sobe 12 meses de OFX retroativo na primeira semana destrava na primeira + semana"* ([`janela-12m.ts:12-16`](../../src/lib/plano-voo/janela-12m.ts#L12-L16)). + + Ou seja: **o gate não é esperar — é subir histórico e fechar.** O caminho completo é + *12 meses de OFX → categorizar e fechar → gerar o plano → consultor aprovar*. Para um cliente da + faixa-alvo, que tem extrato bancário, isso é trabalho de onboarding — semanas, não um ano. Só + espera de verdade a empresa que **não tem** 12 meses de histórico. + +3. **A sugestão proativa é código órfão.** `generateProactiveSuggestions` existe + ([`htr-consultant-agent.ts:151`](../../src/lib/htr-consultant-agent.ts#L151)) e o comentário do + arquivo diz que é *"usado por widgets de sugestão proativa fora do chat"*. O único caller é a rota + [`api/ai/htr/suggest/route.ts:35`](../../src/app/api/ai/htr/suggest/route.ts#L35) — e **nenhum + componente do front chama essa rota** (grep por `ai/htr/suggest` fora do próprio `route.ts`: zero + resultados). O widget descrito no comentário não existe. + +**A tensão com a F1, dita sem rodeio:** a tese de venda é *antecedência*. As camadas de +**diagnóstico** (Resultado, Giro, Endividamento, Caixa) não têm gate e degradam com honestidade — +entregam a partir do primeiro mês fechado. O **cérebro** que prescreve exige a janela de 12 meses +fechada, o que é uma barreira de **histórico**, não de tempo (ponto 2 acima). + +Sobra um problema de UX real, e ele é pequeno: enquanto a janela não fecha, o Cockpit oferece um +botão de ação que não leva a ação nenhuma (ponto 1). Não é o gate que está errado — é o ponteiro que +não o consulta. Ver §7 e o backlog. + +--- + +## 6. Divergências código × documentação encontradas + +Registro para conserto, não para debate: + +1. **Comentário de gate desatualizado, em dois arquivos.** + [`plano-voo-gate.ts:6`](../../src/lib/plano-voo-gate.ts#L6) diz *"≥3 snapshots mensais ATIVOS"* e + [`iniciativas-gate.ts:12-14`](../../src/lib/iniciativas-gate.ts#L12-L14) diz *"o Plano precisa de + ≥3 meses fechados"*. **O gate real é 12** desde 2026-07-28 + ([`consultor-readiness.ts:12-14,62`](../../src/lib/consultor-readiness.ts#L62)). Os dois + cabeçalhos ficaram para trás — e são exatamente os arquivos que alguém lê para entender o gate. + Correção de 2 linhas, risco zero. + +2. **Comentário promete um widget que não existe** — + [`htr-consultant-agent.ts:16-17`](../../src/lib/htr-consultant-agent.ts#L16-L17) (ver §5.3.3). + +3. **Divergência conhecida e já documentada** entre a convenção de sinal do TS e do SQL v3 na + cascata quando há estorno dentro da categoria ([`dre/cascata.ts:30-38`](../../src/lib/dre/cascata.ts#L30-L38)) — está coberta por teste e tem trilha própria. **Não toquei.** + +--- + +## 7. Ponte para a Frente 3 + +O que a F3 tem em mãos, de concreto: + +**Três provas verificáveis** (§3) — o produto já implementa a análise da F1 sem tê-la conhecido: +o custo do giro em "% do lucro" na tela, a escada de modalidades com 10 detectores de descida, e a +decomposição que separa "cresci" de "meu ciclo piorou". Mais o caso Vertímetal (§3.5): 58% do custo +de giro invisível, achado pelo próprio produto. + +**Uma linha do tempo honesta do valor**, que é o esqueleto da promessa comercial: + +O relógio **não é o calendário — é a profundidade do histórico que o cliente sobe**: + +| Marco | O que o cliente ganha | Travado por | +|---|---|---| +| 1º mês fechado | A verdade do mês em regime de caixa: deu lucro ou não, e onde | nada | +| ~3 meses fechados | Custo do giro em % do lucro · CMD · sinais de estresse · concentração de clientes | nada | +| ~3 meses + saldo | Projeção de caixa 12m com veredito, causa-raiz e prescrição in-line | saldo ancorado | +| **12 meses da janela fechados** | Meta travada, pacing e **prescrição formal (Iniciativas)** | histórico + aprovação | + +O último marco cai em **semanas** para quem sobe 12–24 meses de OFX retroativo, e só vira espera real +para a empresa que não tem esse histórico. + +**Três gaps para decidir** (§5): aging/risco de recebível (o mais valioso, porque é onde a crise +mora), restrição cadastral (decisão de comprar bureau ou assumir a lacuna), e o elo Cockpit→HTR +travado (decisão de produto: ou o ponteiro respeita o gate, ou o gate abre antes). + +**Recomendação para a F3:** vender o **diagnóstico como a entrega do mês 1** e o **plano como a +entrega do ano 1** — porque é isso que o código faz. Prometer prescrição desde o mês 1 é vender o +que está atrás de um gate de 12 meses, e o primeiro cliente que clicar em "Ver o que fazer" descobre. diff --git a/docs/valor/frente-3-oferta-de-valor.md b/docs/valor/frente-3-oferta-de-valor.md new file mode 100644 index 00000000..3acefe23 --- /dev/null +++ b/docs/valor/frente-3-oferta-de-valor.md @@ -0,0 +1,387 @@ +# Frente 3 — A oferta de valor: o que o ATR OS faz, como, e por que importa + +**Data:** 17/ago/2026 +**Base:** [F1 — cenário](frente-1-cenario-brasil-2026.md) × [F2 — o código vivo](frente-2-o-que-o-atros-faz.md) +**Público:** empresas de R$ 100 mil a R$ 3 milhões de faturamento **mensal** + +> Este documento responde três perguntas em ordem: *(1)* o sistema é uma coisa só ou onze telas? +> *(2)* o que ele impacta e quanto isso vale? *(3)* o que dá pra prometer, com honestidade? +> +> Toda capacidade citada foi verificada no código na F2. Todo número de mercado veio da F1. +> Onde o sistema **não** faz, está dito — e está no [backlog](gaps-backlog.md). + +--- + +## 1. A tese: só existem três buracos + +Uma empresa da faixa-alvo pode estar quebrando por muitas razões aparentes — CMV alto, cliente +concentrado, imposto, dívida, estoque, folha. Mas todas desembocam em **três buracos, e só três**: + +| | O buraco | O que ele significa | Como o dono sente | +|---|---|---|---| +| **1** | **Margem** | a operação não se paga | *"vendo e não sobra"* | +| **2** | **Tempo** | a operação se paga, mas o dinheiro chega depois que a conta vence | *"sou lucrativo e estou quebrado"* | +| **3** | **Custo do dinheiro** | a operação se paga e o ciclo fecha, mas o funding come o lucro antes de você ver | *"trabalho pro banco"* | + +**E aqui está o problema que justifica o produto inteiro: os três aparecem idênticos no extrato.** + +Saldo baixo é saldo baixo. O extrato mostra o *sintoma*, e os três sintomas são o mesmo sintoma. É +por isso que "olhar o banco todo dia" não resolve nada — e por isso que o dono tenta consertar buraco +de tempo com esforço de venda, que é o remédio que **piora** o quadro. + +Separar os três é a função primária do sistema. E não é retórica de material de venda: é o que o +código faz, com essas palavras. O veredito de caixa, quando detecta aperto, escreve +([`veredito-caixa.ts:275`](../../src/lib/treasury/veredito-caixa.ts#L275)): + +> *"É aperto de tempo, não de margem: a operação se paga, mas o ciclo de N dias exige R$ X de giro. +> Cura barata — antecipar/encurtar o prazo de recebimento ou uma única operação de giro de R$ Y, em +> vez de antecipar a cada mês."* + +**A DRE nomeia ONDE. Ela não diz QUAL dos três.** É essa a resposta para "a DRE mostra os buracos, o +resto serve pra quê": o resto do sistema existe para separar os três, medir cada um em reais, e dizer +qual matar primeiro. + +--- + +## 2. A máquina: sete perguntas em ordem obrigatória + +Onze telas dão onze respostas soltas. **Sete perguntas em ordem dão uma resposta só: o que fazer +segunda-feira.** + +| # | A pergunta do dono | Onde ela vive | O que consome | O que entrega | +|---|---|---|---|---| +| 1 | *"Isso aqui é verdade?"* | **Apuração** | extrato bruto (OFX) | meses fechados, em regime de caixa | +| 2 | *"Deu lucro? Pra onde foi o dinheiro?"* | **Resultado (DRE)** | meses fechados | **a linha** do buraco | +| 3 | *"Por que o buraco existe?"* | **Giro · Endividamento · Clientes/Fornecedores** | a linha | **qual dos três**, e quanto custa por mês | +| 4 | *"Isso me mata? Quando?"* | **Caixa & Projeção** | os três + saldo ancorado | o mês do aperto + a causa | +| 5 | *"O que eu mexo primeiro?"* | **Simulador** | o ciclo medido + a margem de contribuição | o efeito de cada alavanca, em R$ | +| 6 | *"Pra onde eu vou?"* | **Plano de Voo** | os 12 meses da janela | meta travada, com sazonalidade | +| 7 | *"Estou indo? O que eu faço?"* | **Cockpit · Iniciativas** | a meta | pacing + ação-raiz | + +**Cada pergunta consome a resposta da anterior.** Isso não é uma convenção de UX — é imposto no +código: Iniciativas exige Plano aprovado ([`iniciativas-gate.ts:43-56`](../../src/lib/iniciativas-gate.ts#L43-L56)), +Plano exige a janela de 12 meses fechada ([`plano-voo-gate.ts:62-73`](../../src/lib/plano-voo-gate.ts#L62-L73)). +O sistema **recusa** pular etapa. + +Não dá pra perguntar "quando acaba" antes de saber "quanto sobra". Não dá pra perguntar "qual +alavanca" antes de saber "qual buraco". A ordem é a arquitetura. + +### A cadência — como isso vira dia a dia + +| Ritmo | O que acontece | Motor | +|---|---|---| +| **Semana** | o que vence, o que entra, o que falta | tesouraria semanal + calendário de compromissos | +| **Mês** | fecha o mês → a verdade → o diagnóstico se atualiza | Apuração → DRE → Giro/Endividamento | +| **Trimestre** | a rota ainda serve? | Cockpit (pacing acumulado vs. esperado-sazonal) | +| **Ano** | a meta e as iniciativas | Plano de Voo → Iniciativas | + +O trabalho recorrente do dono é **um fechamento por mês**. Não é um sistema que exige alimentação +diária — é um ritual mensal que devolve um ano de visibilidade. + +--- + +## 3. Os quatro arquétipos + +### Arquétipo 1 — A ESTAGNADA + +> *"Faturo o mesmo há três anos. Trabalho igual ou mais. Não saio do lugar."* + +**O erro que ela comete.** Trata como problema de venda: contrata vendedor, investe em marketing, +aperta o time. Se a trava não for de venda — e quase nunca é —, isso queima caixa e não move a +receita. + +**O que o sistema faz, na ordem:** + +1. **DRE (Q2):** receita estável, resultado estável. Isso já elimina o buraco de margem escondido. +2. **Giro (Q3):** a régua em dias mostra quantos dias de faturamento estão parados + ([`regua.ts:62-75`](../../src/lib/giro/regua.ts#L62-L75)). E a decomposição **volume × + eficiência** ([`regua.ts:91-109`](../../src/lib/giro/regua.ts#L91-L109)) responde a pergunta que + ninguém faz: **nas vezes em que você cresceu, o dinheiro apareceu?** Se o efeito-volume foi + absorvido pelo ciclo, crescer nunca virou caixa. +3. **Simulador (Q5):** com o ciclo real medido, o motor calcula quanto de giro cada ponto de + crescimento consome — `−ΔReceita × (PMR − PMP) / 30` + ([`what-if-engine.ts:25-26,228-239`](../../src/lib/cash-flow/what-if-engine.ts#L25-L26)). Sem + ciclo medido ele devolve zero e diz que não estimou — não chuta. + +**A conta, numa empresa de R$ 500 mil/mês, margem 6%, PMR 45 / PMP 20 (ciclo de 25 dias):** + +Crescer 20% significa +R$ 100 mil/mês de receita. Isso prende **R$ 83.333 de giro adicional**, uma +vez. Se ela não tem esse dinheiro parado, toma emprestado: + +| Onde ela toma | Juros/mês sobre os R$ 83 mil | A margem nova é R$ 6.000/mês. Sobra: | +|---|---|---| +| Capital de giro contratado (1,89%) | R$ 1.575 | R$ 4.425 — **26% do lucro do crescimento vira juros** | +| Rotativo (2,59%) | R$ 2.158 | R$ 3.842 — **36%** | +| Conta garantida (4,18%) | R$ 3.483 | R$ 2.517 — **58%** | + +**O veredito que o dono nunca ouviu:** + +> *"Você não está estagnado por falta de venda. Cada R$ 100 mil a mais de faturamento exige +> R$ 83 mil de giro que você não tem — e mais da metade do lucro desse crescimento iria embora em +> juros. A sua empresa, sem saber, aprendeu a não crescer. **A estagnação é a defesa dela.**"* + +Isso vira a conversa do avesso. O dono chegou achando que precisava de mais venda; sai sabendo que +crescer sem resolver o ciclo o mataria mais rápido — e que a alavanca é o **prazo**, não o esforço. + +**As três travas possíveis, e como o sistema as separa:** + +| Trava | Sintoma | Instrumento que prova | +|---|---|---| +| **Giro** | cresce e o dinheiro não aparece | régua em dias + decomposição + `giroImpactoUnico` | +| **Margem** | mais volume = mais trabalho, não mais lucro | margem de contribuição por comportamento + ponto de equilíbrio | +| **Dependência** | crescer significa brigar com quem paga a conta | HHI de clientes + top-3/5/10 | + +--- + +### Arquétipo 2 — A QUE ESTÁ NO PREJUÍZO + +> *"Sei que estou perdendo. Não sei onde."* + +**A distinção que salva a empresa** — e que quase ninguém faz: + +- **Prejuízo de margem:** a margem de contribuição é negativa. **Vender mais aumenta o prejuízo.** + Remédio: preço e CMV. Cortar despesa fixa não resolve. +- **Prejuízo de estrutura:** a MC é positiva, mas o ponto de equilíbrio está acima da receita. A + operação funciona, a estrutura é grande demais pro tamanho. Remédio: estrutura ou volume. + +São remédios **opostos**. Errar o diagnóstico aqui é o caminho mais rápido para a estatística. + +**Como o sistema separa:** + +1. **DRE (Q2)** nomeia a linha — CMV (L30), despesas de venda (L40), estrutura (L50), financeiro + (L65), tributos (L70). Com drill-down por célula. +2. **Curva ABC (Q3)** aplica Pareto: das ~200 rubricas, quais somam 80% + ([`abc/abc-despesas.ts`](../../src/lib/abc/abc-despesas.ts)). "Cortar custos" vira "estas três + rubricas são classe A e somam R$ X". +3. **Margem de contribuição por comportamento (Q3)** separa variável de fixo pelo **comportamento + real da subcategoria**, não pelo proxy de linha da DRE + ([`margem-contribuicao.ts:1-22`](../../src/lib/margem-contribuicao.ts#L1-L22)). O proxy erra nas + bordas — conta marketing como variável, ignora que dedução é variável, trata energia como fixa. A + MC reconcilia 1:1 com a DRE oficial. +4. **Ponto de equilíbrio (Q3)** diz o faturamento mínimo para resultado zero, sobre as somas da + janela recente — não a média da vida do projeto + ([`evolucao/ponto-equilibrio.ts`](../../src/lib/evolucao/ponto-equilibrio.ts)). **Quando a razão + de MC é ≤ 0, ele bloqueia e diz "indefinido" em vez de inventar um número** — porque MC negativa + *é* o diagnóstico, e um PE fabricado esconderia justamente ele. +5. **Simulador (Q5)** separa **preço de volume** — mexer no preço vai ~100% pra margem; mexer no + volume arrasta custos variáveis junto ([`what-if-engine.ts:20-33`](../../src/lib/cash-flow/what-if-engine.ts#L20-L33)). + Com MC fina, +1% de preço vale mais que +10% de volume, e o simulador prova qual. +6. **Meta e contratação (Q5):** quanto vender pra lucrar R$ X + ([`breakeven/simulador-meta.ts`](../../src/lib/breakeven/simulador-meta.ts)) e quanto a mais + faturar pra bancar N contratações + ([`breakeven/whatif-contratacao.ts`](../../src/lib/breakeven/whatif-contratacao.ts)) — sem + inventar produtividade da contratação. + +--- + +### Arquétipo 3 — A REFÉM (concentração) + +> *"Três clientes são 70% do meu faturamento. Não durmo."* + +**O que o sistema mede:** HHI + top-3/5/10 + regularidade (recorrente / sazonal / pontual / +irregular) + **detecção de quem sumiu**, do lado dos clientes +([`retrato/client-revenue-analysis.ts:38-44`](../../src/lib/retrato/client-revenue-analysis.ts#L38-L44)); +concentração de fornecedores a partir das transações de CMV +([`supplier-analysis-service.ts`](../../src/lib/supplier-analysis-service.ts)); e concentração +bancária ([`concentracao/hhi.ts`](../../src/lib/concentracao/hhi.ts)). + +**O cruzamento que dá o valor** — e que não sai de nenhuma das telas isoladas: **concentração × +ciclo.** + +Um cliente que é 40% da receita e paga em 52 dias não é um cliente. É um **financiamento que você +concede sem cobrar juros** — e o Giro precifica exatamente quanto ele custa. + +> *"O cliente X é 38% da sua receita e paga em 52 dias. Ele prende R$ Y do seu giro, que te custa +> R$ Z por ano. Ou você renegocia o prazo dele, ou você está pagando para atendê-lo."* + +Essa frase muda uma negociação comercial inteira, e ela só existe porque duas telas conversam. + +**Honestidade (gap G-01):** hoje a concentração é medida sobre a receita **recebida** — olha para +trás. O cliente é inferido do descritivo do extrato, não de cadastro. Aging de contas a receber e +risco por sacado **não existem ainda** e são o item nº 1 do [backlog](gaps-backlog.md). + +--- + +### Arquétipo 4 — A AFOGADA (dívida, antecipação, sem crédito) + +> *"Devo pra todo mundo, antecipo tudo, e o banco cortou meu limite."* + +**O que o sistema faz:** + +1. **CMD** — custo médio ponderado da dívida, ao mês e ao ano composto + ([`endividamento/cmd.ts:17-33`](../../src/lib/endividamento/cmd.ts#L17-L33)). Responde: **em que + degrau médio eu estou?** +2. **As 15 modalidades PJ** como fonte única + ([`loan-contracts-modalidades.ts:13-29`](../../src/lib/loan-contracts-modalidades.ts#L13-L29)) — + dos degraus baratos (Pronampe, Finame, FGI/FAMPE, cartão BNDES) aos caros (conta garantida / + cheque especial). Responde: **quais degraus eu ocupo?** +3. **Dez detectores de estresse** + ([`sinais-estresse/index.ts:59-81`](../../src/lib/endividamento/sinais-estresse/index.ts#L59-L81)): + cheque especial, antecipação sistemática, antecipação crescente, **tampa-buraco**, fornecedor em + atraso, imposto parcelado, saldo zerado, pagamentos empilhados no fim do mês, saldo volátil. +4. **Custo do giro em % do lucro** ([`giro/custo.ts:102-113`](../../src/lib/giro/custo.ts#L102-L113)), + na tela como "X% do lucro" ([`GiroView.tsx:132`](../../src/components/giro/GiroView.tsx#L132)) — e + a **taxa efetiva anual que ela realmente paga**, derivada do que já gastou, não de tabela + ([`custo.ts:121-124`](../../src/lib/giro/custo.ts#L121-L124)). +5. **A antecipação marcada como insustentável.** O veredito classifica a fonte que cobre o caixa e + crava `insustentavel: true` na antecipação — *"venda já feita, puxada com deságio"* + ([`veredito-caixa.ts:32-46`](../../src/lib/treasury/veredito-caixa.ts#L32-L46)). É a diferença + entre **ponte** e **dependência**. +6. **What-if de renegociação** ([`endividamento/whatif-renegociacao.ts`](../../src/lib/endividamento/whatif-renegociacao.ts)). + +**O veredito:** + +> *"Você não tem um problema de dívida. Tem três: R$ A parados no degrau de 4,18% ao mês, R$ B de +> antecipação recorrente que custa C% ao ano, e o fornecedor em atraso — que é a dívida que fecha a +> porta do crédito barato."* + +**A consequência estratégica (F1 §3.4):** a ordem de pagamento não é por valor nem por juros. É por +**qual dívida fecha portas**. Protesto e imposto geram restrição cadastral, e restrição cadastral +bloqueia Pronampe e BNDES *por regra* — o degrau barato some. O sistema hoje dá os insumos para essa +priorização; automatizá-la depende do gap G-02. + +--- + +## 4. O que muda, e quanto vale + +Empresa de referência: **R$ 500 mil/mês**, margem líquida 6% (**R$ 30 mil/mês, R$ 360 mil/ano**). +Taxas do Banco Central, maio/2026 (F1 §3.1). + +| Alavanca que o sistema torna visível | Mecanismo | Vale por ano | +|---|---|---| +| **Trocar de degrau** — sair da conta garantida (4,18%) para capital de giro contratado (1,89%), sobre R$ 500 mil | CMD + modalidades mostram onde ela está | **R$ 137.400** — 38% do lucro anual | +| **Encurtar 10 dias de PMR** — libera R$ 166,7 mil de giro e para de pagar juros sobre ele | régua em dias + simulador | **R$ 51.800** | +| **Antecedência no aperto** — estruturar R$ 200 mil antes (1,89%) em vez de tapar depois (4,18%) | projeção 12m + veredito com causa | **R$ 54.960** | +| **Enxergar o custo do giro** — não dá pra negociar o que não se mede | custo do giro em % do lucro | caso real: **R$ 272.804/ano invisíveis** (ver §5) | +| **Não crescer no escuro** — saber o giro que o crescimento consome antes de crescer | `giroImpactoUnico` com ciclo medido | evita tomar R$ 83 mil no degrau errado | + +**Uma alavanca dessas paga o sistema muitas vezes.** Cada linha vale entre R$ 50 mil e R$ 137 mil por +ano numa empresa que lucra R$ 360 mil. + +**A ressalva que precisa estar no material, sempre:** o sistema **não economiza esse dinheiro**. Ele +torna a decisão possível — quem economiza é o dono, negociando o degrau, o prazo ou o contrato. Vender +"economia automática" é o jeito mais rápido de perder o cliente no terceiro mês. + +--- + +## 5. A prova que vale mais que o argumento + +Durante a construção do módulo de Capital de Giro (17/ago/2026), o check ao vivo achou isto +([`giro/custo.ts:46-68`](../../src/lib/giro/custo.ts#L46-L68)): + +O campo `finalidade` dos contratos está **NULL em 100% dos contratos de produção** — ninguém preenche, +porque é uma pergunta de intenção feita depois do fato. Filtrando o custo de giro só por ele, um +cliente real media: + +``` +deságio R$ 201.390 + juros R$ 0 = R$ 201.390/ano +``` + +O real era **R$ 474.194/ano**. Dois contratos de capital de giro pagando **R$ 272.804 de juros em +12 meses, invisíveis**. Erro de **58%, para menos**. + +Isso é a tese inteira em miniatura, e aconteceu dentro do próprio produto, com um cliente real: +**o custo do dinheiro é o número que some — e ele some para menos, que é a direção que ninguém +percebe.** + +Vale como história de venda porque é verificável, é específica, e é sobre alguém que existe. + +--- + +## 6. A oferta + +**O relógio não é o calendário — é a profundidade do histórico que o cliente sobe.** A janela de 12 +meses do Plano é **retroativa**: são os 12 meses que antecedem a entrada +([`janela-12m.ts:12-16`](../../src/lib/plano-voo/janela-12m.ts#L12-L16)). Quem sobe 12–24 meses de +OFX no onboarding destrava em semanas. + +| Marco | O que o cliente tem | Depende de | +|---|---|---| +| **1º mês fechado** | **A verdade.** Deu lucro ou não, e em qual linha o dinheiro foi. Em regime de caixa — o dinheiro que entrou e saiu de fato, não competência. | subir o extrato | +| **~3 meses fechados** | **O diagnóstico.** Qual dos três buracos é o seu, e quanto ele custa por mês: custo do giro em % do lucro, CMD, degraus ocupados, sinais de estresse, concentração de clientes e fornecedores. | nada além do histórico | +| **+ saldo ancorado** | **O leme.** Projeção de 12 meses com o mês do aperto, a causa-raiz e a prescrição. Simulador com o **seu** ciclo medido, separando preço de volume. | confirmar o saldo bancário | +| **Janela de 12 meses fechada** | **A rota.** Meta travada com sazonalidade própria, pacing mensal e as iniciativas — o que fazer. | histórico completo + aprovação | + +### A oferta em uma frase + +> **Você para de decidir por susto.** +> +> Em um mês você sabe a verdade do seu resultado. Em três, sabe qual dos três buracos é o seu e +> quanto ele custa. E enquanto o dinheiro no Brasil custa 25% a 63% ao ano e 67% dos pedidos de +> crédito são negados, saber isso com antecedência é a diferença entre estruturar barato e tapar +> caro. + +### Por que agora (a ponte com a F1) + +O que mudou não foi o cenário — foi o **custo do erro**: + +- A Selic caiu para 14%, mas a taxa média PJ **subiu** para 25,19% a.a. — a maior desde 2017. + **Esperar o juro cair não é estratégia.** +- 9,1 milhões de CNPJs negativados, recorde. E a dívida que estoura é **comercial**: outras empresas + respondem por 31,6% da origem, acima dos bancos (19,5%). +- 67% dos pedidos de crédito negados. E restrição cadastral bloqueia as linhas baratas **por regra** — + a porta fecha por dentro. +- 49% das PMEs perderam margem; só 14,7% conseguiram repassar preço. +- **~30% das PMEs não sabem dizer se fecharam o mês com lucro ou prejuízo.** + +Em 2019, com Selic a 4,5%, descobrir tarde custava caro e se corrigia. Em 2026, descobrir tarde é +terminal — porque não existe segunda rodada de crédito barato. + +--- + +## 7. O vocabulário — como falar isso + +Nenhum cliente acorda querendo uma DRE gerencial. Use a frase dele, e devolva o mecanismo: + +| Ele diz | Você devolve | Onde está | +|---|---|---| +| *"Vendi mais e sobrou menos."* | "Sua margem de contribuição caiu X p.p. e você não repassou. Seu ponto de equilíbrio subiu pra R$ Y." | MC + PE | +| *"Não sei onde o dinheiro está sangrando."* | "Estas 3 rubricas são classe A e somam R$ X. E o custo do dinheiro come Z% do seu lucro." | ABC + custo do giro | +| *"Todo dia 20 aperta."* | "Seu ciclo é de N dias. Isso exige R$ X de giro — o aperto é de tempo, não de margem." | régua + veredito de caixa | +| *"Tenho o buraco e o empréstimo."* | "Você está no degrau de 4,18% ao mês. O sinal tampa-buraco disparou em 3 dos últimos 6 meses." | CMD + sinal 05 | +| *"Meu cliente não paga e não posso parar de vender pra ele."* | "Ele é 38% da sua receita e paga em 52 dias. Prende R$ X do seu giro e custa R$ Y/ano." | HHI × ciclo | +| *"O banco cortou meu limite."* | "Sua cobertura de caixa é antecipação recorrente — insustentável por definição. Precisamos trocar a fonte antes de trocar o limite." | veredito de cobertura | +| *"Não sei se aguento seis meses."* | "Aguenta até [mês]. O aperto é de R$ X e a causa é [causa]. Estruturado agora custa R$ A; tapado depois, R$ B." | projeção 12m | +| *"Faturo o mesmo há três anos."* | "Crescer 20% prende R$ 83 mil de giro que você não tem. Sua empresa aprendeu a não crescer." | decomposição + simulador | + +**Regra:** nunca leve a tela pra conversa. Leve o número e a decisão. A tela é onde ele confere +depois — e conferir é o que constrói a confiança. + +--- + +## 8. O que NÃO prometer + +Disciplina de oferta. Cada item abaixo é uma promessa que o código **não** sustenta hoje: + +1. **Não prometa prescrição no primeiro mês.** Diagnóstico sim, plano e iniciativas exigem a janela + de 12 meses fechada. Quem sobe histórico chega lá em semanas — mas é a *subida do histórico* que + destrava, não o tempo de contrato. +2. **Não prometa cobrança nem gestão de inadimplência de clientes.** Não existe aging de contas a + receber (gap G-01). O que existe é concentração sobre o que já foi recebido. +3. **Não prometa monitoramento de Serasa/protesto.** Não existe (gap G-02). +4. **Não prometa "economia automática".** O sistema torna a decisão possível; quem negocia é o dono. +5. **Não prometa integração bancária automática.** A ingestão é por OFX — o ritual mensal existe e é + parte do método, não um defeito a esconder. +6. **Não prometa contabilidade.** O ATR OS é regime de **caixa**, feito para decisão. Não substitui a + contabilidade fiscal — e é justamente por isso que ele responde o que ela não responde. + +--- + +## 9. Como isso se conecta ao Norte + +A oferta acima não inventa nada fora da Constituição — ela a descreve para fora: + +| Régua | Como a oferta a executa | +|---|---| +| **R1** dono-no-comando | o consultor fala primeiro (veredito), o dono decide a alavanca | +| **R2** vivo | regime de caixa + frescor; o mês fechado avisa quando muda | +| **R3** causa→efeito | "aperto de tempo, não de margem" — a causa vem junto do número | +| **R4** termina-em-ação-raiz | as sete perguntas terminam em Iniciativas | +| **R5** amarrado-ao-objetivo | janela e meta de 12 meses | +| **R6** HTR-proativo | ainda parcial — ver gaps G-03 e G-04 | +| **R7** híbrido | motor calcula, IA narra | +| **R8** ingestão-guiada | o OFX e o gate de fechamento | +| **R9** hábito-leve | um fechamento por mês, não alimentação diária | + +**Próximo passo natural:** rodar `/norte` sobre esta oferta antes de virar material de venda — para +que a peça comercial nasça pontuada pela mesma régua que governa o produto. diff --git a/docs/valor/gaps-backlog.md b/docs/valor/gaps-backlog.md new file mode 100644 index 00000000..e06e0162 --- /dev/null +++ b/docs/valor/gaps-backlog.md @@ -0,0 +1,265 @@ +# Backlog de gaps — saídos da trilha Valor (F1 × F2) + +**Aberto em:** 17/ago/2026 · **Decisão do Lucas:** anotar agora, executar depois. +**Origem:** [`frente-2-o-que-o-atros-faz.md`](frente-2-o-que-o-atros-faz.md) §5 e §6. +Todos os itens foram verificados no código vivo — nenhum vem de doc. + +--- + +## G-01 · Aging e risco de recebível — **maior valor** + +**O que falta.** Não existe aging de contas a receber. Grep por +`contas_receber|receivable|aging.*receb` em `src/lib` retorna zero. Contas a **pagar** tem aging +(`treasury/contas-pagar-aging.ts`); a **receber**, não. + +O que existe hoje é adjacente, não substituto: +- Concentração de clientes por receita **recebida** (`retrato/client-revenue-analysis.ts`) — olha + para trás, e o cliente é inferido do descritivo do extrato, não de cadastro. +- Carteira viva de parcelas de venda a prazo declarada no gate + (`dre-detalhamento/recebivel-cronograma.ts`) — só cobre a venda que alguém declarou. + +**Por que importa.** A F1 §F2 mediu: a dívida que está estourando no Brasil é **comercial, não +bancária** — Serviços responde por 31,6% da origem das dívidas negativadas, acima dos bancos +(19,5%), enquanto a inadimplência da carteira bancária PJ está em 2,6%. O risco mora exatamente onde +o produto ainda não olha. + +**Forma provável.** Aging por faixa (a vencer / 1–30 / 31–60 / 61–90 / 90+) × sacado, alimentando +(a) a projeção de caixa com haircut por faixa e (b) o HHI de clientes já existente, que passaria a +medir concentração de **risco** e não só de receita. + +--- + +## G-02 · Vigilância da porta do crédito (restrição cadastral) + +**O que falta.** Zero código. Grep por `serasa|negativad|protesto|cadin|restricao_cadastral` em +`src/` devolve um único hit: `client-service.ts:60` (`score_serasa`), que é enriquecimento de **lead +do CRM da ATR**, não monitoramento do cliente. + +**Por que importa.** F1 §3.4: uma vez com restrição ativa, Pronampe/BNDES/linhas direcionadas ficam +bloqueados **por regra** — e 67% dos pedidos de crédito já são negados (Sebrae). O produto vê a +empresa escorregando na escada de funding (sinais 02–07), mas não vê a porta fechando. + +**Consequência estratégica que isso destrava.** Com esse dado, a priorização de pagamento deixa de +ser por valor ou por juros e passa a ser por **qual dívida fecha portas** — que é a ordem certa e +que nenhum concorrente da faixa faz. + +**Decisão pendente do Lucas:** comprar integração com bureau (custo recorrente por CNPJ) ou assumir +a lacuna explicitamente. Não é bug; é escopo. + +--- + +## G-03 · O ponteiro de ação do Cockpit não consulta o gate + +**O que acontece.** `acaoNoHtr` é literalmente `semaforo !== "verde"` +(`treasury/veredito-caixa.ts:290`), sem qualquer checagem de readiness. O card renderiza +"Ver o que fazer → abrir no HTR" (`VeredictoCaixaCard.tsx:109-124`), e o `/htr` redireciona pro +Plano de Voo quando a janela de 12 meses não está fechada (`htr/page.tsx:40-42`). + +Resultado: cliente com aperto real e histórico incompleto clica em "Ver o que fazer" e cai numa tela +que não é a ação. + +**Conserto provável (pequeno).** `acaoNoHtr` passa a exigir `iniciativasDestravadas`; quando +travado, o card troca o ponteiro por "complete os N meses que faltam" — que é a ação-raiz verdadeira +naquele estado, e reforça R8 (ingestão-guiada) em vez de furar o R4. + +--- + +## G-04 · Código órfão: sugestão proativa do HTR + +`generateProactiveSuggestions` (`htr-consultant-agent.ts:151`) tem como único caller a rota +`api/ai/htr/suggest/route.ts:35`, e **nenhum componente do front chama essa rota** (grep por +`ai/htr/suggest` fora do próprio `route.ts`: zero). O comentário do arquivo (linhas 16-17) afirma que +é *"usado por widgets de sugestão proativa fora do chat"* — o widget não existe. + +**Decisão:** ou construir o widget (é o R6, HTR-proativo) ou remover a rota e corrigir o comentário. +Deixar como está é o pior dos três. + +--- + +## G-05 · Comentários de gate desatualizados — ✅ **FECHADO 18/ago/2026** + +**Não eram 2 linhas, eram 8, em 6 arquivos** — e três delas propagavam **decisões revogadas**, não só +um número velho: + +| Onde | O que dizia de errado | +|---|---| +| `plano-voo-gate.ts:6` | "≥3 snapshots mensais ATIVOS" | +| `iniciativas-gate.ts` (×2) | "≥3 meses" · **"o ato explícito do consultor"** | +| `useProjectSidebarBadges.ts` (×2) | "≥3 meses" · **"gate D-3"**, revogada em 2026-06-05 | +| `Sidebar.tsx:145` | "≥3 meses **+ folha**" — folha não é gate | +| `htr-consultant-agent.ts:62` | "≥3 snapshots **+ folha**" | +| `financial-snapshot-service.ts:86` | citava `learned_rules`, removida no G-08 | + +O gate real é **12** desde 2026-07-28, e por **cobertura da janela** (`janela-12m.ts`), não por +contagem: 12 snapshots velhos, ou com buraco no meio, não destravam. + +### ⚠️ A armadilha: dois candidatos óbvios estavam CERTOS + +Um replace de "≥3 → 12" varrendo o repo teria corrompido dois comentários corretos, que descrevem +**outra regra, com um 3 real**: + +- `auto-conclusion-service.ts:91` — literalmente `snapshotsTotal >= 3` (endpoint legado `/concluir`); +- `module-service.ts:51` + `dre-publishing-service.ts:6` — a escada de publicação da DRE: + `rascunho (<3) → preliminar (3–11) → consolidada (12+)`. + +**Lição para a próxima faxina de comentário:** o número no texto só é erro se o literal no código +disser outra coisa. Conferir o literal antes de trocar a prosa. + +--- + +--- + +## G-06 · Partes relacionadas: a inteligência existe e está desligada da tomada + +Saído da [E2 §6](pente-fino/02-categorizacao.md). **Bloqueia G-07.** + +O detector de parte relacionada é bom e está pronto: lê `lancamento_original` (não o tratado, que +apaga dígitos e mata CNPJ/CPF), faz documento ganhar do nome, separa `propria`/`empresa_grupo`/`socio` +e só age sozinho com confiança `alta`. As portas e o catálogo (`socio_aporte`, `socio_distribuicao`, +`socio_emprestimo_*`, `mov_inter_*`) também existem. + +**Faltam dois elos:** + +1. **`client_socios` não tem escritor.** Nenhum código popula a tabela que o detector lê. E o dado já + está no sistema — verificado em produção, `companies.dados_receita.qsa` traz `nome_socio`, + `cnpj_cpf_do_socio` (máscara `***939098**`), `qualificacao_socio` e o representante legal. + `extrairSocios` (`receita-ficha.ts:35-46`) lê o QSA mas **descarta a máscara**. +2. **O detector só roda em `confirmarImportacao`.** Cadastrar sócio depois não alcança o histórico. + Falta a varredura retroativa equivalente a `aplicarRegrasNoProjeto`. + +**Tamanho:** 795 lançamentos com nome de sócio só na Di Forni (9.320 transações medidas). + +**Cuidado:** empresa cadastrada à mão (Vertímetal) tem QSA sem máscara — degradar para nome +(confiança `media`, vira sugestão), nunca quebrar. + +--- + +## G-07 · Regra de categorização por token contido + +Saído da [E2 §5](pente-fino/02-categorizacao.md). **Depende de G-06.** + +Regra de cliente casa hoje por igualdade exata (`.eq`). Com `match_type` (`'exact' | 'contains'`, +default `'exact'`) e `.ilike('%token%')` nos três pontos de aplicação +(`financial-document-service.ts:721`, `:1340`, `:1952`), **1.808 decisões viram 529** num cliente real +— queda de 71%. + +**Ordem de precedência obrigatória:** regra exata → detecção de parte relacionada → regra por token, +e **o token só se aplica a linhas sem contraparte detectada**. Sem isso, aporte de sócio entra como +receita e contamina margem, ponto de equilíbrio e a meta do Plano de Voo. + +Preview obrigatório antes de salvar ("essa regra vai categorizar 1.157 lançamentos, R$ 134.012, todos +entrada"), no mesmo espírito do `tempo-economizado.ts`, que mostra a fórmula e não só o número. + +--- + +## G-08 · Remover o código de categorização por inferência — 🟡 **metade feita** + +**Decisão do Lucas (17/ago/2026): a categorização é determinística, sem IA.** + +### ✅ Feito em 18/ago — o código + +`ai-categorizer.ts` apagado (210 linhas, zero callers). A cascata que o backlog não tinha mapeado: +matar ele torna morto o `CATEGORY_MAP`, que existia só para alimentá-lo, o que por sua vez mata +`FINANCIAL_SUBCATEGORIES`, que existia só para montar o `CATEGORY_MAP`. +`constants/financial-categories.ts` foi de **123 para 25 linhas** — sobra `FINANCIAL_CATEGORIES`, +com 9 consumidores vivos. Suíte verde, zero erro novo de lint. + +### ⏸️ Adiado por decisão do Lucas (18/ago) — o DROP da tabela + +`learned_rules` ficou **vazia e sem porta**: ninguém escreve, ninguém lê. Dropar é seguro, mas é +**irreversível**, mexe em produção e arrasta cauda — por isso não entrou na mesma leva, e a migration +**não fica em `supabase/migrations/`**: migration não aplicada quebra o oráculo de frescor do +`SNAPSHOT.md` (marca o dump como obsoleto sem ele estar) e um `supabase db push` a aplicaria sem +perguntar. + +O SQL, verificado contra o dump de 18/ago, é este: + +```sql +DROP TABLE IF EXISTS "public"."learned_rules"; +-- DROP TABLE não leva a função de trigger junto — ela é exclusiva desta tabela +-- e viraria órfã no schema. (A tabela tinha DOIS triggers de updated_at fazendo +-- a mesma coisa: um com esta função dedicada, outro com a compartilhada +-- `update_updated_at_column()`, que é usada por dezenas de tabelas e FICA.) +DROP FUNCTION IF EXISTS "public"."update_learned_rules_updated_at"(); +NOTIFY pgrst, 'reload schema'; +``` + +**Por que é seguro** (conferido no dump, não de memória): zero linhas em produção (`seed.sql` traz só +o cabeçalho da seção, nenhum INSERT); nenhuma FK aponta para ela; os 2 triggers e a policy +`learned_rules_membro` caem junto com a tabela. + +**Cauda obrigatória depois de aplicar:** +1. `npx supabase gen types typescript --linked > src/types/supabase.ts` +2. remover `"learned_rules"` do mapa de domínios em `scripts/db/gerar-estrutura.ts:56` +3. re-dump + `npx tsx scripts/db/gerar-estrutura.ts` + +Delta esperado: tabelas 113 → 112 · funções 73 → 72 · policies 353 → 352 · triggers 92 → 90. + +### ⏳ Continua aberto + +A rota órfã `api/ai/htr/suggest` — é o **G-04**, e é decisão (construir o widget ou remover a rota). + +--- + +## G-09 · 🔴 O produto é self-service, e a cadeia quebra no consultor + +**Decisão do Lucas (18/ago/2026): o ATR OS é SaaS de uso próprio. O cliente usa sozinho; o papel de +consultor deixa de existir como etapa obrigatória.** Tudo tem de ser pensado assim. + +Isso não é ajuste de discurso — **quebra a promessa das 7 perguntas**, e o código diz onde. +Levantado contra a RLS da baseline e as server actions: + +| Elo da cadeia | Cliente sozinho consegue? | Evidência | +|---|---|---| +| Subir OFX | ✅ | `pending_ofx_insert` | +| Distribuir conta desconhecida | ✅ | `pending_ofx_cliente_update` / `_delete` via `client_project_access` | +| Categorizar | ✅ | `transactions_cliente_insert` / `_update` | +| Criar regra (inclusive por token) | ✅ | "Usuários autenticados podem gerenciar regras" | +| Fechar o mês | ✅ | `dre_monthly_snapshots` é *service_role only*, gravado por server action | +| **Cadastrar empresa / sócios** | ❌ | `client_socios_insert` exige `owner\|admin\|consultor` | +| **Aprovar o Plano de Voo** | ❌ | `approvePlanoVooV2Action` → `canOperateProject`: *"Apenas consultor/admin podem alterar o Plano de Voo"* (`plano-voo/actions.ts:409`) | +| **Iniciativas (o "o que faço")** | ❌ | `iniciativas-gate` exige `plano_voo_snapshots.status='aprovado'` | + +**As perguntas 1 a 5 funcionam sozinhas. As perguntas 6 e 7 — "pra onde eu vou" e "o que eu faço" — +estão trancadas atrás de um papel que não vai existir.** É o elo final da tese da F3, e ele depende +de um humano que o modelo de negócio acabou de remover. + +**DECIDIDO (Lucas, 18/ago/2026) — spec completa em +[`docs/atros-v3/saas-sem-consultor.md`](../atros-v3/saas-sem-consultor.md):** + +1. **Quem trava a meta é o cliente.** +2. **Quem cadastra empresa e sócios é o cliente**, na contratação. +3. **O consultor não tem poder especial** — é ferramenta externa opcional, ligada só se o cliente + quiser, com acesso igual ao do cliente dentro do projeto. Owner/admin veem todos os projetos. + +Medido: **104 das 348 policies** codificam o bypass antigo, em **47 tabelas**. O mecanismo de vínculo +(`project_members`, com `granted_by`) **já existe** — a execução é repoint, não invenção. Fica para a +próxima sessão. + +**Impacto no que já foi construído (G-06):** o sync de sócios ficou em `CompanyService.criar/atualizar` +justamente por ser zona de consultor+. **Continua correto hoje**, mas num onboarding self-service o +cliente não passa por lá — e o buraco volta. O conserto é a policy, não o choke point. + +**Impacto na régua do pente fino:** a métrica "tempo-consultor" da [E0](pente-fino/00-cliente-e-regua.md) +some. O que ela media agora é pior: **trabalho sem dono**. + +--- + +## G-10 · Débito de lint em `src/features/dre` + +40 problemas (27 erros, 13 warnings) fora dos arquivos da trilha, concentrados em +`services/dreService.ts` (`no-explicit-any`), `utils/calculator.ts` e `utils/periodSelection.ts` +(`no-unused-vars`). **Pré-existentes** — nenhum introduzido pelas levas de partes relacionadas e +regra por token, que fecharam com zero. + +Anotado a pedido do Lucas para faxina posterior. Vale casar com a Fase 2/3 de faxina já registrada em +[[financeiro_faxina_e_roadmap]]. + +--- + +## Fora de escopo desta trilha (registrado para não se perder) + +- **C8 — preço.** "Custar uma fração de um controller" é decisão de posicionamento comercial, não de + código. Entra na conversa de oferta, não no backlog de engenharia. +- **Divergência de sinal TS × SQL na cascata** quando há estorno dentro da categoria + (`dre/cascata.ts:30-38`) — conhecida, coberta por teste, com trilha própria. **Não mexer por aqui.** diff --git a/docs/valor/pente-fino/00-cliente-e-regua.md b/docs/valor/pente-fino/00-cliente-e-regua.md new file mode 100644 index 00000000..c515dcaa --- /dev/null +++ b/docs/valor/pente-fino/00-cliente-e-regua.md @@ -0,0 +1,190 @@ +# E0 — O cliente e a régua + +**Trilha:** pente fino da jornada real · **Data:** 17/ago/2026 +**Objetivo da trilha:** descobrir se o sistema está maduro para a promessa dos +[**3 buracos** e das **7 perguntas**](../frente-3-oferta-de-valor.md), ou o que falta. + +> **Navegação:** [índice da pasta](README.md) · [relatório técnico completo](RELATORIO-TECNICO.md) · etapa anterior/próxima no rodapé. + +--- + +## 1. A cliente + +### Casa Pilar Distribuidora de Materiais de Construção Ltda. + +| | | +|---|---| +| **Nome fantasia** | Casa Pilar | +| **CNAE** | 4679-6/99 — comércio atacadista de materiais de construção em geral | +| **Sede** | Contagem/MG | +| **Fundada** | 2009 | +| **Regime** | Lucro Presumido (saiu do Simples em 2023, ao furar o sublimite de ICMS) | +| **Faturamento** | R$ 520 mil/mês médio · **R$ 6,2 milhões/ano** | +| **Margem líquida** | ~4,5% (**R$ 23,4 mil/mês**) | +| **Funcionários** | 14 | + +**Quem decide:** Marlene Ramalho, 56 anos, sócia-administradora (ela/dela). Fundou com o marido, que +saiu da operação em 2021. Sabe tudo do negócio e nada de número — "o contador cuida disso". + +**Quem opera o financeiro:** Kátia, que mantém uma planilha de entradas e saídas há sete anos. + +**Quem vende:** Rafael Ramalho, 31, filho, sócio minoritário. Acha que o problema é falta de vendedor. + +**Contabilidade:** escritório terceirizado. Entrega guia mensal e balanço anual, em competência, com +~45 dias de atraso. + +### A operação (o que gera os três buracos) + +| Indicador | Valor | O que significa | +|---|---|---| +| Mix de venda | 60% a prazo (construtoras 30/60/90) · 40% balcão | quem manda no prazo é o cliente | +| **PMR** | 48 dias | as construtoras pagam quando querem | +| **PME** | 55 dias | louça e metal giram devagar | +| **PMP** | 25 dias | cimento e aço não dão prazo | +| **Ciclo financeiro** | **78 dias** | | +| **NCG** | **~R$ 1,35 milhão** | **2,6 meses de faturamento presos** | + +### O dinheiro dela + +| Banco / instrumento | Situação | +|---|---| +| **Sicoob** — conta principal | desconto de duplicatas recorrente, ~R$ 180 mil/mês | +| **Itaú** — conta | conta garantida de R$ 250 mil, usada quase todo mês (virou "saldo normal") | +| **Banco do Brasil** — conta | Pronampe de 2021, saldo ~R$ 40 mil, quase quitado | +| **Cielo** — recebíveis de cartão | repasses diários do balcão, com antecipação automática ocasional | +| CDC de caminhão | saldo ~R$ 95 mil | +| Cartão BNDES | usado para compras | + +### O sintoma que a traz + +**Fatura entre R$ 495 e R$ 545 mil/mês há três anos.** Não cai, não sobe. Marlene trabalha mais do +que em 2019 e tira menos. Rafael quer contratar dois vendedores. Ninguém na empresa sabe dizer: + +- quanto o desconto de duplicatas custa por ano (está diluído como "tarifa" no extrato); +- que a conta garantida a 4,18%/mês virou funding permanente; +- que **três construtoras são 41% do faturamento**; +- se o mês passado deu lucro. + +**Ela é o arquétipo 1 (a estagnada) com o buraco 2 (tempo) e o buraco 3 (custo do dinheiro) +simultâneos.** Exatamente o caso que a F3 diz que o sistema resolve. É isso que vamos testar. + +### O que ela traz para o sistema + +> *"Cliente entra hoje, coloca dois anos fechados pra trás e o ano corrente até o momento."* + +| | | +|---|---| +| Período | jan/2024 → ago/2026 = **32 meses** | +| Contas a cobrir | 4 (Sicoob, Itaú, BB, Cielo) | +| **Transações estimadas** | **~20.000** (Sicoob ~380/mês · Itaú ~120 · BB ~40 · Cielo ~90 = ~630/mês × 32) | +| Fontes de apoio | folha (planilha do contador), contas a pagar (planilha da Kátia), estoque (sistema de PDV, exporta CSV) | + +Esse número — **20 mil transações** — é o que vai determinar o custo real do onboarding nas etapas +E1 e E2. Ele é a diferença entre uma promessa de semanas e uma de meses. + +--- + +## 2. A régua + +Cada capacidade testada nas etapas E1–E7 recebe um dos três selos: + +| Selo | Significa | +|---|---| +| ✅ **Pronto** | a cliente faz sozinha, dentro do sistema | +| 🟡 **Assistido** | funciona, mas exige alguém da ATR ou um passo fora do sistema | +| 🔴 **Falta** | não existe | + +> **Correção de premissa (Lucas, 18/ago/2026): o ATR OS é SaaS de uso próprio.** O cliente usa +> sozinho; o consultor não é etapa do fluxo. +> +> Isso **rebaixa todo 🟡 a defeito**. Na primeira versão desta régua eu media "tempo-cliente" e +> "tempo-consultor" lado a lado, tratando o segundo como custo a vigiar. Ele não é custo: é +> **trabalho sem dono**. Onde a etapa depende de consultor, ela simplesmente não acontece. +> +> A medição segue sendo só uma: **tempo-cliente**. E todo 🟡 vira item de backlog, não ressalva. +> +> A varredura contra a RLS está em [G-09](../gaps-backlog.md). **Atualização de 18/ago: o G-09 foi +> EXECUTADO** — 3 migrations em produção. As perguntas 6 e 7 **destravaram**: o dono gera e trava a +> própria meta, e as Iniciativas abrem pelo plano aprovado por ele. Ver +> [`handoff-2026-08-18b`](../../atros-v3/handoff-2026-08-18b-saas-executado-e-vazamento.md). + +### O que conta como "maduro" + +A promessa da F3 é uma cadeia de 7 perguntas em ordem obrigatória. O sistema está maduro se: + +1. **Nenhum elo da cadeia é 🔴** — se um for, a promessa quebra naquele ponto e precisa ser reescrita. +2. **O tempo-cliente até a primeira resposta útil ("qual dos três buracos é o meu") é medível em + dias, não em meses.** +3. **Nenhum elo exige alguém da ATR** — é SaaS: se depende de nós, não escala e, pior, não acontece. +4. **A cliente sabe o que fazer sem alguém traduzindo** — ou seja, o sistema fala a língua dela, não + a nossa. + +### O resultado dos quatro critérios — fechado em 20/ago + +*Esta seção é a linha de chegada da trilha. O caminho até ela está em +[`RELATORIO-TECNICO.md`](RELATORIO-TECNICO.md) §4 e no [E8](08-veredito.md).* + +| # | Critério | Veredito | Onde foi decidido | +|---|---|---|---| +| 1 | Nenhum elo da cadeia é 🔴 | 🔴 **reprova** — 4 dos 7 elos | E3 · E4 · E6 | +| 2 | 1ª resposta útil em dias, não meses | 🔴 **reprova hoje** — por bloqueio, não por lentidão | E3-B | +| 3 | Nenhum elo exige alguém da ATR | 🔴 **reprova** | E6-A | +| 4 | A cliente sabe o que fazer sem tradutor | 🟡 **parcial** | E4 · E5 | + +**Sobre o item 2 — o tempo não é o problema.** A jornada custa **~13 h 40** de entrada e **< 1 h/mês** +depois. Isso é *dias*, e o critério passaria. **Só que a Casa Pilar não chega lá:** ela desconta +~R$ 180 mil/mês em duplicatas, e o gate de fechamento exige o **valor de face**, que o OFX não +credita. Sem responder, **nenhum** dos 32 meses fecha — e sem mês fechado não existe DRE, Giro, +Endividamento, Simulador nem Plano. *(E3-B; decisão de conserto travada em 19/ago.)* + +> **⚠️ CORREÇÃO DE 19/ago — este documento afirmava o contrário.** A versão de 18/ago registrava: +> *"os itens 1 e 3 **passam no código** — nenhum elo depende mais de alguém da ATR."* **A [E6](06-pra-onde-e-o-que-faco.md) +> provou que o item 3 reprova:** travar a meta (`approvePlanoVooV2Action`) só existe dentro de +> `WorkflowControls`, montado numa única página que redireciona quem não é `PRIVILEGED_ROLES`. +> +> **Por que escapou — e é a lição de método da trilha:** a virada SaaS inverteu `canOperateProject` +> (vínculo, não papel) e reescreveu 139 policies. O gate que sobrou é **outro** — `resolveRole` + +> `PRIVILEGED_ROLES` + `redirect`. **Grep de um não acha o outro.** Verificar a mutação não basta: +> é preciso verificar o resultado **pelo caminho do usuário**. + +**Sobre o item 4 — parcial, e agora se sabe onde.** O **Capital de giro** ([E4 §1.1](04-onde-e-porque.md)) +e a **Mesa de Decisão** ([E5 §1.2](05-simulador.md)) provam que a casa sabe escrever para a Marlene: +limiar em português, causa antes do número, o preço junto do ganho. Onde a régua não foi aplicada, o +resultado não é feio — é **errado**: a Curva ABC soma débito cru de 32 meses, o Ponto de Equilíbrio +calcula sobre um mês parcial, e a concentração sai de mil linhas arbitrárias. + +> **Item de 18/ago encerrado:** *"9 textos de tela ainda mandam o cliente falar com um consultor"* — +> **varridos em `2425c6d`.** Eram **21**, não 9: a leitura original grepou frases prontas e não pegou +> a família de **rótulos**, onde estava o pior caso (blocos "Consultor responsável" exibindo +> `fetchConsultor(userId)` — que sob SaaS é **o próprio dono**; o rótulo honesto virou "Gerado por"). +> A regra que ficou: **antes de reescrever um texto que cita um papel, olhe de onde vem o dado que +> ele exibe.** + +### Método de cada etapa + +``` +simular o uso → perguntar ao código → selar ✅/🟡/🔴 → medir o tempo do CLIENTE +``` + +*(A versão original media "tempo-cliente" e "tempo-consultor" lado a lado. A correção de premissa de +18/ago eliminou o segundo: sob SaaS ele não é custo, é **trabalho sem dono**.)* + +Nenhuma afirmação sobre o sistema entra sem `arquivo:linha`. Estimativas de tempo são marcadas como +**estimativa** e trazem a premissa junto — não são medição. + +--- + +## 3. Índice das etapas + +| # | Etapa | Estado | +|---|---|---| +| E0 | O cliente e a régua | ✅ este documento | +| [E1](01-ingestao.md) | A porta: ingestão dos 32 meses | ✅ fechada — ~2 h de cliente | +| [E2](02-categorizacao.md) | A verdade: categorização das ~20 mil transações | ✅ fechada — ~4 a 5 h de cliente | +| [E3](03-fechamento.md) | O fechamento: 32 meses apurados | ✅ fechada — ~5 h de cliente | +| [E4](04-onde-e-porque.md) | ONDE + POR QUÊ (Resultado · Giro · Endividamento · Concentração) | ✅ fechada — ~1 h 35 de cliente | +| [E5](05-simulador.md) | QUAL ALAVANCA (Simulador · Mesa de Decisão) | ✅ fechada — ~35 min de cliente | +| E5b | QUANDO (Caixa · Projeção) | ⏸️ **adiada por decisão (19/ago)** — vai ser redesenhada | +| [E6](06-pra-onde-e-o-que-faco.md) | PRA ONDE + O QUE FAÇO (Plano · Cockpit · Iniciativas · chat × sistema) | ✅ fechada — **a cadeia quebra aqui** | +| [E7](07-cadencia.md) | A cadência de uso (dia · semana · mês · trimestre) | ✅ fechada — R9 passa, **R6 reprova** | +| [E8](08-veredito.md) | Veredito de maturidade + backlog priorizado | ✅ fechada — **não maduro: desconectado, não imaturo** | diff --git a/docs/valor/pente-fino/01-ingestao.md b/docs/valor/pente-fino/01-ingestao.md new file mode 100644 index 00000000..be606fe9 --- /dev/null +++ b/docs/valor/pente-fino/01-ingestao.md @@ -0,0 +1,247 @@ +# E1 — A porta: 32 meses de extrato entram como? + +**Cliente:** [Casa Pilar](00-cliente-e-regua.md) · **Volume:** 32 meses × 4 contas ≈ 20.000 transações +**Pergunta que esta etapa fecha:** a Marlene consegue subir três anos de extrato sozinha, e em quanto tempo? + +> **Navegação:** [índice da pasta](README.md) · [relatório técnico completo](RELATORIO-TECNICO.md) · etapa anterior/próxima no rodapé. + +--- + +## 1. A simulação + +É segunda-feira. Marlene assinou na sexta. Kátia recebeu o acesso e a instrução: *"sobe os extratos +de 2024, 2025 e 2026 até agora."* + +Ela abre o Financeiro → aba **Ingestão**. Vê um painel do mês com quatro indicadores e, no trilho da +direita, um botão: **"Clique para selecionar arquivos OFX — aceita múltiplos em um upload."** + +E aí começa o trabalho de verdade — que **não é no ATR OS**. + +--- + +## 2. O que o código faz + +### 2.1 Upload múltiplo — ✅ **Pronto** + +O input aceita lote de `.ofx` +([`GroupIngestionTab.tsx:156-165`](../../../src/features/dre/components/forense/GroupIngestionTab.tsx#L156-L165)): + +```tsx + +``` + +O serviço recebe o array inteiro — `scanAndEnqueue(clientId, files: File[])` +([`ofx-scan-service.ts:156`](../../../src/lib/ofx-scan-service.ts#L156)). A tela lista os arquivos +selecionados, permite remover um a um, e o botão vira "Analisar e importar (N)". + +**Kátia sobe os 96 arquivos numa tacada.** Não há limite de quantidade no código. + +### 2.2 Roteamento automático por conta — ✅ **Pronto** para a cliente · 🟡 no fecho + +O sistema extrai `BANK_ID + ACCT_ID` de cada OFX e agrupa +([`ofx-scan-service.ts:1-16`](../../../src/lib/ofx-scan-service.ts#L1-L16)): + +- conta **já cadastrada** → importa imediato, direto no projeto da empresa dona; +- conta **desconhecida** → é **enfileirada** em `pending_ofx_distributions` (+ blob no bucket + `ofx-pending`) até o consultor abrir o modal e atribuí-la. + +Isso é o desenho certo e importa muito: **a Kátia não é bloqueada.** Ela sobe tudo, e o que o sistema +não reconhece fica parado esperando alguém — em vez de ser recusado. + +> ⚠️ **CORREÇÃO (18/ago/2026) — este 🟡 não existia.** A leitura original olhou +> `pending_ofx_update`/`_delete` (staff-only) e **não viu as irmãs** +> `pending_ofx_cliente_update`/`_delete`, que já existiam na baseline com acesso por vínculo. +> Policies permissivas **se somam por OR**: basta uma dar caminho. Ler policy isolada engana — use +> `npx tsx scripts/saas/policies-da-tabela.ts ` para ver o conjunto. +> +> E a UI **expõe ao cliente**: `CasaDoGrupoShell` → `IngestaoCasaTab:139` → `GroupIngestionTab`, que +> renderiza o `DistribuirContaPendenteModal` (`:292`) com o botão "Distribuir" (`:376`). O diretório +> `forense/` engana — é pasta de componentes, **não existe rota forense**. +> +> **Selo corrigido: ✅.** A cliente distribui as 4 contas sozinha. O tempo sai do consultor e entra +> no dela: **~10 minutos**, uma vez. + +> ⚠️ **Armadilha que quase entrou neste relatório.** Existe um `ofx-import-service.ts` cujo +> comportamento para conta não-cadastrada é **recusar** com mensagem fixa +> (`OFX_REJECTION_MESSAGE`: *"Entre em contato com seu consultor para cadastrá-la"*). Eu ia reportar +> isso como bloqueio de self-service. **É código órfão** — grep por todos os seus exports +> (`analisarExtratoOFX`, `analisarExtratoLote`, `confirmarImportacaoLote`, `importarLote`, +> `OFX_REJECTION_MESSAGE`) devolve **zero callers**. O fluxo vivo é o `ofx-scan-service`, que +> enfileira em vez de recusar. Ver §5. + +### 2.3 Dedup por FITID — ✅ **Pronto** (e é o que salva o onboarding) + +Num onboarding de 96 arquivos, a Kátia **vai** subir o mesmo período duas vezes. Isso é certo. + +[`fitid-dedup.ts`](../../../src/lib/fitid-dedup.ts) filtra do lote as linhas que violariam os índices +únicos parciais de FITID, mantendo o upsert de 7 colunas intacto. E degrada com elegância: linhas sem +FITID não são filtradas — caem no dedup de 7 colunas + `row_index`, que preserva lançamentos +legítimos idênticos no mesmo dia. Banco com FITID instável **degrada sem erro**. + +Tradução: **re-subir não duplica e não quebra o lote.** Sem isso, um erro no meio de 96 arquivos +custaria o dia inteiro. + +### 2.4 A âncora de saldo sai do próprio extrato — ✅ **Pronto** (achado importante) + +Eu assumi que "confirmar o saldo inicial" — pré-requisito da projeção de caixa (F2, critério C2) — +era digitação manual. **Não é.** + +O `` do OFX vira âncora de saldo automaticamente no confirmar da importação +([`financial-document-service.ts:679-699`](../../../src/lib/financial-document-service.ts#L679-L699)), +resolvendo a conta por `(client + BANK_ID + ACCT_ID)`. O registro é **não-bloqueante**: se falhar, +loga um warn e a importação segue. + +**Consequência para a jornada:** ao terminar o upload, a Casa Pilar já tem 32 meses de âncoras de +saldo nas 4 contas. A projeção de caixa (E5) nasce ancorada, sem ninguém digitar nada. + +### 2.5 Detecção de buraco no histórico — ✅ **Pronto** + +[`missing-months.ts`](../../../src/lib/missing-months.ts) distingue duas coisas que parecem iguais: +"o extrato não chegou" e "o mês não teve movimento". Um mês vazio **cercado** por dado ou âncora +antes e depois é marcado como gap de **alta confiança**. + +Isso é o que impede a Casa Pilar de fechar um DRE com março/2025 faltando e ninguém notar. + +### 2.6 Cobertura das 6 fontes — ✅ **Pronto** + +[`central-dados/cobertura.ts`](../../../src/lib/central-dados/cobertura.ts) mede, por empresa e por +mês, quais das seis fontes esperadas já chegaram: + +`ofx` · `cartao` · `manual` · `folha` · `estoque` · `contas_pagar` + +A aba Ingestão mostra no topo o **Painel do mês** — quatro tiles que respondem *"já fechei o mês?"*: +cobertura %, OFX base, apoio coberto, e o que está aguardando +([`IngestaoCasaTab.tsx:1-16`](../../../src/components/central-dados/IngestaoCasaTab.tsx#L1-L16)). + +A Kátia sabe o que falta sem perguntar. Isso é o R8 (ingestão-guiada) funcionando. + +### 2.7 Cartão e planilhas — 🟡 **Assistido** + +Fatura de cartão e lançamentos manuais têm parsers próprios, em XLSX/CSV +([`credit-card-parser.ts`](../../../src/lib/parsers/credit-card-parser.ts), +[`manual-template-parser.ts`](../../../src/lib/parsers/manual-template-parser.ts)) — **schema +rígido**: `data | descricao | valor | tipo | forma_pagamento | observacao`. + +Funciona, mas exige que alguém monte a planilha no formato. Para a Casa Pilar, o extrato da Cielo e a +folha entram por aqui. **Tempo-cliente: ~1h por fonte, uma vez**, para acertar o formato. + +### 2.8 Integração bancária — 🔴 **Falta** + +Não existe Open Finance, nem conexão direta com banco. **E é aqui que mora o custo real do +onboarding.** + +--- + +## 3. O custo real: onde a hora é gasta + +**Estimativa**, com as premissas declaradas — não é medição. + +| Passo | Onde acontece | Tempo-cliente | Tempo-consultor | +|---|---|---|---| +| Gerar 32 meses de OFX em 3 internet bankings | fora do ATR OS | **20 a 30 min** | — | +| Subir os arquivos (1–3 lotes) | ATR OS | ~15 min | — | +| Distribuir as 4 contas novas da fila | ATR OS | — | ~10 min | +| Conferir cobertura e re-subir o que faltou | ATR OS | ~30 min | — | +| Montar e subir cartão/folha/contas a pagar | planilha + ATR OS | ~1 h | — | +| **Total E1** | | **~2 horas** | **~10 min** | + +> **Correção (Lucas, 17/ago/2026).** Minha primeira estimativa era de 3 a 6 horas para gerar os OFX, +> assumindo ~96 exportações mensais. **Está errado** — na prática, o internet banking exporta o +> período em poucas seleções e a coleta leva **20 a 30 minutos**. A estimativa de cartão/folha/contas +> a pagar segue não-verificada e pode ser menor. + +### A leitura que importa + +**A E1 não é gargalo.** Duas horas de cliente, dez minutos de consultor, e 32 meses de histórico +estão dentro do sistema com âncora de saldo, dedup e cobertura mapeada. + +Isso desloca o risco da jornada inteira: + +1. **A coleta não é o problema.** Open Finance seria conveniência, não destravamento — cai de risco + estratégico para melhoria futura. +2. **Todo o peso do onboarding está na E2.** Os 20 mil lançamentos entraram, mas entraram como + ruído. Se transformá-los em verdade custar caro, é ali — e só ali — que o cliente desiste antes de + ver valor. **A E2 é a etapa que decide a promessa.** + +--- + +## 4. Veredito da E1 + +| Capacidade | Selo | +|---|---| +| Upload múltiplo de OFX | ✅ | +| Roteamento automático por conta bancária | ✅ | +| Fila para conta desconhecida (não bloqueia o cliente) | ✅ | +| Distribuição da conta nova | ✅ *(era 🟡 por erro de leitura da RLS — ver §2.2)* | +| Dedup / re-upload seguro | ✅ | +| Âncora de saldo automática do `` | ✅ | +| Detecção de mês faltante com alta confiança | ✅ | +| Cobertura visível das 6 fontes | ✅ | +| Cartão / folha / contas a pagar | 🟡 planilha em schema rígido | +| Integração bancária (Open Finance) | 🔴 | + +**A porta está madura, e é barata.** Nenhum elo é 🔴 dentro do sistema; o único 🔴 é uma +conveniência que fica *antes* dele e que a prática mostrou não ser gargalo. + +**Tempo até aqui:** **~2 horas de cliente** (os ~10 min de distribuição de conta entram aqui, não +num consultor) — uma tarde da Kátia. + +**Mas nenhuma pergunta foi respondida ainda.** A Marlene ainda não sabe nada. Os 20 mil lançamentos +entraram como ruído — e é a E2 que decide se viram verdade em dias ou em meses. + +--- + +## 5. Achados para o backlog + +| # | Achado | Gravidade | +|---|---|---| +| **E1-A** | `ofx-import-service.ts` é **código órfão completo** — zero callers para todos os exports. Duplica o fluxo vivo (`ofx-scan-service`) com semântica **diferente e pior** (recusa em vez de enfileirar). Quem ler primeiro esse arquivo entende o produto errado — como eu quase entendi. | 🟠 remover ou marcar como morto | +| **E1-B** | Sem Open Finance a coleta é manual, mas leva 20–30 min. **Conveniência, não bloqueio** — rebaixado de estratégico após correção do Lucas. | 🔵 futuro | +| **E1-C** | Um **guia de exportação por banco** dentro da aba Ingestão (onde fica o OFX no Sicoob, Itaú, BB, Cielo) ainda vale: corta dúvida no primeiro contato e evita o cliente subir período errado. | 🟢 barato | +| **E1-D** | ~~A distribuição da conta nova exige consultor.~~ **RETIRADO (18/ago).** O 🟡 nunca existiu: a leitura original olhou `pending_ofx_update`/`_delete` (staff) e não viu as irmãs `pending_ofx_cliente_*`, que já estavam na baseline — policies se somam por OR. A cliente distribui sozinha (§ do selo corrigido, acima). | ✅ fechado | + +Registrados também em [`../gaps-backlog.md`](../gaps-backlog.md) quando virarem execução. + +--- + +**Próxima etapa:** [E2 — a categorização das ~20 mil transações](02-categorizacao.md). É onde eu +espero achar o gargalo de verdade. + + +--- + +## Anexo — evidência verificada + +Tudo abaixo foi lido no código vivo durante a varredura. Serve para reauditar sem refazer o caminho. + +### Fluxo vivo de ingestão + +| O que | Onde | O que prova | +|---|---|---| +| Upload múltiplo de `.ofx` | `features/dre/components/forense/GroupIngestionTab.tsx:156-165` | ``; sem limite de quantidade | +| Assinatura em lote | `lib/ofx-scan-service.ts:156` | `scanAndEnqueue(clientId, files: File[])` | +| Roteamento por `BANK_ID + ACCT_ID` | `lib/ofx-scan-service.ts:1-16` | conta conhecida importa; desconhecida **enfileira** | +| Dedup por FITID | `lib/fitid-dedup.ts` | filtra o que violaria os índices únicos parciais; sem FITID cai no dedup de 7 colunas + `row_index` | +| Âncora de saldo automática | `lib/financial-document-service.ts:679-699` | `` vira âncora, resolvida por `(client + BANK_ID + ACCT_ID)`; **não-bloqueante** | +| Buraco no histórico | `lib/missing-months.ts` | separa "extrato não chegou" de "mês sem movimento"; alta confiança quando cercado por dado | +| Cobertura das 6 fontes | `lib/central-dados/cobertura.ts` · `components/central-dados/IngestaoCasaTab.tsx:1-16` | `ofx · cartao · manual · folha · estoque · contas_pagar` | + +### O caminho da conta pendente (correção de 18/ago) + +| O que | Onde | +|---|---| +| Cadeia de render | `CasaDoGrupoShell` → `IngestaoCasaTab:139` → `GroupIngestionTab` | +| Modal e botão | `DistribuirContaPendenteModal:292` · botão "Distribuir" em `:376` | +| Policies que dão o caminho ao cliente | `pending_ofx_cliente_update` / `_delete` · `bank_accounts_cliente_insert`, todas via `project_access` | +| Ferramenta de conferência | `npx tsx scripts/saas/policies-da-tabela.ts ` | + +> **Regra que fica:** policies permissivas **se somam por OR**. Ler uma isolada engana — foi +> exatamente o que produziu o 🟡 falso desta etapa. + +### Parsers de apoio, e o órfão + +| O que | Onde | Estado | +|---|---|---| +| Fatura de cartão | `lib/parsers/credit-card-parser.ts` | schema rígido | +| Lançamentos manuais | `lib/parsers/manual-template-parser.ts` | `data · descricao · valor · tipo · forma_pagamento · observacao` | +| **Órfão** | `lib/ofx-import-service.ts` | zero callers em `analisarExtratoOFX`, `analisarExtratoLote`, `confirmarImportacaoLote`, `importarLote`, `OFX_REJECTION_MESSAGE` — **e recusa onde o fluxo vivo enfileira** | diff --git a/docs/valor/pente-fino/02-categorizacao.md b/docs/valor/pente-fino/02-categorizacao.md new file mode 100644 index 00000000..90cf30a1 --- /dev/null +++ b/docs/valor/pente-fino/02-categorizacao.md @@ -0,0 +1,552 @@ +# E2 — A verdade: 20.000 lançamentos viram DRE em quanto tempo? + +**Cliente:** [Casa Pilar](00-cliente-e-regua.md) · **Entrada:** ~20.000 transações, 32 meses, 4 contas +**Pergunta que esta etapa fecha:** quanto o sistema faz sozinho, quanto sobra pra Kátia, e quantas horas isso custa? + +> Esta é a etapa que decide a promessa. A [E1](01-ingestao.md) custou ~2 horas e não respondeu nada. +> Se transformar ruído em verdade custar semanas, a oferta de "diagnóstico em dias" cai aqui. + +> **Navegação:** [índice da pasta](README.md) · [relatório técnico completo](RELATORIO-TECNICO.md) · etapa anterior/próxima no rodapé. + +--- + +## 1. O mecanismo, verificado + +### 1.1 A peça que torna tudo possível: o tratamento da descrição + +`tratarLancamento` ([`extrato-processor.ts:102-118`](../../../src/lib/extrato-processor.ts#L102-L118)) +normaliza cada descrição do extrato — e a linha decisiva é esta: + +```ts +.replace(/[0-9]/g, "") // Remove TODOS os números +.replace(/[^\w\s]|_/g, " ") // Remove TODA a pontuação +``` + +Some tudo que varia entre duas ocorrências do mesmo evento: data, número de documento, NSU, CNPJ, +parcela. Sobra o que é estável — **o nome da contraparte e o tipo da operação**. + +``` +"PIX ENVIADO 12/03 CIMENTOS MAURO LTDA 45.678.901/0001-23" ┐ +"PIX ENVIADO 08/04 CIMENTOS MAURO LTDA 45.678.901/0001-23" ├─→ "PIX ENVIADO CIMENTOS MAURO LTDA" +"PIX ENVIADO 22/05 CIMENTOS MAURO LTDA 45.678.901/0001-23" ┘ +``` + +**Três linhas do extrato colapsam em uma decisão.** É esta função que transforma 20.000 transações +em algumas centenas de escolhas. Tudo na E2 depende dela. + +### 1.2 A tela trabalha em grupo, o gate conta em linha + +A RPC `get_lancamentos_unicos` agrega por `(lancamento_tratado, sentido)` — entrada e saída com a +mesma descrição são grupos independentes. Cada grupo chega com `ocorrencias`, `total_credito`, +`total_debito` e `pendentes` +([`types/index.ts:93-102`](../../../src/features/dre/types/index.ts#L93-L102)). + +E há um detalhe de rigor que vale registrar: **o progresso é contado em LINHA, não em grupo** +([`useDreController.ts:188-215`](../../../src/features/dre/hooks/useDreController.ts#L188-L215)). O +comentário explica o porquê — antes, um grupo de 538 lançamentos pesava o mesmo que um de 1, e uma +única linha sem categoria dentro de um grupo "categorizado" destravava o botão *Apurar resultado* com +o mês incompleto. Era bug de **gate**, não de barrinha. + +### 1.3 Uma decisão da Kátia toca os 32 meses + +Quando ela categoriza um grupo, `atualizarCategoriaEmLote` +([`financial-document-service.ts:1313-1345`](../../../src/lib/financial-document-service.ts#L1313-L1345)) +dá um UPDATE em **todas** as transações do projeto com aquele `lancamento_tratado` e aquele sentido — +não só as do mês na tela. Os 32 meses são atingidos de uma vez. + +E a variante `atualizarCategoriaEmLoteComRegra` ([linha 2033](../../../src/lib/financial-document-service.ts#L2033)) +faz o mesmo **e persiste uma regra** em `categorization_rules`, chaveada por +`(project_id, pattern, direction)`. + +### 1.4 A regra nasce durável — deriva da subcategoria, não da tela + +Ao salvar, categoria e nomes são **derivados da subcategoria**, nunca copiados do que estava na tela +([linhas 1699-1712](../../../src/lib/financial-document-service.ts#L1699-L1712)). O comentário diz o +motivo: sem isso *"a regra nasce com o par que estava na tela no dia e envelhece sozinha quando o +catálogo move a sub de categoria"*. + +Melhor ainda: a normalização roda **na leitura** +([`normalizarRegraPeloCatalogo`, linha 1771](../../../src/lib/financial-document-service.ts#L1771)) — +todo leitor de regra passa por `buscarRegrasCategorizacao`, então uma regra torta já gravada **para +de sujar** a importação seguinte mesmo antes de qualquer reparo no banco. É o padrão +"normalizar no choke point de leitura", aplicado certo. + +### 1.5 A varredura retroativa em duas fases + +`aplicarRegrasNoProjeto` ([linha 1894](../../../src/lib/financial-document-service.ts#L1894)) roda +quando a Kátia conclui a categorização de um mês: + +- **Fase 1** — aplica **todas** as regras do projeto em **todas** as transações com + `categoria_id IS NULL`, em todos os anos. Respeita o sentido (crédito/débito) e nunca toca exceção + manual (`manually_reviewed = true`). +- **Fase 2** — resolve "pendentes-fantasma" propagando pares (categoria, subcategoria) **unânimes** + das linhas irmãs já completas. + +E devolve um relatório (`VarreduraResult`) com o que mudou por mês e **quais meses ficaram 100%**. + +**Tradução para a jornada:** ela categoriza janeiro/2024 e, ao concluir, tudo que ensinou se espalha +por 2024, 2025 e 2026 sozinho. O segundo mês é muito mais barato que o primeiro. O décimo, quase de +graça. + +### 1.6 O que o sistema já pega sozinho na importação + +**49 regras regex genéricas** hardcoded em `REGRAS_CATEGORIZACAO` +([`categorization-rules.ts:415+`](../../../src/lib/categorization-rules.ts#L415)), sob uma filosofia +declarada no próprio arquivo: + +> *"padrões GENÉRICOS baseados em CONSTANTES. NÃO incluir nomes de adquirentes (GETNET/CIELO), +> empresas, pessoas. Qualidade > Quantidade: melhor não categorizar do que errar."* + +Pegam tarifa, IOF, imposto, TED/PIX genérico, depósito, folha — o vocabulário do banco, não o da +empresa. Deliberadamente conservador. + +### 1.7 O sistema mede a própria automação — e não infla + +[`varredura/tempo-economizado.ts`](../../../src/lib/varredura/tempo-economizado.ts) calcula o tempo +poupado a partir do que foi **propagado automaticamente** (não do total categorizado), com base +conservadora **capada em 20s por lançamento** (usa 15s), e **exibe a fórmula na tela**, não só o +resultado: `"92 lançamentos × ~15s"`. + +Isso é honestidade de produto rara. E, para esta análise, é ouro: **o próprio sistema declara a +premissa de tempo por decisão.** Vou usar a dele. + +--- + +## 2. A conta — medida no banco de produção, não estimada + +> **Correção.** Minha primeira versão desta seção era um modelo: estimei ~700 a 1.200 grupos únicos +> para 20.000 transações e cheguei em ~4h40. **Rodei a medição no banco real +> (`scripts/diag-grupos-por-transacao.ts`, READ-ONLY) e o modelo estava errado por um fator de ~4.** +> O que segue é o dado. + +### 2.1 O que os clientes reais mostram + +Todos os 18.097 lançamentos do banco de produção, agrupados pela mesma chave que a tela usa +(`lancamento_tratado` + sentido): + +| Projeto | Meses | Linhas | Grupos | **Linhas/grupo** | Grupos p/ 80% das linhas | Grupos com 1 ocorrência | +|---|---:|---:|---:|---:|---:|---:| +| PRUDENTE & SILVA | 16 | 8.182 | 1.808 | **4,5** | 311 | 1.358 (**75%**) | +| Plano de Voo Completo | 12 | 5.859 | 976 | **6,0** | 104 | 685 (70%) | +| **Vertímetal** | 31 | 2.918 | **98** | **29,8** | 52 | 7 (**7%**) | +| DI FORNI PIZZARIA | 16 | 1.138 | 193 | **5,9** | 35 | 111 (58%) | + +**A variação é de 6,6× entre o melhor e o pior caso.** E ela não é aleatória. + +### 2.2 O que explica a diferença: o modelo de negócio, não o volume + +Investiguei a cauda do pior caso (`scripts/diag-cauda-normalizacao.ts`) para separar duas causas com +remédios opostos: **(A)** cardinalidade real vs **(B)** normalização incompleta — sobrou token +variável não-numérico que escapou do `.replace(/[0-9]/g,"")`. + +O teste re-agrupa os singletons por prefixo. Se muitos colapsam, é (B). + +``` +palavras de prefixo grupos resultantes redução + 1 663 51% + 2 1228 10% + 3 1337 2% +``` + +**Só 10% colapsam em 2 palavras. É causa (A) — cardinalidade real.** Os exemplos confirmam: a cauda +são PIX de pessoas físicas distintas, cada nome próprio um grupo: + +``` +34× "PIX RECEBIDO" → PIX RECEBIDO L··· F···· R···· + 6× "ANA PAULA" → ANA PAULA R···· L···· P···· PIX M···· + 6× "PEDRO HENRIQUE" → PEDRO HENRIQUE V··· DOS S···· PIX M···· +``` + +**Não há bug de normalização a consertar.** `tratarLancamento` está fazendo o trabalho dele; o que +varia é quem paga. + +> **A conclusão que vale para o produto inteiro:** o custo da categorização é governado pelo +> **modelo de negócio do cliente**, não pelo volume de transações. +> +> - **B2B com contraparte recorrente** (Vertímetal, indústria) → ~30 linhas/grupo → **barato**. +> - **B2C com PIX nominal de pessoa física** (Prudente & Silva) → ~4,5 linhas/grupo → **caro**. +> +> Dá para estimar o custo de onboarding de um cliente **antes de vender**, olhando como ele recebe. + +### 2.3 A cauda: 83% das decisões para 17% das linhas + +Ainda em PRUDENTE & SILVA, o dado mais acionável da etapa: + +| | Decisões | Linhas cobertas | +|---|---:|---:| +| Núcleo (Pareto 80%) | **311** | 6.546 (80%) | +| Cauda | **1.497** | 1.636 (20%) | + +**311 decisões resolvem 80% das linhas. As 1.497 seguintes resolvem os 20% restantes.** +Os 1.358 singletons sozinhos são 75% das decisões e **16,6% das linhas**. + +E o mês só fecha com pendentes = 0. Ou seja: **a parte mais cara é obrigatória e é a que menos +informa.** + +### 2.4 A cauda tem UM nome — e ele resolve 85% dela + +A pergunta seguinte era: se a cauda é cardinalidade real, ela é irredutível? + +**Não é.** Testei três formas de casar a cauda (`scripts/diag-materialidade-cauda.ts` e +`scripts/diag-tokens-cauda.ts`): + +| Forma de casar | Cobertura da cauda em PRUDENTE & SILVA | +|---|---| +| Prefixo (2 palavras) | **4%** — o nome da pessoa vem primeiro, então o prefixo é inútil | +| Sufixo (1 palavra) | 85% | +| **Token contido (qualquer posição)** | **85% com UM token** | + +O token é **`MAQUININHA`**, com **100% de pureza de sentido** (todas as ocorrências são entrada): + +``` +1.157 grupos · R$ 134.012 · pureza 100% · "MAQUININHA" +``` + +As descrições são `NOME DA PESSOA … PIX MAQUININHA` — cobranças recebidas pela maquininha, de 1.157 +clientes diferentes. **Todas são a mesma coisa: venda no balcão.** São 1.157 decisões idênticas que +deveriam ser uma. + +**E generaliza.** Rodei em outro cliente: + +| Cliente | Tokens 100% puros necessários | Cauda coberta | +|---|---|---| +| PRUDENTE & SILVA | 1 — `MAQUININHA` | 1.157 de 1.358 (**85%**) | +| Plano de Voo Completo | 3 — `REM`, `DINAMIC`, `APARECIDA` | 621 de 685 (**91%**) | + +O padrão é estrutural, não anedótico: **a cauda é sempre "recebimentos de muita gente diferente pelo +mesmo canal", e o nome do canal é um token estável na descrição.** + +**O gargalo da E2 não é a cardinalidade do negócio. É o motor de regra não saber dizer "contém".** + +### 2.5 A Casa Pilar, recalculada + +Casa Pilar é atacado B2B (construtoras a prazo, recorrentes) com 40% de balcão. O balcão via repasse +de adquirente gera poucos grupos; via PIX direto do cliente, gera milhares. Os dois cenários: + +| Cenário | Linhas/grupo | Decisões p/ 20.000 linhas | Tempo (80%×15s + 20%×60s) | +|---|---:|---:|---:| +| Balcão via adquirente (perfil Vertímetal) | ~30 | ~670 | **~4 h 30** | +| Perfil misto realista | ~10 | ~2.000 | **~13 h** | +| Balcão em PIX nominal (perfil Prudente) | ~4,5 | ~4.400 | **~29 h** | + +**A faixa de 3–6 h que o Lucas relata de campo corresponde ao cenário B2B.** Ela é real — e é o +piso, não a média, quando o cliente recebe de pessoa física. + +**Mas isso é o custo de HOJE.** Com regra por token contido (§2.4 e §5), o mesmo cliente cai para: + +| Cenário | Decisões hoje | Com regra por token | Tempo | +|---|---:|---:|---:| +| Balcão via adquirente | ~670 | ~670 (já é barato) | ~4 h 30 | +| Misto | ~2.000 | **~700** | **~4 h 40** | +| Balcão em PIX nominal | ~4.400 | **~1.290** | **~8 h 40** | + +Medido em PRUDENTE & SILVA: 1.808 grupos → **529 decisões** com duas regras de token +(`MAQUININHA` + `TRANSFERENCIA`). **Queda de 71%.** + +--- + +## 3. Veredito da E2 + +| Capacidade | Selo | +|---|---| +| Colapso de descrição (remove dígitos/pontuação) | ✅ | +| Agrupamento por lançamento único + sentido | ✅ | +| Progresso e gate contados em linha, não em grupo | ✅ | +| Uma decisão toca todos os meses | ✅ | +| Regra persistida por projeto, durável (deriva da sub) | ✅ | +| Normalização de regra na leitura | ✅ | +| Varredura retroativa em 2 fases | ✅ | +| 49 regras genéricas na importação | ✅ | +| Medição honesta da automação (com fórmula na tela) | ✅ | +| **Regra do cliente por PADRÃO (não só igualdade exata)** | 🔴 **falta** | +| Re-propagar correção sobre meses já preenchidos | 🟡 limitação declarada | +| Caminho para a cauda imaterial | 🔴 falta | +| **Sócio identificado antes de o cliente categorizar** | 🔴 **desligado** — ver §6 | + +**O motor está maduro; a economia não fecha para todo perfil de cliente.** + +O mecanismo é bom e o trabalho cresce com o número de *contrapartes*, não de *transações* — o que +faz dobrar o período quase não dobrar o esforço. Mas o número de contrapartes é uma característica do +**cliente**, não do sistema, e varia 6,6× entre os clientes reais medidos. + +| | E1 | E2 | Total até aqui | +|---|---:|---:|---:| +| Cliente B2B (perfil Vertímetal) | ~2 h | ~4 h 30 | **~7 h** | +| Cliente misto | ~2 h | ~13 h | **~15 h** | +| Cliente B2C com PIX nominal | ~2 h | ~29 h | **~31 h** | + +**Sete horas é uma semana em janelas. Trinta e uma horas é um mês** — e é o ponto de desistência. + +**E a Marlene ainda não tem resposta nenhuma.** Falta fechar: [E3](03-fechamento.md). + +--- + +## 4. Achados para o backlog + +| # | Achado | Gravidade | +|---|---|---| +| **E2-A** | ~~Código morto a remover~~ — **FEITO em `66771e8`** *(chore(faxina): G-05 e G-08, −308 linhas)*. `ai-categorizer.ts` tinha zero callers (`categorizeWithAI`, `findLearnedRule`, `saveLearnedRule`) e usava `FINANCIAL_CATEGORIES`, catálogo **antigo**, não o v3 vivo de 11×56. **Verificado em 20/ago: o arquivo não existe mais.** Base da decisão: *a categorização é determinística, sem IA* (Lucas, 17/ago). **Sobra:** o DROP de `learned_rules` — tabela ainda no banco, sem nenhum leitor vivo, adiado por decisão (SQL parqueado no G-08). | ✅ fechado (DROP pendente) | +| **E2-B** | **A assimetria que explica a cauda.** As 49 regras da casa são **regex** (`pattern: /DEP.*DINHEIRO/i`). As regras que o **cliente** cria são **igualdade exata** — `.eq("lancamento_tratado", regra.pattern)`, tanto na importação ([linha 721](../../../src/lib/financial-document-service.ts#L721)) quanto na varredura ([linha 1952](../../../src/lib/financial-document-service.ts#L1952)). O sistema sabe casar por padrão, mas só para si mesmo. Medido: 34 singletons começam com `PIX RECEBIDO` e todos são a mesma coisa (venda). **Uma regra por prefixo/token resolveria centenas de decisões numa.** É o maior ganho de tempo disponível na etapa. | 🔴 **maior alavanca** | +| **E2-F** | Depois da regra por token sobra a cauda genuinamente irrepetível (~15% em PRUDENTE, ~9% em Plano de Voo). Ela é decisão caso a caso e continua manual — **por decisão de produto, não por falta de alternativa**. O caminho para encurtá-la é o E2-E (resto imaterial), não automação por inferência. | 🔵 aceito | +| **E2-G** | **Custo de onboarding é previsível antes da venda.** Linhas/grupo varia 6,6× entre clientes reais e é função de *como o cliente recebe* (B2B recorrente vs PIX de pessoa física). Dá para rodar `diag-grupos-por-transacao.ts` no primeiro extrato e estimar o esforço — vira input de precificação e de expectativa, em vez de surpresa no dia 10. | 🟢 barato, comercial | +| **E2-C** | Fase 1 da varredura só toca `categoria_id IS NULL` (limitação declarada no código). **Corrigir um pattern depois NÃO re-propaga** sobre meses que já o tinham preenchido. No onboarding isso morde: a Kátia aprende o catálogo *enquanto* categoriza e vai querer corrigir as decisões do primeiro mês. | 🟠 UX de correção | +| **E2-D** | ~~Guarda de NULL inconsistente~~ — **MEDIDO E REBAIXADO.** `atualizarCategoriaEmLote` filtra `.eq("manually_reviewed", false)` enquanto a varredura usa `.or(is.null, is.false)`. Rodei `scripts/diag-manually-reviewed-null.ts`: **0 linhas NULL** em 18.097 (18.072 false, 25 true). **Não pula nada hoje.** Vira dívida latente — a coluna é `DEFAULT false` sem `NOT NULL`, então volta a morder se alguma inserção futura gravar NULL explícito. Correção de uma linha, prioridade baixa. | 🔵 latente | +| **E2-E** | **MEDIDO.** O mês só fecha com pendentes = 0, e a cauda é desproporcional: em PRUDENTE & SILVA, **311 decisões cobrem 80% das linhas e 1.497 cobrem os 20% restantes**; os 1.358 singletons são 75% das decisões e 16,6% das linhas. **A parte mais cara é obrigatória e é a que menos informa.** Vale desenhar um caminho de "resto imaterial" (agrupar a cauda abaixo de um limiar de valor numa decisão só) sem furar a régua do gate. | 🟠 decisão de produto | + +--- + +## 5. A solução: regra por token contido + +O achado E2-B, agora com desenho. É a maior alavanca de tempo de toda a jornada e cabe numa +migration de uma coluna. + +### 5.1 O que muda + +**1 · Schema.** `categorization_rules` ganha: + +```sql +match_type text NOT NULL DEFAULT 'exact' + CHECK (match_type IN ('exact','contains')) +``` + +`DEFAULT 'exact'` torna a mudança retrocompatível por construção: **toda regra existente continua +casando por igualdade, byte a byte.** + +**2 · Os três pontos de aplicação ramificam.** Hoje todos fazem `.eq("lancamento_tratado", …)`: + +| Onde | Linha | +|---|---| +| auto-categorização da importação | [`financial-document-service.ts:721`](../../../src/lib/financial-document-service.ts#L721) | +| varredura retroativa | [`:1952`](../../../src/lib/financial-document-service.ts#L1952) | +| lote da tela | [`:1340`](../../../src/lib/financial-document-service.ts#L1340) | + +Passam a usar `.ilike("%" + pattern + "%")` quando `match_type = 'contains'`. O PostgREST suporta +`ilike` nativamente — não precisa de RPC nova. + +**3 · Precedência sai de graça.** A regra exata é mais específica e tem de ganhar. Isso já é +garantido pela arquitetura atual: a varredura **só toca linhas com `categoria_id IS NULL`**. Basta +ordenar `exact` antes de `contains` no laço, e a regra ampla nunca sobrescreve a decisão específica. + +**4 · Guarda de pureza + preview obrigatório.** Antes de gravar, o sistema mede o alcance da regra e +mostra: + +> *"Essa regra vai categorizar **1.157 lançamentos**, **R$ 134.012**, **todos entrada**."* + +Se a pureza de sentido for < 100%, avisa antes de deixar salvar. É o mesmo estilo do +`tempo-economizado.ts`, que exibe a fórmula e não só o resultado — o dono confere e confia. + +### 5.2 O passo além: o sistema sugere a regra sozinho + +Todo o dado para isso já existe, e o `scripts/diag-tokens-cauda.ts` é o protótipo do algoritmo: +varrer os pendentes, achar tokens de alta frequência e pureza de sentido 100%, propor. + +> *"1.157 lançamentos sem categoria contêm **MAQUININHA** e são todos entrada. +> Categorizar todos como **venda no balcão**?"* + +Isso é R6 (HTR-proativo) e R8 (ingestão-guiada) na etapa em que o cliente mais precisa. Transforma o +pior momento da jornada — 1.358 cliques idênticos — em uma pergunta. + +### 5.3 Determinismo, por decisão + +**Decisão do Lucas (17/ago/2026): a categorização não usa inferência automática.** Regra escrita, +resultado previsível, auditável pelo dono. + +E o dado desta etapa mostra que isso não custa nada: a cauda **não é ambígua** — são 1.157 +lançamentos idênticos em natureza, entrando pelo mesmo canal. Uma regra `ilike` resolve 85% deles com +100% de acerto por construção. Não há problema de julgamento a delegar; há um problema de expressão +de regra, e ele se resolve com uma coluna. + +O que sobra depois (~15%) é decisão caso a caso e **continua manual, de propósito** — o caminho para +encurtá-la é o resto imaterial (E2-E), não inferência. + +### 5.4 Risco e mitigação + +| Risco | Mitigação | +|---|---| +| Token amplo demais pega o que não devia (PIX de sócio, intercompany, estorno) | A regra carrega `direction` (já existe) + só preenche `categoria_id IS NULL` + preview com contagem e valor antes de confirmar | +| Cliente cria regra ruim e suja o histórico | Mesma reversibilidade das regras atuais; e a normalização na leitura (`normalizarRegraPeloCatalogo`) já torna regra torta inofensiva na importação seguinte | +| `ilike '%x%'` não usa índice | 18 mil linhas hoje; mesmo a 20 mil por cliente é irrelevante. Se virar problema, `pg_trgm` resolve | + +--- + +## 6. O sócio na cauda — pré-requisito da §5, não assunto à parte + +### 6.1 Por que isto tem de vir junto + +A cauda de PRUDENTE & SILVA são 1.358 pessoas físicas mandando dinheiro para a conta PJ. **Nem todas +são clientes.** Algumas são o sócio. + +Uma regra `contains 'MAQUININHA'` é segura — maquininha é canal, não pessoa. Mas a tentação seguinte +é uma regra ampla em entrada PIX, e **essa varreria o aporte do sócio para dentro da receita**. +Consequência em cascata: receita inflada → margem inflada → ponto de equilíbrio errado → meta do +Plano de Voo calibrada em cima de dinheiro que não é venda. + +Por isso a §5 não pode ir sozinha. **Identificar o sócio é pré-condição da regra ampla.** + +### 6.2 O tamanho do problema, medido + +O detector documenta a medição feita nos dois CNPJs da Di Forni (9.320 transações) +([`detector.ts:10-20`](../../../src/lib/partes-relacionadas/detector.ts#L10-L20)): + +| O que apareceu no descritor | Ocorrências | +|---|---:| +| **Nome completo de sócio** | **795** | +| CNPJ de empresa irmã | 80 | +| Só o nome da irmã (sem CNPJ) | 113 | +| CPF formatado (8 casaram com máscara de sócio) | 42 | +| CNPJ da própria empresa (é L95, não parte relacionada) | 57 | + +**795 lançamentos com nome de sócio em um cliente só.** Hoje eles entram no bolo geral da +categorização parecendo o PIX de qualquer pessoa. + +### 6.3 O que já existe — e é bom + +| Peça | Estado | +|---|---| +| Detector puro que responde "com quem foi essa transação?" | ✅ [`detector.ts`](../../../src/lib/partes-relacionadas/detector.ts) | +| Lê `lancamento_original`, **não** o tratado — porque o tratado apaga todos os dígitos e mata CNPJ/CPF | ✅ | +| Documento ganha do nome quando discordam (o extrato real traz `PIX ENVIADO DI FORNI … 53.708.623/0001-80`, e o CNPJ é da Prudente) | ✅ | +| Três tipos: `propria` (L95), `empresa_grupo`, `socio` | ✅ | +| Dois níveis: `alta` (documento bateu → age sozinho) e `media` (só nome → **vira sugestão, o dono confirma**) | ✅ | +| Portas para empresa do grupo: operação · movimentação · **reembolso** (abate a despesa, não vira receita) | ✅ [`portas.ts`](../../../src/lib/partes-relacionadas/portas.ts) | +| Portas para sócio: 2 portas + natureza — aporte, mútuo, devolução, distribuição, pró-labore | ✅ `gate-secoes.ts` | +| Catálogo com as subcategorias certas (`socio_aporte`, `socio_distribuicao`, `socio_emprestimo_entrada`, `socio_emprestimo_devolucao`, `mov_inter_receita`, `mov_inter_mutuo`) | ✅ | + +**A inteligência está construída.** O problema é que ela está desligada da tomada. + +### 6.4 Os dois elos que faltam + +**Elo 1 — `client_socios` não tem quem escreva nela.** + +O detector lê `client_socios (nome, cpf_mascara)` +([`cadastro.ts:43`](../../../src/lib/partes-relacionadas/cadastro.ts#L43)). Grep em `src/` e +`scripts/`: **nenhum código escreve nessa tabela.** Só leitura. + +E o dado já está no sistema. Consultei `companies.dados_receita` em produção: + +``` +DI FORNI PIZZARIA → cnpj_cpf_do_socio = ***939098** +PRUDENTE & SILVA → cnpj_cpf_do_socio = ***861048** +DROGARIA AGUIAR JAU → cnpj_cpf_do_socio = ***300568** +``` + +O QSA da BrasilAPI grava, por sócio: `nome_socio`, `cnpj_cpf_do_socio` (máscara), `qualificacao_socio`, +`data_entrada_sociedade`, `nome_representante_legal`, `cpf_representante_legal`. + +**O sistema já puxa o CNPJ e já sabe quem são os sócios. Só não conecta isso ao detector.** + +Duas ressalvas verificadas: +- `extrairSocios` ([`receita-ficha.ts:35-46`](../../../src/lib/receita-ficha.ts#L35-L46)) lê o QSA mas + **devolve só `{nome, qualificacao}` — descarta a máscara de CPF**, que é justamente o que dá + confiança `alta` ao detector. +- **Vertímetal é exceção:** seu QSA tem apenas `{nome_socio, qualificacao_socio}` — foi cadastrada à + mão, sem enriquecimento. Empresa não-enriquecida precisa degradar para casamento por nome + (confiança `media`), não quebrar. + +**Elo 2 — o detector só roda na importação.** + +Ele é chamado num único ponto do código vivo: `confirmarImportacao` +([`financial-document-service.ts:590,630`](../../../src/lib/financial-document-service.ts#L590)). +Consequências: + +- Transação importada **antes** de o sócio existir no cadastro nunca é marcada. +- Cadastrar um sócio novo **não** re-varre o histórico. +- Existe `scripts/marcar-contrapartes.ts`, mas com `PROJETO_ID` fixo no arquivo — é ferramenta de + operação, não caminho de produto. + +Falta o equivalente de `aplicarRegrasNoProjeto` para contraparte: **uma varredura retroativa de +partes relacionadas.** + +### 6.5 A solução, e a ordem certa + +**1 · Ligar o QSA no cadastro de sócios.** Ao cadastrar ou enriquecer uma empresa, popular +`client_socios` a partir de `dados_receita.qsa` — `nome_socio` → `nome`, `cnpj_cpf_do_socio` → +`cpf_mascara`. Incluir o representante legal (`nome_representante_legal` + `cpf_representante_legal`), +que movimenta caixa tanto quanto o sócio. Idempotente por `(client_id, nome)`. + +Corrigir `extrairSocios` para carregar a máscara junto, ou ler o QSA cru neste caminho. + +**2 · Varredura retroativa de contraparte.** Espelhar `aplicarRegrasNoProjeto`: roda o detector sobre +todas as transações do projeto, marca `alta` sozinho, enfileira `media` como sugestão para o dono +confirmar. Dispara quando um sócio ou empresa do grupo é cadastrado, e no fim da ingestão. + +**3 · Ordem de precedência na varredura — é isto que torna a §5 segura:** + +``` +1º regra EXATA do cliente (a mais específica ganha) +2º detecção de PARTE RELACIONADA (quem é a contraparte) +3º regra por TOKEN (contains) (só onde não há contraparte detectada) +``` + +A cláusula que fecha o risco: **a regra por token só se aplica a linhas sem contraparte detectada.** +Fica segura por construção, do mesmo jeito que a varredura hoje é segura por só tocar +`categoria_id IS NULL`. + +**4 · A pergunta certa na tela.** Com a contraparte conhecida, o gate para de perguntar "que +categoria é essa?" e passa a perguntar o que o dono sabe responder: + +> *"Entraram R$ 40.000 de **Marlene Ramalho**, sócia. Isso é: +> aporte · devolução de mútuo · pró-labore devolvido · venda de verdade?"* + +Isso é o R3 (causa→efeito) e o R1 (dono no comando) na etapa mais mecânica da jornada. E é a +diferença entre uma DRE que conta a verdade e uma que chama aporte de faturamento. + +### 6.6 Achados + +| # | Achado | Gravidade | +|---|---|---| +| **E2-H** | `client_socios` **não tem escritor** — nenhum código popula a tabela que o detector lê. O QSA com máscara de CPF já está em `companies.dados_receita` (verificado em produção). O elo é uma função. | 🔴 **alto** | +| **E2-I** | `extrairSocios` (`lib/receita-ficha.ts:35-46`) descarta `cnpj_cpf_do_socio` — justamente o campo que dá confiança `alta`. Representante legal também é ignorado. | 🟠 | +| **E2-J** | Detector roda **só** em `confirmarImportacao`. Sem varredura retroativa, sócio cadastrado depois não alcança o histórico já importado. | 🔴 **alto** | +| **E2-K** | Empresa cadastrada à mão (Vertímetal) tem QSA sem máscara. O caminho precisa degradar para nome (`media`), não quebrar. | 🔵 | +| **E2-L** | **Trava de segurança da §5:** a regra por token só pode rodar depois da detecção de parte relacionada, e só sobre linhas sem contraparte. Sem isso, aporte de sócio vira receita. | 🔴 **bloqueante da §5** | + +--- + +**Próxima etapa:** [E3 — o fechamento dos 32 meses](03-fechamento.md). + + +--- + +## Anexo — evidência verificada + +### Motor de categorização + +| O que | Onde | O que prova | +|---|---|---| +| Colapso de descrição | `tratarLancamento` | remove dígitos e pontuação — três linhas do extrato viram uma decisão | +| **Regra da casa = regex** | as 49 regras genéricas da importação | `pattern: /DEP.*DINHEIRO/i` | +| **Regra do cliente = igualdade exata** | `lib/financial-document-service.ts:721` (importação) e `:1952` (varredura) | `.eq("lancamento_tratado", regra.pattern)` — **a assimetria que explica a cauda (E2-B)** | +| Varredura retroativa, fase 1 | `lib/financial-document-service.ts` | só toca `categoria_id IS NULL` — limitação declarada no código (E2-C) | +| Guarda de NULL | `atualizarCategoriaEmLote` usa `.eq("manually_reviewed", false)`; a varredura usa `.or(is.null, is.false)` | divergência real, **impacto zero hoje** — medido | + +### Partes relacionadas + +| O que | Onde | O que prova | +|---|---|---| +| Medição do problema | `lib/partes-relacionadas/detector.ts:10-20` | 795 nomes de sócio · 80 CNPJ de irmã · 113 nome de irmã · 42 CPF · 57 CNPJ próprio | +| Campo descartado | `lib/receita-ficha.ts:35-46` | `extrairSocios` joga fora `cnpj_cpf_do_socio` — o campo que dá confiança `alta` (E2-I) | +| Tabela sem escritor | `client_socios` | nenhum código popula a tabela que o detector lê (E2-H) | +| Detector só na porta | `confirmarImportacao` | sem varredura retroativa (E2-J) | +| Fonte que já existe | `companies.dados_receita` | QSA com máscara de CPF, verificado em produção | + +### Medições — todas read-only, contra produção + +| Script | Resultado | +|---|---| +| `scripts/diag-grupos-por-transacao.ts` | 18.097 lançamentos; linhas por grupo de **4,5 a 29,8** (variação de 6,6×) | +| `scripts/diag-cauda-normalizacao.ts` | prefixo de 2 palavras colapsa só **10%** → é cardinalidade real, **não** bug de normalização | +| `scripts/diag-materialidade-cauda.ts` · `diag-tokens-cauda.ts` | prefixo cobre 4% · sufixo 85% · **token contido: 85% com UM token** (`MAQUININHA`, pureza de sentido 100%) | +| `scripts/diag-manually-reviewed-null.ts` | **0 linhas NULL** em 18.097 (18.072 false, 25 true) → E2-D rebaixado | + +### Código morto — **executado em 20/ago** + +`lib/ai-categorizer.ts` tinha zero callers em `categorizeWithAI`, `findLearnedRule` e +`saveLearnedRule`, e usava `FINANCIAL_CATEGORIES` (catálogo antigo), não o v3 vivo de 11×56. + +**O arquivo já não existe** — removido em `66771e8` (*chore(faxina): G-05 e G-08*), −308 linhas. +Verificado em 20/ago: `src/lib/ai-categorizer.ts` não está no working tree. + +**O que sobrou:** a tabela `learned_rules` segue no banco (34 ocorrências em `db/snapshot/schema.sql`) +e no `types/supabase.ts` gerado. **O DROP foi adiado por decisão** — SQL parqueado no G-08 do +backlog. Nenhum código vivo a lê ou escreve. diff --git a/docs/valor/pente-fino/03-fechamento.md b/docs/valor/pente-fino/03-fechamento.md new file mode 100644 index 00000000..eebffff1 --- /dev/null +++ b/docs/valor/pente-fino/03-fechamento.md @@ -0,0 +1,378 @@ +# E3 — O fechamento: 32 meses viram resultado a que custo? + +**Cliente:** [Casa Pilar](00-cliente-e-regua.md) · **Entrada:** 32 meses categorizados, ~20 mil transações, 4 contas +**Pergunta que esta etapa fecha:** com a verdade pronta, o que ainda falta para o mês virar número — e a Marlene fecha 32 meses **sozinha**? + +> A [E1](01-ingestao.md) custou ~2 h e não respondeu nada. A [E2](02-categorizacao.md) custou 4–5 h e +> transformou ruído em verdade. Aqui a verdade vira **resultado apurado** — a última etapa antes de a +> Marlene finalmente ver alguma coisa. + +**Régua nova desta trilha:** 🟡 não existe mais como "aceitável". Sob SaaS não há alguém da ATR para +fazer pelo cliente — 🟡 é trabalho sem dono. E a pergunta que decide as etapas daqui pra frente: +**a cliente sozinha consegue interpretar o que o sistema pergunta?** + +> **Navegação:** [índice da pasta](README.md) · [relatório técnico completo](RELATORIO-TECNICO.md) · etapa anterior/próxima no rodapé. + +--- + +## 1. O mecanismo, verificado + +### 1.1 Fechar é um wizard de 4 etapas — por mês + +`ConsolidatedFinishModal` ([`ConsolidatedFinishModal.tsx:53-60`](../../../src/features/dre/components/forense/ConsolidatedFinishModal.tsx#L53-L60)) +encadeia: + +``` +report → gate → preview → done +resumo ajustes resultado mês fechado +``` + +O stage **gate** é pulado quando o mês não tem pendência *hard*. O **preview** mostra os KPIs e o +diff contra a versão anterior antes de gravar. É bom desenho: causa → efeito → confirmação. + +**Mas ele roda uma vez por mês.** `fecharMes(mesRef)` recebe um mês; não existe caminho de lote — +grep por `fechar.*(todos|lote|massa)` em `src/` volta **zero**. Para a Casa Pilar, isso é +**32 execuções do wizard**. + +### 1.2 O gate hard é servidor, não é sugestão + +Cinco tipos de card (`emprestimo`, `antecipacao`, `recebivel`, `socio`, `revisao`), disparados pelo +`impeditivo` da subcategoria ([`pendencias.ts:33-38`](../../../src/lib/dre-detalhamento/pendencias.ts#L33-L38)). +Cinco subcategorias são **hard** — bloqueiam de verdade: + +| Subcategoria | Card | +|---|---| +| `captacao_emprestimo` · `pagamento_emprestimo` | empréstimo | +| `recebimento_antecipacao` | antecipação | +| `mov_inter_receita` · `mov_inter_mutuo` | intercompany | + +E o bloqueio não é só de UI: a rota valida antes de gravar o snapshot e devolve erro +([`fechar-mes/route.ts:306-309`](../../../src/app/api/projetos/[id]/dre/fechar-mes/route.ts#L306-L309)) — +*"Pendências hard não resolvidas"*. Movimentação de sócio é **opcional** e não bloqueia +([`useGateForm.ts:530-534`](../../../src/features/dre/hooks/useGateForm.ts#L530-L534)). + +O problema não é o gate existir — é **o que ele pergunta** (§1.5) e **quantas vezes** (§1.3). + +### 1.3 O que carrega entre meses — e o que não carrega + +| Carrega | Não carrega | +|---|---| +| **Empréstimo**: `transactions.contrato_id` fica gravado; o contrato é cadastrado **uma vez** | **Antecipação**: `antecipacaoFormInicial(ant)` lê **só o detalhamento do próprio mês** ([`useGateForm.ts:270`](../../../src/features/dre/hooks/useGateForm.ts#L270)) | + +O gate **não herda nada do mês anterior** para a antecipação. Quem antecipa todo mês — a Casa Pilar +antecipa ~R$ 180 mil/mês — responde a mesma pergunta **32 vezes**, do zero. + +### 1.4 O empréstimo: aqui o desenho está certo + +Três peças que se somam bem: + +1. **Contrato cadastrado uma vez.** `loan_contracts` é, na própria descrição da tabela, + *"persistente entre meses (não duplica)"*. +2. **Auto-linkagem por amortização** ([`auto-linkagem.ts:1-21`](../../../src/lib/dre-detalhamento/auto-linkagem.ts#L1-L21)): + cada lançamento é comparado contra a parcela teórica **do seu mês** no schedule reconstruído + (SAC/Price + carência), com tolerância de **±10% no valor** e **±5 dias na data**. É isso que faz + parcelas de valores diferentes do mesmo contrato casarem todas — a premissa antiga (PMT plana) + não casava SAC. Score 0..1, threshold 0,5, top 3, e **não aplica nada**: sugere, o cliente + confirma. +3. **Vinculação retroativa em massa** — `RetroativaWizard`, montado dentro do próprio gate + ([`GateFormBody.tsx:532`](../../../src/features/dre/components/forense/GateFormBody.tsx#L532)): + cadastrou o contrato, o sistema varre o histórico atrás das parcelas órfãs e o cliente confirma + **agrupado por contrato**, com o porquê do match visível. + +**É o padrão que a etapa inteira deveria ter:** o trabalho se paga uma vez e alcança os 32 meses. + +### 1.5 A antecipação: o gate pede o dado que o extrato não tem + +O OFX credita o **líquido**. Para separar receita de custo do dinheiro — a régua do regime de caixa — +o sistema precisa do **bruto**, e o bruto não está lá: + +```ts +// antecipacao-multinivel.ts:125-135 +derivarAntecipacaoDoLiquido(bruto, liquido, prazoDias) + desagio = bruto − liquido + taxaPercent = desagio / bruto +``` + +`bruto` e `prazo` são **entrada do cliente**, sem fallback e sem estimativa. Sem eles +`completo = false` → `antValido = false` → **o mês não fecha** +([`useGateForm.ts:503`](../../../src/features/dre/hooks/useGateForm.ts#L503)). + +O que a tela pergunta, literalmente +([`GateFormBody.tsx:343-347`](../../../src/features/dre/components/forense/GateFormBody.tsx#L343-L347)): + +> **Valor bruto da face** · **Prazo médio (dias)** · **Tipo** + +E o rodapé de ajuda diz: *"O bruto é a face do título — informe o valor cheio (≥ líquido)"* +([linha 1871](../../../src/features/dre/components/forense/GateFormBody.tsx#L1871)). + +**Não há uma linha dizendo onde achar isso.** O valor de face mora no borderô de desconto, que a +Marlene busca no internet banking do Sicoob ou pede ao gerente. + +E é exatamente o que a [E0](00-cliente-e-regua.md) diz que ninguém na empresa sabe: + +> *"quanto o desconto de duplicatas custa por ano (está diluído como 'tarifa' no extrato)"* + +**O sistema pergunta à cliente justamente aquilo que ela veio perguntar ao sistema.** É o achado +central da E3 — e a resposta à pergunta nova da régua é **não**: sozinha, ela trava aqui. + +Há atenuação parcial, e é boa: com 2+ operações no mês, o modo **híbrido** deixa detalhar algumas +linhas e as demais **herdam a média ponderada** +([`antecipacao-multinivel.ts`](../../../src/lib/dre-detalhamento/antecipacao-multinivel.ts)). Reduz o +preenchimento **dentro** do mês. Não ajuda no mês seguinte, porque nada é herdado entre meses. + +### 1.6 Fechar dispara três motores + +Além de gravar o snapshot imutável, `POST fechar-mes` chama +([route.ts:28-30](../../../src/app/api/projetos/[id]/dre/fechar-mes/route.ts#L28-L30)): + +- `onMonthClosed` — motor de Iniciativas; +- `reconciliarCarteiraNoFechamento` — a carteira de recebíveis; +- `snapshotGiro` — o snapshot de capital de giro. + +Fechar o mês é o **evento** que move o resto do sistema — e é o que torna o custo desta etapa +determinante: **nada a jusante existe antes dele.** + +### 1.7 O mês fechado envelhece sozinho — e isso é feature + +O trigger `marcar_snapshot_desatualizado` (statement-level, na baseline) marca `desatualizado_em` no +snapshot ativo quando as transações daquele mês mudam **depois** do fechamento. A UI acende +`closed_stale` — âmbar, não vermelho — com o texto certo: + +> *"fechado, mas a categorização mudou depois. O número guardado é de antes; apure de novo."* +> ([`MonthChip.tsx:112`](../../../src/features/dre/components/forense/MonthChip.tsx#L112)) + +**A consequência de ordem que ninguém avisa:** a varredura retroativa da E2 +(`aplicarRegrasNoProjeto`) toca meses de **todos os anos**. Se a Marlene fechar janeiro e no mês +seguinte criar uma regra nova, janeiro envelhece e precisa ser reapurado. **A ordem certa é +categorizar os 32 meses inteiros antes de fechar o primeiro** — e o sistema não diz isso em lugar +nenhum. + +--- + +## 2. A conta — medida em produção, não estimada + +Script: [`scripts/diag-fechamento-e3.ts`](../../../scripts/diag-fechamento-e3.ts) (somente leitura). + +### 2.1 O que os dois clientes com histórico mostram + +| Projeto | Meses fechados | Fechados +1× | Stale hoje | Meses com gate hard | Antecipações/mês | Contratos | Escada | +|---|---:|---:|---:|---:|---:|---:|---| +| Vertímetal | **31** | **30** | 0 | **31/31** | 2,1 (em 31 meses) | 3 | — | +| Di Forni | 3 | 3 | 0 | **0/3** | — | 0 | — | + +**91% dos meses fechados tinham pendência que bloqueia o fechamento.** Na Vertímetal, **todos**. + +Repete-se o padrão que a E2 achou na cauda: **o gate é função do perfil do cliente, não do sistema.** +A pizzaria não antecipa nem paga parcela categorizada — para ela o gate nunca abre. A indústria abre +em 100% dos meses. A Casa Pilar é o pior dos dois mundos: desconto de duplicatas recorrente **e** +conta garantida **e** três contratos de dívida. + +### 2.2 O `v5` conta a história que o `v>1` esconderia + +97% dos meses foram fechados mais de uma vez — mas isso **não é a Marlene reapurando**. A +distribuição diz o que aconteceu: + +``` +v1: 1 mês v2: 4 meses v5: 27 meses v6: 1 mês v7: 1 mês +janela: 2026-06-03 → 2026-08-09 +dias mais densos: 23/06 = 30 · 04/08 = 30 · 28/06 = 29 · 22/07 = 29 +``` + +**Trinta meses fechados no mesmo dia, quatro vezes.** É o histórico inteiro sendo **refechado em +massa** — e existe a ferramenta que faz exatamente isso: +[`scripts/refechar-snapshots-catalogo.ts`](../../../scripts/refechar-snapshots-catalogo.ts), que +re-fecha cada mês com a categorização atual quando o **catálogo** muda (e a irmã +`refechar-snapshots-antecipacao.ts`, quando o **motor** muda). + +> **O achado:** fechar não é um ato único. Toda vez que o catálogo ou o motor evoluem, os 32 meses +> precisam ser refechados — e hoje isso só existe como **script com service key**. Em 2 meses de +> desenvolvimento, o histórico da Vertímetal foi refechado **4 vezes**. +> +> Sob consultoria, alguém da ATR rodava. **Sob SaaS não existe esse alguém** — e o cliente não tem +> caminho de lote: seriam 32 wizards de novo, por uma mudança que ele não pediu e não percebeu. + +Refechar **um** mês, esse sim, é caminho de produto: o `CloseMonthModal` já fala *"Refazer +fechamento"* e o chip âmbar convida. O que falta é o lote. + +### 2.3 A Casa Pilar, projetada + +*Estimativa — premissas explícitas: 32 meses; 4 contratos de dívida (Itaú conta garantida, BB +Pronampe, CDC caminhão, cartão BNDES); desconto de duplicatas em todos os meses; ritmo de 2,1 +operações de antecipação por mês, que é o medido na Vertímetal.* + +| Trabalho | Vezes | Custo | +|---|---|---| +| Cadastrar os 4 contratos | 1× | ~20 min | +| Vincular parcelas ao histórico (`RetroativaWizard`) | 1× | ~10 min | +| Wizard por mês (resumo + alocação + preview + fechar) | 32× | ~3 min = **~1 h 40** | +| Preencher face + prazo da antecipação | 32× | ~2 min = **~1 h** | +| **Buscar o valor de face no banco** | 32× | **~2 h — e isso acontece FORA do sistema** | + +**Total E3: ~5 horas**, das quais **~40% são a cliente caçando um dado que o sistema não tem como +pedir ao banco por ela.** + +| | E1 | E2 | E3 | Acumulado | +|---|---:|---:|---:|---:| +| Perfil B2B (Vertímetal) | ~2 h | ~4 h 30 | ~5 h | **~11 h 30** | +| Casa Pilar (B2B + antecipação recorrente) | ~2 h | ~4 h 30 | **~5 h** | **~11 h 30** | + +E um multiplicador que as etapas anteriores não tinham: **se o catálogo ou o motor mudarem, parte +disso se repete** — hoje sem caminho de cliente. + +--- + +## 3. Veredito da E3 + +| Capacidade | Selo | +|---|---| +| Wizard encadeado (resumo → ajustes → resultado) | ✅ | +| Servidor recusa fechar com pendência hard | ✅ | +| Contrato cadastrado uma vez, persistente entre meses | ✅ | +| Auto-linkagem parcela↔contrato por amortização (±10% / ±5d), sugere+confirma | ✅ | +| Vinculação retroativa em massa das parcelas | ✅ | +| Fechar dispara carteira, giro e Iniciativas | ✅ | +| Stale-by-event: o mês fechado que envelheceu se declara | ✅ | +| Refazer o fechamento de **um** mês | ✅ | +| Antecipação herda média ponderada **dentro** do mês | ✅ | +| **Valor de face: o sistema pede o que o extrato não tem, sem dizer onde achar** | 🔴 | +| **Gate não herda nada do mês anterior** | 🔴 | +| **Fechar 32 meses = 32 wizards; não existe lote** | 🔴 | +| **Refechar o histórico quando catálogo/motor mudam** | 🔴 só por script — sob SaaS, ninguém roda | +| **A ordem correta (categorizar tudo → depois fechar) não é dita** | 🔴 | +| Escada de publicação (rascunho → preliminar → consolidada) | 🔴 **inalcançável na UI** — E3-F | + +**O motor de fechamento é sólido; o caminho do cliente não é.** + +Nada aqui está errado no cálculo — a parte difícil (amortização SAC/Price, tolerância de match, +imutabilidade versionada, stale-by-event) está feita, e bem feita. O que falta é tudo do lado de +fora: o dado que ele pede, a repetição que ele impõe e o lote que ele não tem. + +**A resposta à pergunta nova da régua é NÃO.** A Marlene não fecha 32 meses sozinha — não por +incapacidade de operar a tela, mas porque na terceira pergunta o sistema exige um número que ela +nunca teve. E é justamente o número que ela veio buscar. + +**Onze horas e meia acumuladas, e ela ainda não viu uma resposta.** A primeira é a +[E4](04-onde-e-porque.md). + +--- + +## 4. Achados para o backlog + +| # | Achado | Gravidade | +|---|---|---| +| **E3-A** | **A antecipação não herda nada do mês anterior.** `antecipacaoFormInicial` lê só o detalhamento do próprio mês. Quem antecipa todo mês responde 32× a mesma coisa. **Correção barata, retorno alto:** propor prazo e tipo do último mês declarado e deixar o cliente confirmar — o mesmo "sugerir + confirmar" que a auto-linkagem já usa. | 🔴 **maior alavanca da etapa** | +| **E3-B** | **O valor de face não tem de onde vir.** O OFX credita o líquido; a face está no borderô. **DECIDIDO em 19/ago (Lucas) — ver §5:** a face deixa de ser obrigatória e vira um **switch do cliente dentro do gate** — "tenho o borderô" (face por operação, juros na vírgula) × "sei a taxa que pago" (o sistema deriva a face do líquido). | 🔴 **alto — decisão travada, implementação aberta** | +| **E3-C** | **Não existe fechamento em lote.** 32 meses = 32 wizards. Os meses sem pendência hard (na Di Forni, todos) poderiam fechar em fila com uma confirmação só — o wizard já pula o stage `gate` quando não há pendência. | 🔴 **alto** | +| **E3-D** | **Refechar o histórico é script, não produto.** Medido: 4 refechamentos em massa do histórico da Vertímetal em 2 meses, por drift de catálogo/motor. `refechar-snapshots-catalogo.ts` exige service key. Sob SaaS não há quem rode — o cliente fica com números velhos sem saber, ou com 32 wizards para refazer. | 🔴 **alto — regressão do modelo SaaS** | +| **E3-E** | **A ordem certa não é dita.** Fechar antes de terminar a categorização gera retrabalho garantido: a varredura da E2 toca todos os anos e o trigger marca os meses fechados como stale. O sistema sabe (o chip âmbar existe) mas só avisa **depois**. Uma linha no início da apuração resolve. | 🟠 | +| **E3-F** | **A escada de publicação é inalcançável.** `ResultsTab` declara o prop `onPublishDRE` e **nunca o usa** (a linha 66 é a única ocorrência no arquivo); `PublishDREModal` não tem ponto de montagem; `/financeiro/publicar` e `/financeiro/concluir` não têm caller no front. Medido: **0 projetos** passaram de `rascunho`. Consequência: a cascata que auto-conclui as análises #2/#5/#8/#10 nunca dispara, e `get-analysis-diagnosis` — a ferramenta que dá contexto à IA — lê `status='concluido'`, então a IA nunca vê essas análises. **Decidir:** religar o botão ou aposentar a escada. Manter os dois lados vivos e desconectados é o pior caso. | 🟠 **decisão de produto** | +| **E3-G** | Movimentação de sócio é opcional no gate (não bloqueia). Correto para não travar o fechamento — mas significa que a DRE pode fechar com aporte de sócio ainda indefinido, exatamente o erro que a [E2 §6](02-categorizacao.md) combate. Vale medir quantos meses fecharam com `socio_natureza` nulo. | 🔵 medir | + +--- + +## 5. A decisão do E3-B — o switch da antecipação (19/ago/2026, Lucas) + +**A face deixa de ser obrigatória.** No gate, a cliente escolhe **qual papel ela tem na mão**: + +> ○ **Tenho o borderô** → valor de face por operação → *juros na vírgula, operação a operação* +> ○ **Não tenho** → informo a taxa que pago → *o sistema calcula a face* + +``` +t_período = taxa_a.m. × prazo_dias / 30 (desconto comercial, como o banco cobra) +face = líquido / (1 − t_período) +deságio = face − líquido +``` + +Exemplo: entrou R$ 100.000, taxa 2% a.m., prazo 45 dias → face **R$ 103.092,78**, deságio +**R$ 3.092,78**, custo efetivo **~27,6% a.a.** — que é exatamente a pergunta que a Marlene veio fazer +ao sistema ([E0](00-cliente-e-regua.md): *"quanto o desconto de duplicatas custa por ano"*). + +**O que a auditoria do código achou junto com a decisão:** + +1. **O switch que existe hoje é outro.** `ToggleModoAntecipacao` + ([`GateFormBody.tsx:332`](../../../src/features/dre/components/forense/GateFormBody.tsx#L332)) + alterna **agregado × detalhado** — *quantos formulários*, não *qual dado eu tenho*. A face é + obrigatória nos dois: no agregado, `completo = bruto > 0 && prazo > 0 && bruto >= liquido` + ([`useGateForm.ts:504`](../../../src/features/dre/hooks/useGateForm.ts#L504)); no detalhado, a + taxa de cada linha é **derivada da face** (`:471-476`) — sem face, `taxa = null`, a linha fica + indefinida e o mês não fecha. +2. **O caminho "cliente informa a taxa" já existe no motor, e está morto.** + `resolverAntecipacaoMultinivel` tem o Nível 1 (`nivelGeral.taxa_media`), mas o hook passa + `nivelGeral = null` (`useGateForm.ts:488`). Nenhuma tela oferece. +3. **⚠️ Religar o Nível 1 como está erra em silêncio, pra menos.** Sem face, `valorBrutoDe` cai no + `valor_bruto_real` ([`antecipacao-multinivel.ts:92`](../../../src/lib/dre-detalhamento/antecipacao-multinivel.ts#L92)), + que o hook preenche com **o crédito do extrato — o líquido** (`useGateForm.ts:479`). Aí + `decomporLinha` faz `deságio = líquido × taxa` e `líquido resolvido = líquido − deságio`: + receita subdeclarada (face = líquido), custo subdeclarado, e o líquido **deixa de bater com o + extrato**. A implementação certa é a **derivação inversa**, não o forward. + +**Consequências que a decisão traz de brinde:** + +- **O líquido nunca é recalculado** — segue sendo o crédito do extrato, ao centavo. É o invariante que + o caminho morto quebra hoje, e o que o teste de regressão deve travar. +- **No modo taxa, o toggle agregado × detalhado fica irrelevante:** uma taxa resolve cada linha + individualmente (cada operação deriva a própria face pelo próprio crédito). +- **Precisão declarada, não escondida:** o `FonteDado` já existe no motor — o lado taxa entra como + derivado e a tela diz "estimado pela taxa que você informou"; o lado borderô segue "na vírgula" + (R2). +- **Mata o [E3-A] de graça:** escolhido o modo e a taxa, o mês seguinte propõe os mesmos e ela só + confirma. A pergunta 32× vira uma. + +**Decidido junto:** o campo pede **% ao mês** (é como o banco cota e como a dona pensa), com o prazo +fazendo a conversão — não "% da operação". A tela mostra os dois derivados embaixo, para conferência +contra o extrato. + +--- + +**Próxima etapa:** [E4 — ONDE está o dinheiro e POR QUÊ](04-onde-e-porque.md). É a primeira em que a +Marlene vê alguma coisa — e a primeira em que a pergunta *"ela entende sozinha?"* vale para uma tela, +não para um formulário. + + +--- + +## Anexo — evidência verificada + +### O gate e o motor de antecipação + +| O que | Onde | O que prova | +|---|---|---| +| Derivação a partir do líquido | `lib/dre-detalhamento/antecipacao-multinivel.ts:125-135` | `derivarAntecipacaoDoLiquido(bruto, liquido, prazoDias)` — `bruto` e `prazo` **sem fallback** | +| O gate que barra | `features/dre/hooks/useGateForm.ts:503` | `completo=false` → `antValido=false` → o mês não fecha | +| Validação agregado × detalhado | `useGateForm.ts:504` e `:525-529` | agregado exige `bruto>0 && prazo>0 && bruto>=liquido`; detalhado exige `indefinidas === 0` | +| A taxa é **derivada**, nunca digitada | `useGateForm.ts:471-476` | sem face, `taxa = null` → linha indefinida | +| **O caminho da taxa está morto** | `useGateForm.ts:488` | o hook passa `nivelGeral = null` — o Nível 1 do motor nunca é usado | +| **A armadilha do religamento** | `antecipacao-multinivel.ts:92` + `useGateForm.ts:479` | `valorBrutoDe` cai em `valor_bruto_real`, que o hook preenche com **o líquido do extrato** | +| O que a tela pergunta | `features/dre/components/forense/GateFormBody.tsx:343-347` | "Valor bruto da face · Prazo médio (dias) · Tipo" | +| Ajuda insuficiente | `GateFormBody.tsx:1871` | *"O bruto é a face do título — informe o valor cheio (≥ líquido)"* — **não diz onde achar** | +| O toggle existente | `GateFormBody.tsx:332` | `ToggleModoAntecipacao` alterna **agregado × detalhado** — quantos formulários, não qual dado eu tenho | +| Pré-preenche pela face do MEMO | `useGateForm.ts:106-123` | `faceDoExtrato` — nunca sobrescreve o que o dono salvou | + +### Fechamento e seus efeitos + +| O que | Onde | +|---|---| +| Fechar dispara três motores | `api/projetos/[id]/dre/fechar-mes/route.ts:28-30` — `onMonthClosed` · `reconciliarCarteiraNoFechamento` · `snapshotGiro` | +| Stale-by-event | trigger `marcar_snapshot_desatualizado` (statement-level, na baseline) | +| O texto do chip âmbar | `features/dre/components/forense/MonthChip.tsx:112` | +| Refechar em massa | `scripts/refechar-snapshots-catalogo.ts` · `scripts/refechar-snapshots-antecipacao.ts` — **exigem service key** | + +### A escada de publicação (E3-F), reverificada em 19/ago + +| O que | Resultado | +|---|---| +| `onPublishDRE` em `ResultsTab.tsx` | **1 ocorrência** — a declaração do prop, em `:66`. Nunca usado | +| ` **Consequência a jusante:** é este caminho morto que trava o Consultor IA — as análises nunca +> chegam a `status='concluido'`, e o gate do chat exige 30. Ver [E6 §1.4](06-pra-onde-e-o-que-faco.md). + +### Medição + +`scripts/diag-fechamento-e3.ts` (read-only): Vertímetal com **31 meses fechados, 30 refechados, 31/31 +com gate hard, 2,1 antecipações por mês, 3 contratos**; Di Forni com 3 meses e 0/3 com gate. +Distribuição de versões: `v1:1 · v2:4 · v5:27 · v6:1 · v7:1`, janela 03/06 a 09/08, com picos de 30 +meses fechados no mesmo dia. diff --git a/docs/valor/pente-fino/04-onde-e-porque.md b/docs/valor/pente-fino/04-onde-e-porque.md new file mode 100644 index 00000000..ba11a7e5 --- /dev/null +++ b/docs/valor/pente-fino/04-onde-e-porque.md @@ -0,0 +1,334 @@ +# E4 — ONDE está o dinheiro, e POR QUÊ + +**Trilha:** pente fino da jornada real · **Data:** 19/ago/2026 +**Etapa anterior:** [E3 — o fechamento](03-fechamento.md) · **Régua:** [E0](00-cliente-e-regua.md) + +> **A pergunta desta etapa não é mais "o sistema faz?".** É a pergunta nova da régua: +> **a Marlene entende sozinha o que está vendo?** As E1–E3 auditaram formulários — coisas que +> ela *preenche*. A E4 é a primeira que audita **telas** — coisas que ela *lê*. Um formulário +> falha travando; uma tela falha **respondendo errado sem avisar**. + +**Onze horas e meia depois** (E1 2h + E2 4–5h + E3 5h), a Casa Pilar tem 32 meses fechados. A +Marlene abre o menu e vê, na seção FINANCEIRO: Apuração · Cockpit · Caixa & Projeção · +**Resultado** · **Capital de giro** · **Endividamento** · Pessoas · Simulador +([`Sidebar.tsx:174-182`](../../../src/components/Sidebar.tsx#L174-L182)). + +> **Navegação:** [índice da pasta](README.md) · [relatório técnico completo](RELATORIO-TECNICO.md) · etapa anterior/próxima no rodapé. + +--- + +## 1. O mecanismo, verificado + +### 1.0 As quatro perguntas e onde elas moram + +| Pergunta da E4 | Tela | Rota no menu? | Fonte do número | +|---|---|---|---| +| Estou ganhando — e por quê? | Resultado | ✅ | **live** (RPC) + ABC (RPC próprio) | +| O que trava meu dinheiro? | Capital de giro | ✅ | snapshot fechado | +| Quanto devo e a que custo? | Endividamento | ✅ | snapshot fechado + cadastro | +| **De quem depende minha receita?** | Retrato → Clientes | 🔴 **não** — mora em `/projetos/[id]` | snapshot fechado | + +As três primeiras são a mesma casca: `CasaDoGrupoShell` com `painelSecao` +(`resultado/page.tsx:45`, `giro/page.tsx:23`, `endividamento/page.tsx:24`), que desemboca em +`PainelEmpresaView` ([`PainelCasaTab.tsx:123`](../../../src/components/central-dados/PainelCasaTab.tsx#L123)). + +### 1.1 Capital de giro — a tela que passa, e vira a régua das outras + +**É o padrão da casa.** Escrita em português de dono, sem uma sigla, e com causa→efeito explícito +([`GiroView.tsx`](../../../src/components/giro/GiroView.tsx)): + +- **herói** — o custo anual do giro em reais, com `% do lucro`, e a composição dita na mesma frase: + *"É deságio de antecipação mais juros de empréstimo de giro"* (`:126-133`); +- **decomposição da variação** — quanto do buraco veio de **volume** (acompanha a venda) e quanto veio + de **descasamento** (dá pra mexer) — `:140-170`. O comentário no código registra por que os sinais + ficam visíveis: a versão com `Math.abs` produzia *"caiu R$ 51 mil: R$ 21 mil e R$ 72 mil"*, e o dono + faz essa conta de cabeça; +- **o mecanismo** — *"seu dinheiro sai em média no dia 12 e entra no dia 27"* (`:207-216`), com texto + **diferente** conforme o sinal, para não fabricar um problema onde não há; +- **honestidade sobre o que não sabe** — o vale do mês **não é exibido** quando a cobertura bancária é + parcial, porque *"um vale medido sobre parte das contas sai menor do que o real — é o erro que passa + despercebido"* (`:238-243`); e o aviso de que o extrato só enxerga a oscilação **dentro** do mês + (`:324-330`). + +**O que ela não faz: terminar em ação.** O fecho é um convite de captura de 3 números (`:530-566`) — +que é uma boa porta para o Nível 1, mas não é uma ação sobre o problema que a própria tela acabou de +provar em reais. Não há link para renegociar, para o Endividamento, nem para as Iniciativas. + +### 1.2 Endividamento — rica, interpretável, e sem desfecho + +Cada KPI carrega um campo `oQueSignifica` **com o limiar em português** +([`EndividamentoView.tsx:126-205`](../../../src/components/endividamento/EndividamentoView.tsx#L126-L205)): + +> *"Quanto a dívida tira da sua receita. Acima de ~3% já é faixa apertada; acima de 5%, estresse."* +> *"Quanto do que entra é venda futura adiantada (com deságio). Acima de 25% é estresse; acima de 40%, crítico."* + +As seções são perguntas, não rótulos: *"quanto estou pagando, e está caro?"* · *"estou alavancado +demais para o que a empresa gera?"* · *"estou vivendo de adiantar o que ainda nem recebi?"* · *"o que +você deve, e qual renegociar primeiro"*. **A terceira é literalmente o buraco 3 da Casa Pilar** — a +dependência de antecipação que ninguém na empresa sabe medir. + +E o cadastro cobre a estante inteira dela: conta garantida, desconto de duplicatas, antecipação de +cartão, Pronampe, cartão BNDES, CDC +([`loan-contracts-modalidades.ts:14-28`](../../../src/lib/loan-contracts-modalidades.ts#L14-L28)). + +**Três defeitos, em ordem de tamanho:** + +1. **Não termina em ação.** `montarVereditoEndividamento` + ([`veredito.ts`](../../../src/lib/endividamento/veredito.ts)) não tem campo de ação: grep por + `acao|cta|Iniciativa|htr` no arquivo → **zero**. A tela diz "renegocie" na pergunta da seção e + entrega uma **tabela de cadastro** (`RankingContratosTable`), cujo único clique abre o modal de + gestão do contrato. +2. **O custo do cadastro é dela.** São 5 instrumentos × um formulário que pede taxa, prazo, + modalidade e **"Amortização · SAC ou Price"** + ([`NovoContratoSheet.tsx:106`](../../../src/components/endividamento/NovoContratoSheet.tsx#L106)) — + pergunta que a Marlene não sabe responder sem o contrato na mão. +3. **A copy escorrega no jargão exatamente onde ela está mais perdida:** a mensagem de indisponível + diz *"Os indicadores de endividamento (DL/EBITDA, custo, comprometimento) aparecem ao fechar um + mês"* ([`EndividamentoSection.tsx:261`](../../../src/components/endividamento/EndividamentoSection.tsx#L261)), + e o tooltip do card fecha com *"É o indicador-rei de alavancagem"* (`EndividamentoView.tsx:180`). + +### 1.3 Resultado — a tela onde os números discordam entre si + +É a resposta a *"Estou ganhando — e por quê?"* +([`PainelCasaTab.tsx:241-251`](../../../src/components/central-dados/PainelCasaTab.tsx#L241-L251)): +um selo de frescor, um gráfico Receita × Despesas, a Curva ABC de despesas, e dois drills. + +**Três relógios diferentes na mesma dobra, nenhum rotulado:** + +| Peça | Janela | Fonte | +|---|---|---| +| `FrescorSelo` | último mês **fechado** | `dre_monthly_snapshots` | +| Gráfico de resultados | últimos **12 meses**, ao vivo | RPC `calculate_monthly_dre` | +| Curva ABC de despesas | **os 32 meses inteiros** | RPC `get_abc_buckets_despesas`, sem período | +| Drill "Ver DRE completa" | mês a mês **fechado** | `dre_monthly_snapshots` | + +O gráfico vem de `getFinancialOverview` → `rpc('calculate_monthly_dre')` +([`financial-dashboard-service.ts:39`](../../../src/lib/financial-dashboard-service.ts#L39)) → +janela de 12 ([`financial-kpis.ts:95`](../../../src/lib/data/financial-kpis.ts#L95)). Essa função +está marcada como **DEPRECATED no próprio banco** e encadeia `v2` → `v3` +([`db/snapshot/schema.sql:429,459,463`](../../../db/snapshot/schema.sql#L429)), que calcula **ao vivo +sobre `transactions`** (`:483-510`). O drill ao lado lê o **congelado** +([`dreService.ts:84`](../../../src/features/dre/services/dreService.ts#L84)). + +**O efeito prático:** quando a Marlene mexer numa regra de categorização (a E2 mostrou que ela vai +mexer — a varredura toca todos os anos), o **gráfico anda sozinho** e a **DRE não**. Só a DRE tem o +chip âmbar de `desatualizado`; o gráfico muda calado. Duas respostas para *"eu ganhei quanto?"*, na +mesma tela, e a errada é a que está mais acima. + +### 1.4 A Curva ABC — a pergunta certa, respondida por outro motor + +`AbcDespesasSection` chama o RPC **sem período** +([`PainelCasaTab.tsx:772-774`](../../../src/components/central-dados/PainelCasaTab.tsx#L772-L774)), e o +RPC é isto, na íntegra ([`schema.sql:1516-1530`](../../../db/snapshot/schema.sql#L1516-L1530)): + +```sql +SELECT COALESCE(NULLIF(t.categoria_nome,''),'Sem categoria'), SUM(t.debito) +FROM transactions t +WHERE t.project_id = p_project_id AND t.debito > 0 +GROUP BY 1 +``` + +**Soma de débito cru.** Sem `linha_dre`, sem `sinal`, sem `destino` — as três colunas que a DRE v3 usa +para saber o que é despesa ([`schema.sql:485-495`](../../../db/snapshot/schema.sql#L485)). Entram no +mesmo bolo: transferência entre contas da própria empresa, **principal** de empréstimo (que é dívida +amortizada, não despesa), aporte devolvido a sócio, e o intercompany — a irmã de grupo, +`get_abc_buckets_despesas_grupo`, **exclui `mov_inter_*` explicitamente** (`:1545`); a individual não +exclui nada. + +O texto da tela é *"Onde o dinheiro sai: N categorias concentram X% das saídas"* +([`PainelCasaTab.tsx:734-736`](../../../src/components/central-dados/PainelCasaTab.tsx#L734)) — e ele +está certo sobre **saídas do banco**. Só que a pergunta da seção é sobre **resultado**. Para uma +empresa que desconta duplicata todo mês e roda conta garantida como saldo, o topo da curva tende a ser +movimentação de dinheiro, não despesa. + +### 1.5 Concentração de clientes — existe, é boa, e está na outra casa + +> **⚠️ Corrigido em 19/ago pela [E5](05-simulador.md):** o **motor** é honesto; a **leitura que o +> alimenta** trunca em 1.000 linhas. `carregarRetrato` lê `transactions` sem `.range()` e sem +> `.order()` ([`retrato-service.ts:67`](../../../src/lib/retrato/retrato-service.ts#L67)), então os +> percentuais abaixo saem de uma fatia arbitrária da janela. Ver **E4-J**. + +A análise é real e honesta: HHI + top-3/5/10, regularidade, ticket médio, e quem sumiu +([`client-revenue-analysis.ts`](../../../src/lib/retrato/client-revenue-analysis.ts)). A tela nomeia o +maior cliente, o mais recorrente, e traz o selo de honestidade certo — *"Clientes inferidos do +descritivo do extrato (proxy, não cadastro)"* +([`SecaoClientes.tsx:50`](../../../src/components/visao/retrato/SecaoClientes.tsx#L50)). Para a Casa +Pilar, é **a** tela do terceiro sintoma: *três construtoras são 41% do faturamento*. + +**Ela mora em `/projetos/[id]` → Retrato** (`page.tsx:63`), que no menu se chama **"Visão do +Projeto"**. Nenhuma das quatro telas do Financeiro linka para lá — grep por `retrato` em +`PainelCasaTab`, `GiroView` e `components/endividamento/` → **zero**. E o card que existia para +levá-la ao Financeiro, +[`ConcentracaoReceitaCard.tsx`](../../../src/features/dre/components/ConcentracaoReceitaCard.tsx), +**não tem um único caller**. + +Também não há a ponte causa→efeito: a tela diz *41%*, e não diz **o que 41% custa** — quantos meses de +lucro somem se a maior construtora atrasar 60 dias. Esse é o mesmo buraco do +[G-01](../gaps-backlog.md) (aging e risco de recebível), visto do lado da receita. + +### 1.6 Ponto de equilíbrio — o drill que calcula sobre o mês errado + +`getBreakevenDataServer` fixa a referência em **hoje** +([`financial.ts:350-351`](../../../src/lib/data/financial.ts#L350-L351)) e busca esse mês na série; só +cai na média se o mês não existir na resposta. Em **19/ago/2026**, com agosto tendo transações, o Ponto +de Equilíbrio da Casa Pilar sai calculado sobre **19 dias de um mês aberto** — receita parcial contra +custo fixo parcial. E usa o shape do RPC deprecado (`despesas_fixas`, `despesas_variaveis`, +`cmv_fornecedores`, `:379-382`), o proxy L30/L40, não a margem de contribuição canônica. + +O número não falha: ele **sai**, e sai errado. + +--- + +## 2. A conta — o tempo da E4 + +A E4 é a primeira etapa em que o tempo não é de digitação, é de **leitura** — com uma exceção cara. + +| O quê | Tempo-cliente | Base | +|---|---|---| +| Ler Capital de giro | ~10 min | estimativa · a tela tem 5 blocos e narrativa longa | +| Ler Resultado + drills | ~10 min | estimativa | +| Ler Retrato → Clientes | ~15 min | estimativa · 8 seções, é a tela mais densa do produto | +| **Cadastrar os 5 contratos de dívida** | **~1 h** | estimativa · 5 × ~12 min, **assumindo os contratos em mãos**; sem eles, some o tempo de pedir ao gerente | +| **Total E4** | **~1 h 35 min** | | + +**Acumulado da jornada: ~13 horas.** + +**A dependência que a E3 deixou e a E4 cobra:** o número-herói do Capital de giro é +`custo_giro_desagio + custo_giro_juros`, lido do snapshot +([`giro-view-service.ts:243-281`](../../../src/lib/giro/giro-view-service.ts#L243-L281)) — e o deságio +só existe lá se o gate da antecipação foi respondido no fechamento. **O E3-B (valor de face) não +bloqueia só a apuração: ele bloqueia a primeira resposta útil do produto inteiro.** + +--- + +## 3. Veredito da E4 + +| Capacidade | Selo | +|---|---| +| **Capital de giro — o custo, a causa e o que não se enxerga** | ✅ | +| Endividamento — custo, tamanho, dependência de antecipação | ✅ **no número** | +| Endividamento — cadastro dos contratos pelo dono | ✅ (mas ~1 h dela) | +| Concentração de clientes — a análise | ✅ no motor · 🔴 **a leitura trunca em 1.000 linhas (E4-J)** | +| **Concentração — achável por quem foi ao Financeiro** | 🔴 **não está lá** | +| **Resultado — o número bate com a DRE fechada** | 🔴 **fontes diferentes, sem rótulo** | +| **Curva ABC — responde "onde o dinheiro sai" como despesa** | 🔴 **soma débito cru, 32 meses** | +| **Ponto de equilíbrio** | 🔴 **calcula sobre o mês corrente parcial** | +| **Alguma das 4 telas termina em ação (R4)** | 🔴 **nenhuma** | +| Copy livre de jargão | 🟠 | + +**A resposta à pergunta nova da régua é: SIM em duas telas, NÃO nas outras duas.** + +O Capital de giro e o Endividamento provam que a casa **sabe** escrever para a Marlene: limiar em +português, causa antes do número, e o pudor de esconder o dado quando a base não sustenta. O +Resultado e a Concentração mostram o que acontece quando essa régua não foi aplicada — não por +falta de motor, mas porque a tela foi montada com o que estava à mão. + +**E há um degrau acima desse.** As duas telas boas respondem *onde* e *por quê* com precisão — e param +ali. A E4 é a etapa do **diagnóstico**, então parar seria aceitável se a etapa seguinte pegasse o +bastão. Mas a Marlene não é levada a lugar nenhum: nenhuma das quatro telas aponta para a E5 (a +alavanca) ou para as Iniciativas. Depois de 13 horas, o produto entrega um **laudo**, e o laudo não +diz o que fazer na segunda-feira. + +--- + +## 4. Achados para o backlog + +| # | Achado | Gravidade | +|---|---|---| +| **E4-A** | **A Curva ABC soma débito cru de todo o histórico.** `get_abc_buckets_despesas` ([`schema.sql:1516`](../../../db/snapshot/schema.sql#L1516)) agrega `SUM(t.debito)` sem `linha_dre`/`sinal`/`destino` e sem excluir transferência própria, principal de empréstimo ou intercompany — a versão do **grupo** exclui `mov_inter_*`, a individual não exclui nada. E a chamada não passa período ([`PainelCasaTab.tsx:772`](../../../src/components/central-dados/PainelCasaTab.tsx#L772)), então soma os 32 meses ao lado de um gráfico de 12. **Correção:** ler as mesmas linhas da DRE v3 e receber o período do Shell. | 🔴 **alto — responde errado, com cara de certo** | +| **E4-B** | **Gráfico live × DRE congelada na mesma tela.** O card de resultados lê o RPC deprecado ao vivo; o drill lê o snapshot. Mexer numa regra move um e não o outro, e só o congelado tem o chip de `desatualizado`. **Correção:** o Resultado lê snapshot, como todo o resto — ou rotula a diferença de forma tão visível quanto o chip âmbar. | 🔴 **alto** | +| **E4-C** | **Ponto de equilíbrio calcula sobre o mês corrente parcial** ([`financial.ts:350`](../../../src/lib/data/financial.ts#L350)) e usa o proxy L30/L40 em vez da margem de contribuição canônica. Sai um número plausível e errado. **Correção:** referência = último mês fechado; motor = `margem-contribuicao.ts`. | 🔴 **alto — falha silenciosa** | +| **E4-D** | **Concentração de clientes não está no Financeiro.** Mora no Retrato (`/projetos/[id]`); nenhuma tela do Financeiro linka, e o `ConcentracaoReceitaCard` que existia para isso tem zero callers. É o buraco declarado da Casa Pilar. **Correção:** seção nativa em Resultado (ou link de drill), reusando `client-revenue-analysis.ts`. | 🔴 **alto** | +| **E4-E** | **Nenhuma das 4 telas termina em ação (R4).** Giro fecha em convite de captura; Endividamento pergunta "qual renegociar primeiro" e entrega uma tabela de cadastro (`veredito.ts` não tem campo de ação); Resultado tem 2 drills de leitura; Concentração, uma tabela. **Correção:** o veredito de cada tela aponta para a alavanca (E5) ou para uma Iniciativa. | 🔴 **alto — é o R4 da constituição** | +| **E4-F** | **Concentração não vira risco em reais.** A tela diz "41% em 3 clientes" e não diz quantos meses de lucro somem se o maior atrasar 60 dias. Mesma lacuna do [G-01](../gaps-backlog.md), pelo lado da receita. | 🟠 | +| **E4-G** | **Copy que trai o modelo:** badge `Live Data` em inglês e vazio dizendo *"Inicie o diagnóstico DRE Gerencial"* — nome de coisa que não existe no menu ([`AcompanhamentoResultadosCard.tsx:46,57`](../../../src/components/financeiro/cockpit/AcompanhamentoResultadosCard.tsx#L46)) · `DL/EBITDA` cru ([`EndividamentoSection.tsx:261`](../../../src/components/endividamento/EndividamentoSection.tsx#L261)) · *"indicador-rei de alavancagem"* ([`EndividamentoView.tsx:180`](../../../src/components/endividamento/EndividamentoView.tsx#L180)). | 🟠 | +| **E4-H** | **Cadastrar 5 contratos custa ~1 h e pede "SAC ou Price"** ([`NovoContratoSheet.tsx:106`](../../../src/components/endividamento/NovoContratoSheet.tsx#L106)). O gate de fechamento já faz engenharia reversa de dívida a partir do extrato — **propor** o contrato detectado e pedir confirmação encurtaria a maior parte disso. | 🟠 | +| **E4-J** | **A concentração é calculada sobre uma amostra truncada.** `carregarRetrato` ([`retrato-service.ts:67`](../../../src/lib/retrato/retrato-service.ts#L67)) lê `transactions` sem `.range()` e sem `.order()` — teto de 1.000 linhas do PostgREST, que este repo documenta em `varredura-contraparte.ts:48` e contorna em outros 11 leitores. A Casa Pilar tem ~7.500 transações na janela de 12m: os "41% em 3 clientes" saem de uma fatia arbitrária. **O mesmo defeito no Simulador** — ver [E5-A](05-simulador.md). *Achado em 19/ago, na E5; rebaixa o ✅ do motor desta etapa.* | 🔴 **alto — falha silenciosa** | +| **E4-I** | O `PainelAnaliseSections` órfão segue no arquivo (`PainelCasaTab.tsx:411`) com `eslint-disable` — parqueado de propósito para o G7, mas é um dos 6 leitores de `cash_cycle_analyses`. Não deletar fora de sessão própria. | 🔵 nota | + +--- + +**Próxima etapa:** E5 — QUANDO e QUAL ALAVANCA (Caixa · Projeção · Simulador). É onde o E4-E +("nenhuma tela termina em ação") ou se resolve, ou vira o veredito da trilha. + + +--- + +## Anexo — evidência verificada + +### O que a cliente vê no menu + +`components/Sidebar.tsx:174-182` — Apuração · Cockpit · Caixa & Projeção · **Resultado** · **Capital +de giro** · **Endividamento** · Pessoas · Simulador. **Concentração de clientes não está aqui.** + +### As três telas e sua casca comum + +| Rota | Arquivo | `painelSecao` | +|---|---|---| +| `/financeiro/resultado` | `app/projetos/[id]/financeiro/resultado/page.tsx:45` | `resultado` (abas `dre` + `painel`) | +| `/financeiro/giro` | `app/projetos/[id]/financeiro/giro/page.tsx:23` | `giro` | +| `/financeiro/endividamento` | `app/projetos/[id]/financeiro/endividamento/page.tsx:24` | `endividamento` | +| Despacho por seção | `components/central-dados/PainelCasaTab.tsx:123` (`PainelEmpresaView`) e `:226-290` | — | + +### Fontes de número, por peça + +| Peça | Fonte | Onde | +|---|---|---| +| Selo de frescor | `dre_monthly_snapshots` (último mês fechado) | `components/financeiro/FrescorSelo.tsx` | +| Gráfico Receita × Despesas | **RPC ao vivo**, janela de 12 meses | `lib/financial-dashboard-service.ts:39` → `lib/data/financial-kpis.ts:95` | +| Curva ABC | **RPC próprio, sem período** | `PainelCasaTab.tsx:772-774` | +| Drill "Ver DRE completa" | `dre_monthly_snapshots` | `features/dre/services/dreService.ts:84` | +| Giro | snapshot do giro | `lib/giro/giro-view-service.ts:243-281` | +| Endividamento | snapshot do mês + cadastro | `components/endividamento/queries.ts` | + +### A cadeia de deprecação da DRE, no dump + +| Linha em `db/snapshot/schema.sql` | O que diz | +|---|---| +| `:429` · `:459` | `calculate_monthly_dre` — **"DEPRECATED: usa calculate_monthly_dre_v2"** | +| `:463` · `:474` | `calculate_monthly_dre_v2` — **"DEPRECATED: alias para v3"** | +| `:483-510` | `calculate_monthly_dre_v3` — calcula **ao vivo sobre `transactions`**, com `linha_dre`, `sinal`, `destino` | +| `:1516-1530` | `get_abc_buckets_despesas` — `SUM(t.debito)` cru, **sem** nenhuma dessas colunas | +| `:1545` | a irmã de grupo **exclui `mov_inter_*`**; a individual não exclui nada | + +### Ponto de Equilíbrio + +`lib/data/financial.ts:350-351` fixa `mesRef`/`anoRef` em **hoje**; `:379-382` lê o shape do RPC +deprecado (`despesas_fixas`, `despesas_variaveis`, `cmv_fornecedores`) — o proxy L30/L40, não a +margem de contribuição canônica. + +### Interpretabilidade — onde a régua foi aplicada + +| Peça | Onde | O que faz certo | +|---|---|---| +| Giro, herói | `components/giro/GiroView.tsx:126-133` | custo anual em R$ e em % do lucro, com a composição na mesma frase | +| Giro, variação | `:140-170` | volume × descasamento, com os **sinais visíveis** (o comentário explica por quê) | +| Giro, descasamento | `:207-216` | texto **diferente** conforme o sinal — não fabrica problema onde não há | +| Giro, pudor | `:238-243` e `:324-330` | esconde o vale quando a cobertura é parcial; declara o que o extrato não enxerga | +| Endividamento | `components/endividamento/EndividamentoView.tsx:126-205` | `oQueSignifica` com o limiar em português, por KPI | +| Cadastro de dívida | `lib/loan-contracts-modalidades.ts:14-28` | cobre a estante inteira da Casa Pilar | + +### Interpretabilidade — onde não foi + +| Onde | O texto | +|---|---| +| `components/financeiro/cockpit/AcompanhamentoResultadosCard.tsx:46` | badge `Live Data` (em inglês) | +| idem `:57` | *"Nenhum dado de DRE encontrado. Inicie o diagnóstico DRE Gerencial"* — nome de coisa que não existe no menu | +| `components/endividamento/EndividamentoSection.tsx:261` | `DL/EBITDA` cru na mensagem de indisponível | +| `components/endividamento/EndividamentoView.tsx:180` | *"É o indicador-rei de alavancagem"* | +| `components/endividamento/NovoContratoSheet.tsx:106` | *"Amortização · SAC ou Price"* | + +### Concentração de clientes + +| O que | Onde | +|---|---| +| Motor (HHI, top-3/5/10, regularidade, sumidos) | `lib/retrato/client-revenue-analysis.ts` | +| Tela | `components/visao/retrato/SecaoClientes.tsx`; selo de proxy em `:50` | +| Onde mora | `app/projetos/[id]/page.tsx:63` — **Visão do Projeto**, não Financeiro | +| Card que existia para o Financeiro | `features/dre/components/ConcentracaoReceitaCard.tsx` — **zero callers** | +| **A leitura que a alimenta** | `lib/retrato/retrato-service.ts:67-73` — sem `.range()` e sem `.order()` (**E4-J**) | + +### Sem ação (R4) + +`lib/endividamento/veredito.ts` — grep por `acao|cta|Iniciativa|htr`: **zero**. O `montarVereditoEndividamento` +não tem campo de ação. diff --git a/docs/valor/pente-fino/05-simulador.md b/docs/valor/pente-fino/05-simulador.md new file mode 100644 index 00000000..306add32 --- /dev/null +++ b/docs/valor/pente-fino/05-simulador.md @@ -0,0 +1,256 @@ +# E5 (parcial) — QUAL ALAVANCA: o Simulador + +**Trilha:** pente fino da jornada real · **Data:** 19/ago/2026 +**Etapa anterior:** [E4 — ONDE + POR QUÊ](04-onde-e-porque.md) · **Régua:** [E0](00-cliente-e-regua.md) + +> **Escopo reduzido por decisão do Lucas (19/ago):** a E5 original era *Caixa · Projeção · +> Simulador*. **Caixa & Projeção fica de fora** — vai mudar bastante, e auditar código que vai ser +> reescrito é o anti-padrão que já custou a parada da trilha na E3. Esta etapa cobre **só o +> Simulador**. O "QUANDO" (caixa/projeção) volta como E5b quando o redesenho estiver de pé. + +> **Navegação:** [índice da pasta](README.md) · [relatório técnico completo](RELATORIO-TECNICO.md) · etapa anterior/próxima no rodapé. + +--- + +## 1. O mecanismo, verificado + +### 1.1 O que a Mesa de Decisão é + +Uma rota própria no menu (`/financeiro/simulador`), hospedada no mesmo Shell das demais. A tela é o +tripé: **alavancas** (rail à esquerda) × **parecer + DRE simulada** (centro) × **caixa e meta** +(abaixo), tudo respondendo ao mesmo motor `simularWhatIf` +([`SimuladorCasaTab.tsx`](../../../src/components/central-dados/SimuladorCasaTab.tsx), 1.708 linhas). + +**As alavancas** (`:146-172`): preço (com "e com isso perco N% dos clientes"), volume, corte de +despesas, os três prazos, eventos pontuais — e cinco **decisões prontas**: contratar pessoas, +renegociar dívidas, queimar estoque parado, **perder o maior cliente**, **reduzir antecipação**. + +### 1.2 O que esta tela acerta — e que as da E4 erraram + +**Lê o congelado, não o vivo.** `lerRealizados` +([`pacing-service.ts:62-68`](../../../src/lib/pacing/pacing-service.ts#L62-L68)) lê +`dre_monthly_snapshots` com `invalidated_at is null` e maior `version`. É exatamente o oposto do +[E4-B](04-onde-e-porque.md): a tela de Resultado mostra um gráfico ao vivo, e a Mesa — que simula o +futuro — parte do que foi fechado. + +**Fala a língua dela.** Os prazos não são PMR/PMP/PME: são **"Clientes pagam"**, **"Você paga +fornecedores"**, **"Estoque parado"** (`:713-726`). É a mesma correção que a spec do Giro §3.2 já +tinha aplicado ao aposentar a tela `/ciclo`, aqui aplicada por conta própria. + +**Declara o regime de cada número.** Selo `medido | rateado | declarado | estimado` por alavanca +(`:1244`), e a hint do volume muda conforme o ciclo ter sido medido ou não (`:696-698`). + +**E as duas decisões que valem a tela inteira para a Casa Pilar:** + +- **Reduzir antecipação** (`:1026-1050`) — *"Você antecipa R$ X/mês e deixa R$ Y/mês na mesa (taxa + Z%). Reduzir 50% economiza R$ W/mês — mas o caixa espera o prazo normal uma vez: buraco de R$ K no + 1º mês (prazo medido: Nd)."* Cobra o preço da decisão na mesma frase em que oferece o ganho. + Alimentado pelo gate do fechamento (`dre_detalhamento tipo='antecipacao'`), não por estimativa. +- **Perder o maior cliente** (`:1000-1024`) — o teste de estresse da concentração, com o nome real e + o % real, e o selo honesto *"cliente inferido do extrato (PIX/TED) — não é cadastro"*. É a resposta + que faltava ao [E4-F](04-onde-e-porque.md): aqui a concentração **vira risco em reais**. + +**Degrada por fonte, não em bloco.** Cada card nasce desabilitado com o motivo escrito ("Sem estoque +cadastrado — suba a planilha pra destravar"), em vez de sumir. + +--- + +## 2. Os três defeitos + +### 2.1 🔴 O maior cliente é calculado sobre 1.000 linhas — em silêncio + +`carregarMaiorCliente` +([`simulador-dados/route.ts:70-76`](../../../src/app/api/projetos/[id]/simulador-dados/route.ts#L70-L76)): + +```ts +.from("transactions") +.select("data, lancamento_tratado, credito, categoria_id") +.eq("project_id", projectId) +.eq("categoria_id", "receita_bruta") +.gte("data", inicioJanela(12)) +``` + +**Sem `.range()` e sem `.order()`.** O teto de 1.000 linhas do PostgREST não é hipótese neste repo — +está escrito no código, palavra por palavra: + +> *"Página do PostgREST — sem `.range()` a query volta 1000 linhas em silêncio."* +> ([`varredura-contraparte.ts:48`](../../../src/lib/partes-relacionadas/varredura-contraparte.ts#L48)) + +E há **onze** outros leitores que paginam justamente por isso (`cost-audit-service.ts:407`, +`financial-document-service.ts` ×4, `giro-snapshot-service.ts:275`, `visao-service.ts:161`, +`completude-mensal/route.ts:96`, entre outros). O comentário em +`completude-mensal/route.ts:74` registra até a armadilha vizinha: *"`.limit(100000)` não vence o teto +de 1000"*. + +**A Casa Pilar tem ~630 transações/mês.** A janela de 12 meses do maior cliente pega ~7.500 linhas, +das quais só a receita já passa de mil. Sem `.order()`, as 1.000 que voltam são uma fatia **arbitrária** +— e o "% da receita" do maior cliente é calculado sobre ela. O número sai, parece plausível, e não tem +como ela desconfiar. + +**E o mesmo defeito está no Retrato.** `carregarRetrato` +([`retrato-service.ts:67-73`](../../../src/lib/retrato/retrato-service.ts#L67-L73)) lê +`custos` + `receita_bruta` da janela inteira, também sem `.range()` — e é dele que sai a seção +Clientes & Concentração que a [E4-D](04-onde-e-porque.md) selou como *"a análise é real e honesta"*. +**A análise é. A leitura que a alimenta, não.** Nas duas telas, os 41% da Casa Pilar saem de uma +amostra truncada. + +*(Ver E4-J, aberto no doc da E4 por causa deste achado.)* + +### 2.2 🔴 "Meta travada" pode ser um rascunho + +`lerMeta` ([`pacing-service.ts:45-56`](../../../src/lib/pacing/pacing-service.ts#L45-L56)) pega o +snapshot de maior `version` — **sem filtrar `status`**. A tabela tem +`status ∈ {rascunho, revisado, aprovado, arquivado}` +([`schema.sql:7004`](../../../db/snapshot/schema.sql#L7004)), e existe um leitor que faz a coisa +certa: `snapshotAprovadoVigente` filtra `status = 'aprovado'` +([`snapshot-vigente.ts:37`](../../../src/lib/plano-voo/snapshot-vigente.ts#L37)) — escrito na sessão +passada exatamente para consertar o Cockpit, que misturava planos. + +**São quatro consumidores de `lerMeta`:** o Simulador (`simulador-dados/route.ts:184`), o **Capital de +giro** (`giro-view-service.ts:444`), as **Iniciativas** (`on-month-closed.ts:129`) e o **pacing do +Cockpit** (`pacing-service.ts:92,140`). + +O Giro imprime na tela, com todas as letras: *"a meta de +X% de faturamento em 12 meses **travada** no +Plano de Voo"* ([`GiroView.tsx:265-268`](../../../src/components/giro/GiroView.tsx#L265-L268)). O +comentário do serviço declara a intenção certa — *"pelo mesmo leitor que o Cockpit usa — nunca uma +segunda régua"* (`giro-view-service.ts:416`) — e a régua única é a **errada**: ela não sabe o que é +aprovado. + +**E aqui a coisa fecha com o G-11.** Como o dono não consegue aprovar o próprio plano +([handoff 18b, G-11](../../atros-v3/handoff-2026-08-18b-saas-executado-e-vazamento.md)), o único +snapshot que um cliente self-service consegue produzir é **rascunho**. Resultado: o Simulador, o Giro +e o Cockpit medem contra uma meta que o próprio sistema considera não aprovada quando trava as +Iniciativas. **O produto discorda de si mesmo sobre se a meta existe.** + +### 2.3 🔴 O cenário não vai a lugar nenhum + +O parecer não prescreve — e isso é **decisão declarada**, não esquecimento: + +> *"R4: NARRA consequência, nunca prescreve ação-raiz (ação mora no HTR)."* +> ([`parecer-mesa.ts:11`](../../../src/lib/simulador/parecer-mesa.ts#L11)) + +A regra é boa e é a da constituição. **Só que o outro lado da ponte não existe.** +`projection_scenarios` é escrito e lido **só pelo próprio Simulador** (`projection-scenarios/service.ts` ++ os dois componentes de simulador). Grep por `simulador|cenario|scenario` em `src/lib/iniciativas/`, +`htr*` e `api/ai/htr` → **zero**. E o ícone de download na lista de cenários (`:1084`) não exporta +nada: é *"abrir na mesa"*. + +A Marlene monta o cenário que resolve o problema dela — reduzir antecipação em 50%, economizar R$ Y por +mês —, clica em salvar, e **o sistema esquece**. Nenhuma iniciativa nasce, nada entra no Cockpit, nada +volta no mês seguinte para perguntar se ela fez. + +--- + +## 3. A conta — o tempo da E5 + +| O quê | Tempo-cliente | Base | +|---|---|---| +| Entender a mesa e mexer nas alavancas | ~20 min | estimativa · não há digitação, só sliders | +| Montar e salvar 2–3 cenários | ~15 min | estimativa | +| **Total E5 (Simulador)** | **~35 min** | | + +**Acumulado da jornada: ~13 h 35.** + +É a etapa mais barata da trilha inteira — e a de maior densidade de valor por minuto. **Contanto que +os três defeitos acima não estejam de pé.** + +--- + +## 4. Veredito + +| Capacidade | Selo | +|---|---| +| Simular preço / volume / corte / prazos com o dado dela | ✅ | +| Parte do **mês fechado**, não do vivo | ✅ | +| Linguagem sem jargão + selo de regime por alavanca | ✅ | +| **Concentração vira risco em reais** (perder o maior cliente) | ✅ **no desenho** | +| **Antecipação vira decisão com preço** (economia × buraco 1×) | ✅ | +| **O % do maior cliente é calculado sobre a base inteira** | 🔴 **1.000 linhas, sem order** | +| **A "meta travada" é a meta aprovada** | 🔴 **não filtra status** | +| **O cenário decidido vira ação em algum lugar** | 🔴 **nada lê `projection_scenarios`** | +| Decisões prontas destravadas para a Casa Pilar | 🟠 3 de 5 dependem de dado que ela ainda não subiu | + +**A resposta à pergunta nova da régua é SIM — esta é a tela que a Marlene entende sozinha.** O +parecer reage ao slider em prosa, cada movimento vem com causa→efeito e com o preço junto do ganho, +e nenhuma sigla aparece. + +**E é justamente por isso que os três 🔴 doem tanto.** Eles não atacam a usabilidade: atacam a +**credibilidade** (um número-chave calculado sobre amostra truncada), a **régua** (mede contra um +rascunho chamando-o de "travado") e o **desfecho** (a decisão morre na tela). A Mesa de Decisão é a +melhor peça do produto — e hoje ela é uma mesa **sem saída**. + +--- + +## 5. Achados para o backlog + +| # | Achado | Gravidade | +|---|---|---| +| **E5-A** | **`carregarMaiorCliente` lê sem `.range()` e sem `.order()`** ([`route.ts:70`](../../../src/app/api/projetos/[id]/simulador-dados/route.ts#L70)) — teto de 1.000 linhas do PostgREST, fatia arbitrária, falha silenciosa. **O mesmo defeito em `carregarRetrato`** ([`retrato-service.ts:67`](../../../src/lib/retrato/retrato-service.ts#L67)), que alimenta a concentração da E4. **Correção:** paginar como os outros 11 leitores já fazem — ou empurrar a agregação para um RPC. | 🔴 **alto — número errado com cara de certo, em 2 telas** | +| **E5-B** | **`lerMeta` não filtra `status`** ([`pacing-service.ts:45`](../../../src/lib/pacing/pacing-service.ts#L45)): pega o snapshot de maior `version`, seja ele rascunho ou arquivado. Quatro consumidores — Simulador, Giro, Iniciativas, pacing do Cockpit — e o Giro escreve "travada no Plano de Voo" na tela. Com o **G-11** aberto, o único snapshot possível de um dono self-service é rascunho. **Correção:** `lerMeta` usa `snapshotAprovadoVigente`; sem plano aprovado, os blocos caem no modo "tendência histórica" que o Giro já sabe fazer. | 🔴 **alto — cruza com o G-11** | +| **E5-C** | **O cenário não vira ação.** `projection_scenarios` só é lido pelo próprio Simulador; nada em `iniciativas/`, `htr*` ou `api/ai/htr` o referencia. O `parecer-mesa` abre mão de prescrever *porque* "ação mora no HTR" — e a ponte para o HTR não existe. **Correção:** "virar iniciativa" a partir do cenário salvo, levando as premissas e o delta esperado. | 🔴 **alto — é o R4 da constituição, o mesmo do [E4-E]** | +| **E5-D** | **3 das 5 decisões prontas nascem travadas para a Casa Pilar:** estoque (planilha não subida), custo/cabeça (folha não subida → cai em "declarado"), antecipação (depende do gate — [E3-B](03-fechamento.md)). O texto de destrave está certo em cada card; o ponto é que a tela mais forte do produto chega capada no cliente novo. | 🟠 | +| **E5-E** | `alvoLucro` nasce com **R$ 10.000 hardcoded** (`SimuladorCasaTab.tsx:329`) e o `colchao` é estado local não persistido — some ao trocar de aba. | 🔵 | + +--- + +**Pendente desta etapa:** **E5b — QUANDO (Caixa · Projeção)**, adiada por decisão até o redesenho. +**Próxima:** E6 — PRA ONDE + O QUE FAÇO (Plano · Cockpit · Iniciativas · chat × sistema), que é onde +o E5-C e o E4-E ou se resolvem, ou viram o veredito da trilha. + + +--- + +## Anexo — evidência verificada + +### A mesa + +| O que | Onde | +|---|---| +| Componente (1.708 linhas) | `components/central-dados/SimuladorCasaTab.tsx` | +| Estado das alavancas | `:146-172` — preço, perda declarada, volume, corte, ΔPMR/ΔPMP/ΔPME, eventos, contratar, dívida, estoque, **perder o maior cliente**, **reduzir antecipação** | +| Rótulos sem sigla | `:713-726` — "Clientes pagam" · "Você paga fornecedores" · "Estoque parado" | +| Hint que muda com o dado | `:696-698` — cita o ciclo medido, ou declara que não foi medido | +| Selo de regime | `:1244` — medido / rateado / declarado / estimado | +| Decisões prontas | `:920-1052` | +| "Perder o maior cliente" | `:1000-1024` — nome real, % real, selo de proxy | +| "Reduzir antecipação" | `:1026-1050` — economia **e** o buraco 1× de timing, com prazo medido | +| Cenários salvos | `:1053-1094`; o ícone de download em `:1084` é **"abrir na mesa"**, não exportar | +| Alvo hardcoded | `:329` — `alvoLucro` nasce em R$ 10.000; `colchao` é estado local não persistido | + +### As fontes + +| O que | Onde | Observação | +|---|---|---| +| Realizados | `lib/pacing/pacing-service.ts:62-68` | `dre_monthly_snapshots` com `invalidated_at is null` e maior `version` — **o congelado** | +| Meta | `lib/pacing/pacing-service.ts:45-56` | `order by version desc limit 1` — **sem filtrar `status`** | +| Maior cliente | `app/api/projetos/[id]/simulador-dados/route.ts:70-76` | **sem `.range()` e sem `.order()`** | +| Antecipação medida | mesmo arquivo, `carregarAntecipacao` | lê `dre_detalhamento tipo='antecipacao'`, `.limit(3)` | +| Composição do DTO | `:184` | `lerMeta`, `lerRealizados`, `calcularCaixaAgora`, `listScenarios` em paralelo, cada um degradando isolado | + +### O teto de 1.000 linhas — que este repositório documenta + +| Onde | O que diz / faz | +|---|---| +| `lib/partes-relacionadas/varredura-contraparte.ts:48` | *"Página do PostgREST — sem `.range()` a query volta 1000 linhas em silêncio."* | +| `app/api/projetos/[id]/categorizacao/completude-mensal/route.ts:74` | *"`.limit(100000)` não vence o teto de 1000"* | +| Leitores que **paginam** | `cost-audit-service.ts:407` · `financial-document-service.ts` (×4) · `giro-snapshot-service.ts:275` · `visao-service.ts:161` · `completude-mensal/route.ts:96` · `varredura-contraparte.ts:126` · `library-service.ts:286` — **11 no total** | +| Leitores que **não** paginam | `simulador-dados/route.ts:70` (E5-A) · `retrato-service.ts:67` (E4-J) | + +### Status do plano — o leitor certo existe + +| O que | Onde | +|---|---| +| Valores possíveis | `db/snapshot/schema.sql:7004` — `rascunho · revisado · aprovado · arquivado` | +| Leitor **correto** | `lib/plano-voo/snapshot-vigente.ts:37` — `.eq("status","aprovado")` | +| Leitor **sem filtro** | `lib/pacing/pacing-service.ts:45-56` | +| Consumidores de `lerMeta` | `simulador-dados/route.ts:184` · `lib/giro/giro-view-service.ts:444` · `lib/iniciativas/on-month-closed.ts:129` · `lib/pacing/pacing-service.ts:92,140` | +| A tela que imprime "travada" | `components/giro/GiroView.tsx:265-268` | +| A intenção declarada | `lib/giro/giro-view-service.ts:416` — *"pelo mesmo leitor que o Cockpit usa — nunca uma segunda régua"* | + +### A ponte que não existe + +| O que | Resultado | +|---|---| +| `parecer-mesa.ts:11` | *"R4: NARRA consequência, nunca prescreve ação-raiz (ação mora no HTR)"* | +| Leitores de `projection_scenarios` | só `lib/projection-scenarios/service.ts` e os dois componentes de simulador | +| grep `simulador\|cenario\|scenario` em `lib/iniciativas/`, `htr*`, `api/ai/htr` | **zero** | diff --git a/docs/valor/pente-fino/06-pra-onde-e-o-que-faco.md b/docs/valor/pente-fino/06-pra-onde-e-o-que-faco.md new file mode 100644 index 00000000..ed85a741 --- /dev/null +++ b/docs/valor/pente-fino/06-pra-onde-e-o-que-faco.md @@ -0,0 +1,252 @@ +# E6 — PRA ONDE eu vou, e O QUE eu faço + +**Trilha:** pente fino da jornada real · **Data:** 19/ago/2026 +**Etapa anterior:** [E5 — o Simulador](05-simulador.md) · **Régua:** [E0](00-cliente-e-regua.md) + +> Esta é a etapa que o handoff de 18/ago mandou adiar até a virada SaaS estar executada, porque ela +> audita exatamente o código que a mudança de papéis ia reescrever. A virada está em produção. Esta +> é a leitura sob o modelo definitivo. + +**O que a E6 cobre:** Plano de Voo · Iniciativas · Cockpit · o Consultor IA (chat × sistema). É o +fim da cadeia das 7 perguntas — onde o diagnóstico das E4/E5 vira **rumo** e **ação**. + +> **Navegação:** [índice da pasta](README.md) · [relatório técnico completo](RELATORIO-TECNICO.md) · etapa anterior/próxima no rodapé. + +--- + +## 1. O caminho, verificado + +### 1.1 Plano de Voo — a Marlene gera, e não consegue abrir + +O gate financeiro passou: a virada SaaS destravou o botão de gerar, e ele **não** está atrás de +`isAdmin` ([`plano-voo/page.tsx:340`](../../../src/app/projetos/[id]/plano-voo/page.tsx#L340)). Com +os 32 meses fechados, a Casa Pilar gera o plano. + +**E para aí.** O botão "Abrir Plano completo" — a única porta para o documento — está embrulhado em +`{isAdmin && …}` (`:402-424`), com o motivo escrito no próprio código: + +> *"O deck é toolbar-first (aprovar/revisar/gerar nova versão): processo de consultoria, que I3 não +> expõe ao dono. Fechado à equipe ATR até existir uma versão client-first — o mesmo gate roda na +> rota, então a URL na mão também não passa."* + +E roda mesmo: `documento/page.tsx` redireciona quem não é `PRIVILEGED_ROLES` **antes de qualquer +leitura** ([`:53-58`](../../../src/app/projetos/[id]/plano-voo/documento/page.tsx#L53-L58)). Essa +página é o **único** ponto de montagem de `WorkflowControls` (`:70`), que por sua vez é o **único** +caller de `approvePlanoVooV2Action` +([`actions.ts:411`](../../../src/app/projetos/[id]/plano-voo/actions.ts#L411)) — grep de prova em +todo o `src/`: duas ocorrências, ambas ali. + +**Traduzindo:** o dono gera a própria meta e **não pode travá-la**. E "travar" não é cosmético — é o +sinal que o resto do sistema espera. + +### 1.2 Iniciativas — a porta que devolve + +O gate lê `plano_voo_snapshots.status = 'aprovado'` +([`iniciativas-gate.ts:44-58`](../../../src/lib/iniciativas-gate.ts#L44-L58)), e a rota **redireciona** +quem não passa ([`htr/page.tsx:39-42`](../../../src/app/projetos/[id]/htr/page.tsx#L39-L42)): + +```ts +const gate = await iniciativasDestravadas(id); +if (!gate.destravado) redirect(`/projetos/${id}/plano-voo`); +``` + +O comentário do gate já foi atualizado para o modelo novo — *"o papel deixou de ser etapa do fluxo em +2026-08-18; quem trava a própria meta é o dono"* (`:11-12`). **A regra está certa e o ator não +existe:** o dono é quem deveria travar, e é justamente quem não alcança o botão. + +### 1.3 Cockpit — o ponteiro aponta para a porta fechada + +O Cockpit foi consertado na sessão passada e lê pelo snapshot **vigente**, não pelo projeto inteiro +([`cockpit/route.ts:38-46`](../../../src/app/api/projetos/[id]/cockpit/route.ts#L38-L46)) — leitura +correta. Só que, sem plano aprovado, ela devolve literalmente: + +```ts +if (!snapshotId) return NextResponse.json({ acoes: [], por_semana: {}, semana_atual: 1, … }); +``` + +E ao lado, o card de veredito de caixa acende um CTA sempre que o semáforo não está verde — +`acaoNoHtr: semaforo !== "verde"` +([`veredito-caixa.ts:290`](../../../src/lib/treasury/veredito-caixa.ts#L290)) — que renderiza o +convite ([`VeredictoCaixaCard.tsx:109-124`](../../../src/components/financeiro/cockpit/VeredictoCaixaCard.tsx#L109)): + +> **Ver o que fazer** sobre o aperto de março · **abrir no HTR** ↗ + +**O loop, inteiro:** + +``` +Cockpit: "Ver o que fazer → abrir no HTR" + ↓ +/htr → gate: sem plano aprovado → redirect + ↓ +/plano-voo → "Abrir Plano completo" escondido atrás de isAdmin + ↓ +(não há botão de aprovar para o dono) + ↓ +volta ao Cockpit, que continua dizendo "ver o que fazer" +``` + +A Marlene clica no único CTA de ação do produto e é devolvida ao ponto de partida, **sem uma +mensagem sequer explicando o porquê** — o redirect é mudo. + +### 1.4 O Consultor IA — um chat sem acesso aos dados dela + +O chat vivo é o `ConsultorBadge` → `ConsultorDrawer` → `/api/ai/chat/consultor` +([`ConsultorBadge.tsx:88`](../../../src/components/consultor/ConsultorBadge.tsx#L88)). Ele tem 10 +ferramentas construídas (`src/lib/ai-tools/`: snapshot financeiro, timeline, plano de voo, +benchmark, diagnóstico de análises, memórias…). E elas ligam assim +([`consultor/route.ts:184-191`](../../../src/app/api/ai/chat/consultor/route.ts#L184-L191)): + +```ts +toolsEnabled: analysesCompleted >= 30, +const tools = analysesCompleted >= 30 ? buildConsultantTools({ projectId, supabase }) : undefined; +``` + +**Trinta análises concluídas.** De onde elas viriam? + +| Caminho para `module_analyses.status='concluido'` | Estado | +|---|---| +| `/financeiro/publicar` + `/financeiro/concluir` | **inalcançável** — reverificado nesta sessão: `onPublishDRE` aparece **1 vez** em `ResultsTab.tsx` (a declaração do prop, `:66`) e ` *"Substitui o limiar hardcoded `dossier.analyses_completed >= 30` por um sinal **client-first**: a +> empresa 'vira Consultor' quando o dado real amadurece — os 12 meses da janela fechados."* +> ([`consultor-readiness.ts:5-10`](../../../src/lib/consultor-readiness.ts#L5-L10)) + +A flag `CONSULTOR_V2` está **ON global desde 2026-05-20** (`feature-flags.ts:131`), e +`compileDossier` **carimba** `consultor_readiness` no dossiê +([`context-compiler-service.ts:967-975`](../../../src/lib/context-compiler-service.ts#L967-L975)). + +**Os consumidores que leem a régua nova:** o badge da sidebar (`useProjectSidebarBadges.ts:121`) e o +gate do Plano de Voo (`MIN_SNAPSHOTS`). +**Os que não leem:** as **duas** rotas de chat. Ambas seguem em `analyses_completed >= 30`. + +O número que destravaria a Casa Pilar (12 meses fechados — ela tem 32) está calculado, gravado no +dossiê e a um `if` de distância. **Ninguém o consulta.** + +--- + +## 2. A conta — o tempo da E6 + +| O quê | Tempo-cliente | Base | +|---|---|---| +| Gerar o Plano de Voo | ~5 min | estimativa · botão + espera da geração | +| Tentar abrir o plano / aprovar | **impossível** | verificado no código | +| Executar as 12 semanas no Cockpit | **0 ações** | `cockpit/route.ts:38-46` | +| Usar as Iniciativas | **rota redireciona** | `htr/page.tsx:39-42` | +| Perguntar ao Consultor IA sobre os números dela | responde **sem ferramentas** | `consultor/route.ts:190` | +| **Total E6** | **~5 min, e o fim da linha** | | + +**Acumulado da jornada: ~13 h 40.** + +--- + +## 3. Veredito da E6 + +| Capacidade | Selo | +|---|---| +| O dono gera o próprio Plano de Voo | ✅ | +| Cockpit lê as ações do plano **vigente** (não mistura planos) | ✅ | +| O gate das Iniciativas tem a regra certa (plano aprovado do dono) | ✅ **na regra** | +| **O dono consegue APROVAR / travar a própria meta** | 🔴 **não — nem abrir o documento** | +| **As Iniciativas abrem para o dono** | 🔴 **redirect mudo** | +| **O Cockpit entrega as 12 semanas** | 🔴 **`acoes: []`** | +| **O CTA "ver o que fazer" leva a algum lugar** | 🔴 **volta ao início** | +| **O Consultor IA enxerga os dados da empresa** | 🔴 **limiar estruturalmente inalcançável** | +| A régua client-first (12 meses) governa o Consultor | 🔴 **existe, está ON, e não é lida** | + +**A resposta à pergunta nova da régua nem chega a ser feita.** Não há o que interpretar: as telas do +"pra onde" e do "o que faço" **não abrem**. + +**E é aqui que a trilha inteira cobra a conta.** As E1–E3 custaram ~11h30 de trabalho da Marlene. As +E4 e E5 provaram que o produto sabe diagnosticar — o Capital de giro e a Mesa de Decisão estão entre +as melhores peças de software financeiro que este repositório tem. **Tudo isso desemboca numa porta +trancada.** + +A promessa das 7 perguntas quebra na **6ª**. Não por falta de motor: o motor das Iniciativas existe +(`on-month-closed`), o Cockpit existe, o Consultor tem 10 ferramentas construídas. Quebra por +**três `if`s** — um redirect por papel, um limiar de 30, e um leitor que não foi repontado. + +--- + +## 4. Achados para o backlog + +| # | Achado | Gravidade | +|---|---|---| +| **E6-A** | **O dono não consegue aprovar (travar) a própria meta** — e nem abrir o documento. `plano-voo/page.tsx:402` esconde "Abrir Plano completo" atrás de `isAdmin`; `documento/page.tsx:53-58` redireciona por `PRIVILEGED_ROLES`; essa página é o único mount de `WorkflowControls`, único caller de `approvePlanoVooV2Action`. **Correção:** superfície client-first de aprovação no overview — o ato é "travar minha meta", não "aprovar documento de consultoria". *(É o G-11, agora com a cadeia inteira medida.)* | 🔴 **crítico — quebra a cadeia na 6ª pergunta** | +| **E6-B** | **O loop fechado do CTA.** `acaoNoHtr: semaforo !== "verde"` ([`veredito-caixa.ts:290`](../../../src/lib/treasury/veredito-caixa.ts#L290)) acende "Ver o que fazer → abrir no HTR" sem consultar o gate; o HTR redireciona **sem mensagem**; o Plano de Voo não oferece o botão. **Correção mínima (independe do E6-A):** o CTA consulta `iniciativasDestravadas` e, travado, diz o que falta — e o redirect do HTR carrega o motivo. *(É o G-03, com a consequência medida.)* | 🔴 **alto — pequeno e muito visível** | +| **E6-C** | **O Consultor IA nunca tem ferramentas.** `analysesCompleted >= 30` hardcoded nas duas rotas de chat (`ai/chat/consultor/route.ts:184,190` e `ai/htr/chat/route.ts:198,204`), enquanto o único caminho vivo para `concluido` no Financeiro está morto (E3-F, reverificado) e os módulos que o alimentariam estão OFF (`ENABLE_MODULOS_EXTRAS: false`). Teto estrutural: 10. | 🔴 **alto** | +| **E6-D** | **A régua substituta não foi repontada.** `consultorPronto` (12 meses da janela fechados) existe, `CONSULTOR_V2` está **ON global**, e `compileDossier` carimba `consultor_readiness` no dossiê — mas as rotas de chat leem `analyses_completed`. **Correção:** trocar o leitor nas duas rotas; a Casa Pilar destravaria com folga (32 meses). É o conserto de menor custo e maior efeito de toda a E6. | 🔴 **alto — a solução já está no repo** | +| **E6-E** | **Duas rotas de chat, uma sem UI.** `/api/ai/htr/chat` não tem caller em `.tsx` (a viva é `/api/ai/chat/consultor`); `/api/chat` é um proxy deprecado que aponta para a legada. Some-se a `api/ai/htr/suggest` (G-04, órfã). Três rotas de IA sem consumidor — e o risco é consertar a errada. | 🟠 | +| **E6-F** | **Copy e comentários do modelo antigo no caminho do dono:** o badge "só equipe ATR" no deck do plano e o comentário *"processo de consultoria, que I3 não expõe ao dono"* (`plano-voo/page.tsx:402,428`). Sob SaaS, "equipe ATR" não é etapa de nada — e o comentário descreve a decisão que virou o E6-A. | 🟠 | +| **E6-G** | Sem plano aprovado, `phase-detection` fixa `semana = 0` — toda a máquina de 12/13 semanas do Cockpit fica inerte. Não é defeito próprio: é consequência do E6-A, anotado para não ser diagnosticado duas vezes. | 🔵 nota | + +--- + +**Próxima etapa:** E7 — a cadência de uso (dia · semana · mês · trimestre). Com o E6-A de pé, a +cadência só existe até o fechamento: vale medir o que sobra do ritual quando o "o que faço" não abre. + + +--- + +## Anexo — evidência verificada + +### O gate de aprovação (E6-A) + +| O que | Onde | O que prova | +|---|---|---| +| Gerar **não** é staff-only | `app/projetos/[id]/plano-voo/page.tsx:340` | o botão de gerar está fora do `isAdmin` | +| Abrir o documento **é** | `:402-424` | `{isAdmin && …}` embrulha "Abrir Plano completo" | +| O motivo, no próprio código | `:402` | *"processo de consultoria, que I3 não expõe ao dono. Fechado à equipe ATR até existir uma versão client-first"* | +| Chrome de consultoria | `:428-441` | badge "só equipe ATR" + versão + status + excluir | +| O redirect da rota | `app/projetos/[id]/plano-voo/documento/page.tsx:53-58` | `resolveRole` + `PRIVILEGED_ROLES` → `redirect`, **antes de qualquer leitura** | +| Único mount de `WorkflowControls` | `documento/page.tsx:70` | — | +| Único caller de `approvePlanoVooV2Action` | `WorkflowControls` → `plano-voo/actions.ts:411` | grep no `src/` inteiro: **duas ocorrências, ambas ali** | + +### O que depende de `status='aprovado'` + +| Consumidor | Onde | Sem plano aprovado | +|---|---|---| +| Iniciativas | `lib/iniciativas-gate.ts:44-58` + `app/projetos/[id]/htr/page.tsx:39-42` | **redirect mudo** para o Plano de Voo | +| Cockpit | `app/api/projetos/[id]/cockpit/route.ts:38-46` | devolve `acoes: []` | +| Fase / semana | `lib/phase-detection.ts:22-24` | `semana = 0` — a máquina de 12/13 semanas fica inerte | +| CTA do Cockpit | `lib/treasury/veredito-caixa.ts:290` → `components/financeiro/cockpit/VeredictoCaixaCard.tsx:109-124` | acende assim mesmo — **não consulta o gate** | + +### O Consultor IA + +| O que | Onde | +|---|---| +| Chat vivo | `components/consultor/ConsultorBadge.tsx:88` → `ConsultorDrawer` → `/api/ai/chat/consultor` | +| O gate | `app/api/ai/chat/consultor/route.ts:184` e `:190` — `analysesCompleted >= 30` | +| O mesmo gate na rota legada | `app/api/ai/htr/chat/route.ts:198` e `:204` | +| As 10 ferramentas | `lib/ai-tools/` — snapshot, timeline, plano de voo, benchmark, diagnóstico de análises, memórias, hipóteses, prontidão de DRE | +| Quem escreve `status='concluido'` | `api/…/financeiro/publicar/route.ts:94` · `financeiro/concluir/route.ts:96,130` · `lib/analysis-service.ts:115` · `lib/auto-conclusion-service.ts:230` | +| Caminho do Financeiro | **morto** — ver [E3-F](03-fechamento.md) | +| Caminho de Comercial/Gestão | `app/projetos/[id]/comercial/[analysisKey]/page.tsx` e `gestao/[analysisKey]/page.tsx` — **desligados** por `ENABLE_MODULOS_EXTRAS: false` (`lib/feature-flags.ts:136`) | +| A aritmética, no próprio código | `lib/phase-detection.ts:38-42` — *"com `MODULOS_EXTRAS` OFF isso é 10 … o financeiro maxa em 10 análises"* | + +### A régua substituta (E6-D) + +| O que | Onde | Estado | +|---|---|---| +| A régua nova | `lib/consultor-readiness.ts:5-10` | 12 meses da janela fechados | +| A flag | `lib/feature-flags.ts:131` | `ENABLE_CONSULTOR_V2: true` — **ON global desde 2026-05-20** | +| Onde é carimbada | `lib/context-compiler-service.ts:967-975` | `consultor_readiness` + `consultor_v2_at_compile` no dossiê | +| Quem **lê** | `hooks/useProjectSidebarBadges.ts:121` · gate do Plano (`MIN_SNAPSHOTS`) | ✅ | +| Quem **não lê** | as duas rotas de chat | 🔴 | + +### Rotas de IA sem consumidor + +`api/ai/htr/chat` (sem caller em `.tsx`) · `api/chat` (proxy deprecado que aponta para a legada) · +`api/ai/htr/suggest` (G-04). diff --git a/docs/valor/pente-fino/07-cadencia.md b/docs/valor/pente-fino/07-cadencia.md new file mode 100644 index 00000000..37e1f275 --- /dev/null +++ b/docs/valor/pente-fino/07-cadencia.md @@ -0,0 +1,221 @@ +# E7 — A cadência de uso: dia · semana · mês · trimestre + +**Trilha:** pente fino da jornada real · **Data:** 19/ago/2026 +**Etapa anterior:** [E6 — PRA ONDE + O QUE FAÇO](06-pra-onde-e-o-que-faco.md) · **Régua:** [E0](00-cliente-e-regua.md) + +> **A pergunta desta etapa:** passada a novidade das primeiras semanas, **o que traz a Marlene de +> volta?** As E1–E6 mediram o que o sistema faz quando ela está dentro. A E7 mede o que ele faz +> quando ela **não** está — e qual é o ritmo que o produto ensina. +> +> Régua aplicável: **R9 (hábito-leve)** e **R6 (proativo)**. + +> **Navegação:** [índice da pasta](README.md) · [relatório técnico completo](RELATORIO-TECNICO.md) · etapa anterior/próxima no rodapé. + +--- + +## 1. O que puxa ela de volta — dentro do app + +### 1.1 O selo de frescor é o mecanismo de cadência do produto + +`FrescorSelo` é a peça que sustenta o ritmo, e sustenta bem +([`FrescorSelo.tsx`](../../../src/components/financeiro/FrescorSelo.tsx)): + +| Estado | O que aparece | +|---|---| +| em dia | *"Em dia · fechado até jul"* (verde, sem CTA) | +| pendente | *"Fechado até **jul** · falta fechar **ago**"* + link **Fechar ↗** para a Apuração (`:126-147`) | +| envelhecido | *"ago mudou depois de fechado"* — âmbar, com link e o title explicando que o número guardado é de antes (`:99-118`) | + +Três acertos que valem registro: o mês **corrente** nunca é cobrado (só o último mês **completo**); +o verde nunca mente (se um mês fechado envelheceu, o selo troca de estado em vez de dizer "em dia"); +e ele está montado onde ela olha — Cockpit (`CasaDoGrupoShell.tsx:339`) e todos os painéis +(`PainelCasaTab.tsx:181,223`). + +### 1.2 A sidebar explica o que está trancado + +`useProjectSidebarBadges` devolve, além dos contadores, o **motivo** de cada cadeado +([`useProjectSidebarBadges.ts:126-134`](../../../src/hooks/useProjectSidebarBadges.ts#L126-L134)) — +*"As Iniciativas abrem quando o seu Plano de Voo for aprovado"* — e o `LockBadge` o carrega como +`title`. + +> **Precisão sobre o [E6-B](06-pra-onde-e-o-que-faco.md):** o que é mudo é o **redirect** vindo do +> CTA do Cockpit. A sidebar, essa, explica. Não desfaz o loop — mas a explicação existe em um lugar, +> e é dele que a correção deve partir. + +**Nota:** o Plano de Voo usa a régua client-first correta aqui — `consultorPronto` sobre os 12 meses +da janela (`:121`). É a mesma régua que as rotas de chat ignoram ([E6-D](06-pra-onde-e-o-que-faco.md)). + +--- + +## 2. O que a chama de fora — nada + +**O produto inteiro tem dois e-mails:** boas-vindas (`email-service.ts:77`) e acesso liberado +(`:321`). **Não existe e-mail de lembrete, de mês fechado, de resultado, de alerta.** Se a Marlene +não abrir o sistema, o sistema não a procura. + +E existe, sim, uma máquina construída para isso — que não funciona por **quatro** motivos +empilhados. O cron `monthly-update-reminder` roda **diariamente às 10h BRT** (`vercel.json`), e o +desenho é o certo: dia 25 lembra o cliente do mês que ele deveria fechar; a partir do dia 3, escala. + +| # | O que impede | Evidência | +|---|---|---| +| 1 | **A flag está OFF.** Cada projeto é testado contra `FINANCIAL_SNAPSHOTS_V3` e pulado se não passar (`route.ts:114-118`) | `ENABLE_FINANCIAL_SNAPSHOTS_V3: false` ([`feature-flags.ts:129`](../../../src/lib/feature-flags.ts#L129)) | +| 2 | **Escreve numa tabela que ninguém lê.** `createNotification` grava em **`project_notifications`** ([`notifications-service.ts:105`](../../../src/lib/notifications-service.ts#L105)); grep no `src/` inteiro: o único escritor é o cron, e fora dele a tabela só aparece em `types/supabase.ts` (código gerado). **Zero leitores.** | verificado nesta sessão | +| 3 | **O sino lê outra tabela.** O `TopHeader` (o único sino do produto, com contador e realtime) lê **`notifications`** por `user_id` ([`notification-service.ts:24-26`](../../../src/lib/notification-service.ts#L24-L26)) — outra tabela, outro modelo de destinatário (`user_id` × `recipient_role`). | `TopHeader.tsx:121,426` | +| 4 | **A escalação vai para quem não existe.** A variante de atraso grava `recipientRole: 'admin'` (`route.ts:262`) — "escala para o consultor humano" (`:52`), o papel que a virada SaaS removeu. | — | + +Ou seja: mesmo ligando a flag, a notificação cairia num buraco; mesmo se caísse no lugar certo, o +atraso seria escalado para um consultor inexistente. + +**E o comentário no topo do arquivo mente:** *"Esta rota DISPARA E-MAIL — era a mais perigosa das +três enquanto o guard era fail-open"* (`route.ts:66-67`). Grep por `email|sendEmail` na rota: +**zero**. Ela só cria notificação. É a mesma família do G-05 — comentário que descreve um sistema +que não existe, no arquivo que alguém abriria para entender o lembrete. + +--- + +## 3. A cadência, por horizonte + +### 📅 Diário — não existe, e provavelmente está certo + +Nada no produto pede visita diária. Isso é **coerente com o R9** (hábito-leve) e com o ICP: a +Marlene não vai abrir um sistema todo dia. O único candidato natural — o saldo e a projeção de caixa +— é justamente a tela que sai para redesenho ([E5b](00-cliente-e-regua.md)). + +**O que falta não é o ritual diário: é o produto dizer que não há um.** Nenhuma tela ensina o ritmo +esperado. Ela descobre sozinha, ou não descobre. + +### 📆 Semanal — construída e inerte + +O Cockpit é desenhado em **12 semanas de ações**. Sem plano aprovado, `phase-detection` fixa +`semana = 0` ([`phase-detection.ts:22-24`](../../../src/lib/phase-detection.ts#L22-L24)) e a rota +devolve `acoes: []`. Com o [E6-A](06-pra-onde-e-o-que-faco.md) de pé, **a cadência semanal +simplesmente não roda para nenhum cliente self-service.** + +E o único cron semanal — health score, segundas às 8h — calcula um número que alimenta o +`/command-center`, tela atrás de `ENABLE_COMMAND_CENTER: false` (`feature-flags.ts:133`). **Roda toda +segunda para ninguém.** + +### 🗓️ Mensal — o único ciclo vivo, e ele é bom + +O ritual (subir extrato → categorizar → apurar → fechar) é o loop canônico do produto, e é o único +com sinalização de verdade: o selo de frescor cobra o mês que falta, o chip âmbar cobra o mês que +envelheceu, e ambos linkam para a Apuração. + +**A limitação é de natureza, não de qualidade: é 100% pull.** Funciona para quem entrou. Não faz +nada por quem não entrou — e é exatamente quem não entrou que o produto precisa recuperar. + +### 🎯 Trimestral — não existe + +Grep por `trimestr` no `src/`: fora de rótulos de seletor de período no Retrato/Raio-X +(`JanelaSelector.tsx:15`, `PeriodoSelector.tsx:9`) e de views administrativas, **nada**. Não há +revisão de meta, recalibragem, fecho de ciclo nem retrospectiva. + +A meta é de **12 meses rolante** — e nada no produto marca a passagem do trimestre para perguntar +"a rota mudou?". O horizonte foi definido; a cadência de revisão dele, não. + +--- + +## 4. A conta — o tempo da E7 + +| Horizonte | Tempo-cliente recorrente | Existe? | +|---|---|---| +| Diário | — | não (por desenho) | +| Semanal | — | construído, inerte | +| **Mensal** | **~30–60 min/mês** *(estimativa: ~630 transações novas com as regras já treinadas na E2 + o wizard de 4 etapas da E3)* | ✅ **sim** | +| Trimestral | — | não existe | + +**O custo recorrente é razoável.** Depois das ~13h40 de entrada, o mês seguinte custa menos de uma +hora. **O problema não é o preço da cadência: é que nada a dispara.** + +--- + +## 5. Veredito da E7 + +| Capacidade | Selo | +|---|---| +| Sinal honesto de "falta fechar", com link para a ação | ✅ | +| Sinal de "este mês envelheceu depois de fechado" | ✅ | +| Sidebar explica o que está trancado e por quê | ✅ | +| Custo recorrente compatível com o ICP (<1h/mês) | ✅ estimativa | +| **O sistema chama a cliente de volta (push)** | 🔴 **nunca** | +| **O lembrete mensal construído funciona** | 🔴 **4 bloqueios empilhados** | +| **Cadência semanal** | 🔴 **inerte (E6-A)** | +| **Cadência trimestral** | 🔴 **não existe** | +| O produto ensina qual é o ritmo esperado | 🔴 **em lugar nenhum** | + +**O R9 (hábito-leve) passa; o R6 (proativo) reprova.** + +O sistema é leve — o mês custa menos de uma hora e os sinais dentro dele são honestos e acionáveis. +Mas **o produto é inteiramente reativo**: ele responde muito bem a quem entra, e não faz nada por +quem parou de entrar. Para o ICP — uma dona de 56 anos, que trabalha o dia inteiro na operação e para +quem "olhar o financeiro" nunca foi hábito — **isso é a diferença entre um sistema usado e um sistema +abandonado no terceiro mês.** + +E há uma ironia que resume a etapa: **a máquina de trazê-la de volta foi construída** — cron diário, +dedup por chave, escalonamento por atraso, severidade. Ela está desligada por uma flag, escreve numa +tabela órfã, e escala para um papel que o produto aposentou. **Não falta desenho. Faltam quatro +ligações.** + +--- + +## 6. Achados para o backlog + +| # | Achado | Gravidade | +|---|---|---| +| **E7-A** | **O sistema nunca chama a cliente de volta.** Só existem 2 e-mails (boas-vindas, acesso). Sem push, a cadência depende de ela lembrar sozinha. **Correção:** e-mail mensal de "seu mês fechou / falta fechar", disparado pelo cron que já existe. | 🔴 **crítico para retenção** | +| **E7-B** | **O lembrete mensal tem 4 bloqueios empilhados:** flag `FINANCIAL_SNAPSHOTS_V3` OFF · grava em `project_notifications`, tabela **sem nenhum leitor** · o sino do TopHeader lê `notifications` (outra tabela, outro modelo de destinatário) · a escalação vai para `recipient_role='admin'`, o consultor removido. **Correção:** unificar as duas tabelas de notificação (ou apontar o sino para a certa), trocar a escalação por um segundo aviso ao **dono**, e ligar a flag. | 🔴 **alto — a máquina existe** | +| **E7-C** | **Comentário que mente:** `monthly-update-reminder/route.ts:66` afirma *"Esta rota DISPARA E-MAIL"* — ela não dispara (zero hits de `email` no arquivo). Mesma família do G-05, no arquivo que alguém abre para entender o lembrete. | 🟠 | +| **E7-D** | **Não existe cadência trimestral.** A meta é 12m rolante e nada marca o trimestre para perguntar "a rota mudou?". O `modelo-meta-pacing.md` define o horizonte; a revisão dele não tem dono nem gatilho. **Decidir:** trimestral é ritual de produto ou não é? Se for, ele nasce do fechamento do 3º mês. | 🔴 **decisão de produto** | +| **E7-E** | **O cron semanal alimenta uma tela desligada.** Health score roda toda segunda às 8h e escreve em `health_scores`, cujo consumidor client-facing é `/command-center`, atrás de `ENABLE_COMMAND_CENTER: false`. **Decidir:** ligar a tela ou aposentar o cron. | 🟠 | +| **E7-F** | **O produto não ensina o próprio ritmo.** Nenhuma tela diz "isto aqui é mensal; te chamamos quando o mês virar". Sem isso, a ausência de ritual diário/semanal parece abandono em vez de desenho. **Correção barata:** uma linha no Cockpit e no fim do fechamento. | 🟠 | +| **E7-G** | Todo projeto novo nasce com `projects.mode = 'consultoria'` (default do schema, [`schema.sql:4710`](../../../db/snapshot/schema.sql#L4710)) — inclusive um signup self-service. Hoje é inócuo (os dois crons filtram os três valores), mas é um rótulo errado na porta de entrada do modelo novo. | 🔵 nota | + +--- + +**Próxima etapa:** [E8](00-cliente-e-regua.md) — veredito de maturidade e backlog priorizado. É onde +os achados E1–E7 viram uma ordem de trabalho: o que quebra a promessa, o que a encarece, e o que é +acabamento. + + +--- + +## Anexo — evidência verificada + +### O que puxa (pull) — e funciona + +| O que | Onde | +|---|---| +| Selo pendente com CTA | `components/financeiro/FrescorSelo.tsx:126-147` — *"Fechado até X · falta fechar Y"* + link **Fechar ↗** | +| Selo envelhecido | `:99-118` — *"X mudou depois de fechado"*, com `title` explicando que o número guardado é de antes | +| Regra do mês corrente | `lib/frescor/mes-fechamento.ts` — só o último mês **completo** é cobrado | +| Onde está montado | `CasaDoGrupoShell.tsx:339` (Cockpit) · `PainelCasaTab.tsx:181,223` · `command-center/page.tsx:124` | +| Motivo de cada cadeado | `hooks/useProjectSidebarBadges.ts:126-134` — `planoVooMotivo` e `iniciativasMotivo`, carregados como `title` do `LockBadge` | + +### O que empurra (push) — e não funciona + +| # | O que | Onde | +|---|---|---| +| — | E-mails existentes no produto inteiro | `lib/email-service.ts:77` (boas-vindas) e `:321` (acesso liberado). **Não há mais nenhum** | +| — | Agenda dos crons | `vercel.json` — health score `0 8 * * 1` · leadpool `0 0 * * *` · lembrete `0 13 * * *` | +| 1 | Flag que gateia o lembrete | `lib/feature-flags.ts:129` — `ENABLE_FINANCIAL_SNAPSHOTS_V3: false`; teste por projeto em `monthly-update-reminder/route.ts:114-118` | +| 2 | Onde o lembrete grava | `lib/notifications-service.ts:105` — `project_notifications`. **Zero leitores** no `src/` (fora do `types/supabase.ts` gerado) | +| 3 | Onde o sino lê | `lib/notification-service.ts:24-26` — `notifications`, por `user_id`; UI em `components/TopHeader.tsx:121,426` (com realtime) | +| 4 | Para quem escala | `monthly-update-reminder/route.ts:262` — `recipientRole: 'admin'`; a intenção declarada em `:14` e `:52` (*"escala para o consultor humano"*) | +| — | Desenho do lembrete | `:47-56` — dia 25 avisa o cliente; a partir do dia 3 escala | +| — | **Comentário que mente** | `:66-67` — *"Esta rota DISPARA E-MAIL"*. Grep por `email|sendEmail` no arquivo: **zero** | + +### Semanal e trimestral + +| O que | Onde | Estado | +|---|---|---| +| 12 semanas de ação | `lib/phase-detection.ts:22-24` | `semana = 0` sem plano aprovado | +| Cron de health score | `api/cron/health-score-snapshot/route.ts` | escreve em `health_scores`… | +| …cujo consumidor client-facing | `app/projetos/[id]/command-center/page.tsx:36-39` | …está atrás de `ENABLE_COMMAND_CENTER: false` (`feature-flags.ts:133`) | +| Trimestre | grep `trimestr` no `src/` | só rótulos de seletor de período (`JanelaSelector.tsx:15`, `PeriodoSelector.tsx:9`) e views administrativas | + +### Rótulo herdado + +`db/snapshot/schema.sql:4710` — `projects.mode` tem `DEFAULT 'consultoria'`. Todo projeto novo, +inclusive um signup self-service, nasce com esse rótulo. Hoje é inócuo: os dois crons filtram +`.in('mode', ['consultoria','saas','transicao'])`. diff --git a/docs/valor/pente-fino/08-veredito.md b/docs/valor/pente-fino/08-veredito.md new file mode 100644 index 00000000..f01641b0 --- /dev/null +++ b/docs/valor/pente-fino/08-veredito.md @@ -0,0 +1,206 @@ +# E8 — Veredito de maturidade e backlog priorizado + +**Trilha:** pente fino da jornada real · **Data:** 20/ago/2026 +**Fecha as etapas** [E0](00-cliente-e-regua.md) → [E7](07-cadencia.md) · **Cliente:** Casa Pilar (Marlene) + +> **Pergunta da trilha, respondida:** *o sistema está maduro para a promessa dos 3 buracos e das 7 +> perguntas, ou o que falta?* +> +> **Não está maduro. E o que falta não é o que parecia.** + +**Cobertura declarada:** esta trilha auditou **6 das 7 perguntas**. A pergunta 4 (*"Isso me mata? +Quando?"* — Caixa & Projeção) foi **deliberadamente não auditada** (decisão do Lucas, 19/ago: a tela +vai ser redesenhada, e auditar código que será reescrito é desperdício). Onde ela entra, este doc diz +**⏸️** — não ✅. + +> **Navegação:** [índice da pasta](README.md) · [relatório técnico completo](RELATORIO-TECNICO.md) · etapa anterior/próxima no rodapé. + +--- + +## 1. A cadeia das 7 perguntas, elo a elo + +| # | A pergunta do dono | Onde vive | Auditada em | Veredito | +|---|---|---|---|---| +| 1 | *"Isso aqui é verdade?"* | Apuração | [E1](01-ingestao.md)·[E2](02-categorizacao.md)·[E3](03-fechamento.md) | 🔴 **trava** — o gate exige o valor de face da antecipação, que o extrato não traz | +| 2 | *"Deu lucro? Pra onde foi?"* | Resultado (DRE) | [E4](04-onde-e-porque.md) | 🔴 **responde diferente de si mesmo** — gráfico ao vivo × DRE congelada; ABC com o motor errado | +| 3 | *"Por que o buraco existe?"* | Giro · Endividamento · Clientes | [E4](04-onde-e-porque.md) | 🟡 **2 de 3** — Giro ✅ e Endividamento ✅; Clientes fora do Financeiro e com leitura truncada | +| 4 | *"Isso me mata? Quando?"* | Caixa & Projeção | — | ⏸️ **não auditada** (redesenho) | +| 5 | *"O que eu mexo primeiro?"* | Simulador | [E5](05-simulador.md) | 🟡 **melhor peça do produto**, com o número-chave calculado sobre amostra truncada | +| 6 | *"Pra onde eu vou?"* | Plano de Voo | [E6](06-pra-onde-e-o-que-faco.md) | 🔴 **a dona gera e não consegue travar** | +| 7 | *"Estou indo? O que eu faço?"* | Cockpit · Iniciativas | [E6](06-pra-onde-e-o-que-faco.md) | 🔴 **não abre** | + +**A promessa quebra em três lugares**: na entrada (1), no meio (2) e no fim (6 e 7). E o fim é o que +mata — porque tudo antes dele existe para chegar lá. + +--- + +## 2. Contra os quatro critérios de maturidade do E0 + +| # | Critério | Veredito | +|---|---|---| +| 1 | **Nenhum elo da cadeia é 🔴** | 🔴 **reprova** — quatro elos | +| 2 | **Tempo até a 1ª resposta útil em dias, não meses** | 🔴 **reprova hoje** — não por lentidão: por bloqueio (ver abaixo) | +| 3 | **Nenhum elo exige alguém da ATR** | 🔴 **reprova** — e isto **corrige o E0** | +| 4 | **A cliente sabe o que fazer sem alguém traduzindo** | 🟡 **parcial** | + +### Critério 2 — o tempo não é o problema; o bloqueio é + +O custo medido da jornada é **~13 h 40** até a última tela, e **menos de 1 h/mês** depois +([E7 §4](07-cadencia.md)). Isso é *dias*, não meses — o critério passaria. + +**Só que a Casa Pilar não chega lá.** Ela desconta ~R$ 180 mil/mês em duplicatas, e o gate de +fechamento exige o **valor de face**, que o OFX não credita ([E3 §1.5](03-fechamento.md)). Sem +responder, **nenhum** dos 32 meses fecha — e sem mês fechado não existe DRE, Giro, Endividamento, +Simulador nem Plano. **Um campo de formulário tranca o produto inteiro para todo cliente que +antecipa** — que é o ICP. + +O conserto já está **decidido** (o switch face × taxa, [E3 §5](03-fechamento.md)) e **não +implementado**. + +### Critério 3 — a correção do próprio E0 + +Em 18/ago o E0 registrou: *"os itens 1 e 3 **passam no código** — nenhum elo depende mais de alguém +da ATR. Falta o check ao vivo para selar."* + +**A E6 provou que o item 3 reprova.** O ato que trava a meta — `approvePlanoVooV2Action` — só existe +dentro de `WorkflowControls`, montado numa única página que redireciona quem não é `PRIVILEGED_ROLES` +([`documento/page.tsx:53-58,70`](../../../src/app/projetos/[id]/plano-voo/documento/page.tsx#L53-L58)). + +**Por que passou despercebido:** a virada SaaS inverteu `canOperateProject` (vínculo, não papel) e +reescreveu 139 policies. O gate que sobrou é **outro** — `resolveRole` + `PRIVILEGED_ROLES` + +`redirect`. Grep de um não acha o outro. É a mesma lição da §5.1 do handoff de 18/ago, num formato +novo: **verificar a mutação não basta; é preciso verificar o resultado pelo caminho do usuário.** + +### Critério 4 — a casa sabe escrever; nem toda tela foi escrita assim + +O **Capital de giro** ([E4 §1.1](04-onde-e-porque.md)) e a **Mesa de Decisão** +([E5 §1.2](05-simulador.md)) são a prova: limiar em português, causa antes do número, o preço junto +do ganho, e o pudor de esconder o dado quando a base não sustenta — *"um vale medido sobre parte das +contas sai menor do que o real: é o erro que passa despercebido"*. + +Onde a régua não foi aplicada, o resultado não é feio — é **errado**: a Curva ABC soma débito cru de +32 meses, o Ponto de Equilíbrio calcula sobre um mês parcial, e a concentração sai de mil linhas +arbitrárias. + +--- + +## 3. O diagnóstico, em uma frase + +> **O produto não está imaturo. Está desconectado.** + +**52 achados** nas etapas E1–E7: **24 🔴 · 15 🟠 · 9 🔵 · 2 🟢 · 2 ✅**. Ao classificá-los por *natureza do +conserto*, o padrão aparece: + +| Natureza | Quantos dos 24 🔴 | O que significa | +|---|---|---| +| **Fiação** — a peça existe e não está ligada (ou está lendo a fonte errada) | **10** | E4-A · E4-B · E4-C · E4-J/E5-A · E5-B · E6-B · E6-C+E6-D · E7-B · E2-J | +| **Construção** — falta a peça | **12** | E6-A · E5-C · E4-D · E4-E · E3-A · E3-B · E3-C · E3-D · E7-A · E7-D · E2-B · E2-H | + +Os motores estão construídos e, em geral, **corretos**: a amortização SAC/Price, a imutabilidade +versionada, o stale-by-event, o what-if de preço ≠ volume, o algoritmo de 3 níveis da antecipação, a +detecção de contraparte, o pacing sazonal. Quase nada disso precisa ser reescrito. + +**O que falha é quem lê.** Leitura truncada em 1.000 linhas (E5-A/E4-J), leitura sem filtro de status +(E5-B), leitura de um limiar obsoleto quando a régua nova já está calculada e gravada (E6-D), leitura +ao vivo onde o resto lê o congelado (E4-B), e leitura que **ninguém faz** — `project_notifications` +tem um escritor e zero leitores (E7-B); `projection_scenarios` só é lido por quem o escreveu (E5-C). + +E há um padrão dentro do padrão: **três consertos já existem no repositório e não foram +repontados** — `snapshotAprovadoVigente` (escrito para o Cockpit, ignorado por `lerMeta`), +`consultorPronto` (com a flag ON global desde maio, ignorado pelas rotas de chat), e o padrão de +paginação (aplicado em 11 leitores, ausente em 2). + +--- + +## 4. O backlog priorizado + +**Princípio da ordem:** primeiro **destravar a cadeia** (nada a jusante importa se a promessa quebra +antes), depois **consertar o que responde errado** (credibilidade sustenta tudo), depois **fechar o +ciclo em ação** (é o R4), depois **baratear a entrada**, e por fim acabamento. + +### 🟥 Bloco 0 — sem isto não existe promessa (3 itens) + +| Ordem | Item | Por quê agora | Natureza | +|---|---|---|---| +| **1** | **[E3-B](03-fechamento.md) — o switch da antecipação** | Um campo tranca as 7 perguntas para todo cliente que antecipa. **Decisão já travada; falta implementar.** | construção (média) | +| **2** | **[E6-A](06-pra-onde-e-o-que-faco.md) — a dona trava a própria meta** | Destrava as perguntas 6 e 7 **e** a cadência semanal inteira (E7). Sem ele, tudo que veio antes desemboca numa porta trancada. | construção (média) | +| **3** | **[E6-D](06-pra-onde-e-o-que-faco.md) — repontar o gate do Consultor IA** | Troca `analyses_completed >= 30` (teto estrutural: 10) por `consultorPronto`, que já está calculado e gravado no dossiê. **Duas rotas, um `if` cada.** | fiação (pequena) | + +> **O item 3 é o melhor retorno por esforço da trilha inteira**: a Casa Pilar tem 32 meses fechados e +> destravaria com folga, e o chat passa a enxergar a DRE, a timeline e o plano dela. + +### 🟧 Bloco 1 — o número está errado (5 itens) + +| Ordem | Item | Natureza | +|---|---|---| +| **4** | **[E5-A / E4-J](05-simulador.md) — paginar as duas leituras de `transactions`** (maior cliente + Retrato). O padrão já existe em 11 leitores. | fiação (pequena, 2 call sites) | +| **5** | **[E4-C](04-onde-e-porque.md) — Ponto de Equilíbrio**: referência = último mês fechado; motor = margem de contribuição canônica. | fiação (pequena) | +| **6** | **[E4-A](04-onde-e-porque.md) — Curva ABC** lê as linhas da DRE v3 e recebe o período do Shell. | fiação (média) | +| **7** | **[E4-B](04-onde-e-porque.md) — Resultado lê o snapshot**, como todo o resto (ou rotula a diferença tão visivelmente quanto o chip âmbar). | fiação (pequena) | +| **8** | **[E5-B](05-simulador.md) — `lerMeta` usa `snapshotAprovadoVigente`.** Quatro consumidores hoje chamam rascunho de "meta travada". | fiação (pequena) | + +### 🟨 Bloco 2 — a decisão precisa terminar em ação (R4) (3 itens) + +| Ordem | Item | Natureza | +|---|---|---| +| **9** | **[E6-B](06-pra-onde-e-o-que-faco.md)** — o CTA do Cockpit consulta `iniciativasDestravadas` e diz o que falta; o redirect carrega o motivo (a sidebar já o tem — [E7 §1.2](07-cadencia.md)). | fiação (pequena) | +| **10** | **[E5-C](05-simulador.md)** — "virar iniciativa" a partir do cenário salvo, levando premissas e delta esperado. Fecha a ponte que o `parecer-mesa` **declara** existir. | construção (média) | +| **11** | **[E4-E](04-onde-e-porque.md)** — o veredito de cada tela de diagnóstico aponta para a alavanca ou para uma Iniciativa. | construção (média) | + +### 🟦 Bloco 3 — baratear a entrada e a repetição (5 itens) + +**[E3-A](03-fechamento.md)** herança de antecipação entre meses (32× → 1×) · **[E3-C](03-fechamento.md)** +fechamento em lote · **[E3-D](03-fechamento.md)** refechar histórico como produto, não script (hoje +exige service key — sob SaaS não há quem rode) · **[E4-H](04-onde-e-porque.md)** propor o contrato de +dívida detectado no extrato em vez de pedir "SAC ou Price" · **[E2-J](02-categorizacao.md)/G-06** +reprocessar contraparte pela UI. + +### 🟩 Bloco 4 — o sistema chamar de volta (2 itens) + +**[E7-A](07-cadencia.md)** e-mail mensal · **[E7-B](07-cadencia.md)** desempilhar os 4 bloqueios do +lembrete (flag OFF · tabela sem leitor · sino em outra tabela · escalação para o consultor removido). + +**Depende do Bloco 0:** chamar a Marlene de volta antes de ela conseguir fechar o mês é chamá-la para +a mesma porta trancada. + +### ⬜ Bloco 5 — acabamento + +Os 15 🟠: jargão e copy do modelo antigo (E4-G, E6-F), órfãos de IA (E6-E), comentários que mentem +(E7-C), cron alimentando tela desligada (E7-E), o produto não ensinar o próprio ritmo (E7-F), e a +escada de publicação da DRE (E3-F) — que é **decisão de produto**: religar ou aposentar; manter os +dois lados vivos e desconectados é o pior caso. + +--- + +## 5. O que NÃO fazer agora — e por quê + +| O quê | Por que esperar | +|---|---| +| **G-01 — aging de recebível** | Segue sendo **o item de maior valor do backlog** (a F1 mediu: a dívida que estoura no Brasil é comercial, não bancária). Mas é **feature nova** numa cadeia que quebra em quatro pontos. Construir o 8º cômodo antes de destrancar a porta da frente é o anti-padrão que esta trilha inteira documenta. **Primeiro item depois do Bloco 1.** | +| **[E7-D](07-cadencia.md) — cadência trimestral** | 🔴 real, mas é **decisão de produto** antes de código: existe ritual trimestral ou não? Sem a decisão, qualquer implementação é chute. | +| **Caixa & Projeção (pergunta 4)** | Em redesenho por decisão. Auditar e consertar agora seria trabalho jogado fora — **mas a E5b fica devendo**: a única pergunta da cadeia sem veredito. | +| **G-10 — débito de lint em `features/dre`** | 40 problemas, estável há meses. Não afeta cliente. | + +--- + +## 6. O que a trilha provou — e o que ela custou saber + +**Provou que a tese está certa.** Duas telas — Capital de giro e Mesa de Decisão — respondem "onde +está o dinheiro" e "o que eu mexo primeiro" em português de dono, com o número medido do extrato +dela e o preço da decisão junto do ganho. Isso não é protótipo: é o produto que a F3 promete, +funcionando. **A casa sabe fazer.** + +**Provou que a promessa não chega ao cliente.** Não por falta de motor: por um campo de formulário, +um redirect por papel, um limiar de 30 e um punhado de leitores apontando para a fonte errada. + +**E provou uma coisa sobre o método.** Sete dos oito achados mais graves são **invisíveis a quem lê +o código por dentro** — eles só aparecem quando alguém percorre a jornada como a Marlene percorreria: +tentar fechar 32 meses, clicar no único CTA de ação, perguntar ao chat sobre o próprio caixa, esperar +o sistema chamar de volta. `SUM(t.debito)` está certo como SQL. `analyses_completed >= 30` está certo +como código. **Só estão errados como resposta a uma pergunta que alguém fez.** + +--- + +**Estado da trilha:** E0–E8 fechadas · **E5b (Caixa & Projeção) em aberto**, aguardando o redesenho. +**Próximo passo natural:** executar o Bloco 0 — e, ao terminar, o **check ao vivo** que o +[`metodo.md`](../../atros-v3/metodo.md) exige, porque até aqui toda a prova desta trilha é estática. diff --git a/docs/valor/pente-fino/PLANO-DE-CORRECAO.md b/docs/valor/pente-fino/PLANO-DE-CORRECAO.md new file mode 100644 index 00000000..b62362b7 --- /dev/null +++ b/docs/valor/pente-fino/PLANO-DE-CORRECAO.md @@ -0,0 +1,330 @@ +# Plano de correção — do pente fino ao produto que o cliente opera sozinho + +**Data:** 20/ago/2026 · **Origem:** [`RELATORIO-TECNICO.md`](RELATORIO-TECNICO.md) (52 achados) +**Substitui:** a ordem do §9 daquele relatório — ver §2, a inversão que a decisão D1 impõe. + +> **A frase que governa este plano (D1, Lucas, 20/ago):** *"Tudo o que o sistema faz deve ser feito +> 100% pelo cliente. Ter ou não um consultor não diferencia isso. Se ele não quiser fazer, ele +> contrata a gente — mas se quiser seguir sozinho, o sistema funciona e ponto."* + +--- + +## 0. A jornada canônica (Lucas, 20/ago) — a lei que o plano serve + +Esta é a jornada inteira do cliente. **Não há outra.** Tudo no backlog existe para servi-la; o que +não serve, sai. + +| # | Passo | Estado no código vivo | +|---|---|---| +| 1 | Baixa os OFX no banco dele e sobe no sistema | ✅ E1 mediu: madura e barata | +| 2 | Organiza quais contas são de cada empresa (multi-CNPJ) | ✅ existe (`BankAccountFormModal`/`MoveModal`, cobertura por empresa) | +| 3 | Categoriza tudo, finaliza no **gate**, mês fechado | ✅ motor maduro — mas caro (A3) e travado pela antecipação (E3-B) | +| 4 | Repete até **100% do que subiu** estar categorizado | 🔴 sem lote nem herança (A2) | +| 5 | Mínimo **12 meses**: de M−1 até M−12. **O sistema entende e cobra** | 🟡 entende, **não cobra** — ver §3.1 | +| 6 | Mais de 12 meses: destrava igual; o excedente serve à sazonalidade, não ao plano | ✅ a janela já ignora o excedente | +| 7 | DRE completa dos últimos 12 meses | ✅ motor único v3 | +| 8 | **Não existe "X análises".** Cada tela consome o que é dela + a DRE | 🔴 = **D10**, o modelo morto ainda pilota a IA (§3) | +| 9 | Sinal de que o Plano destravou e ele pode fazer agora | 🟡 existe como **cadeado que abre**, não como chamado | +| 10 | Analisa a meta que o sistema indica **e pode mexer nela** | 🔴 **não existe** — ver §3.2 | +| 11 | **Trava a meta** → popula Iniciativas e Cockpit | 🔴 = E6-A (a porta; o motor já está pronto) | +| 12 | Endividamento entra **pelo gate**: categorizou pagamento de empréstimo → cadastra o contrato ali | ✅ **ligado** — `ListaPorBancoView` → `CadastroContratoInline` (porta única). Resta o E4-H (pede "SAC ou Price" em vez de propor) | +| 13 | Fora isso, o cliente quase não preenche mais nada. As telas mostram, com o cálculo certo | 🔴 = A4 (as 4 leituras que erram o número) | +| 14 | Ele pode usar o Simulador e o chat | 🟡 melhor peça do produto; o chat está amordaçado (§3) | + +**Duas consequências para o escopo:** + +- **As telas estão dadas.** Motor, consumidor, lógica, cálculo: melhorar, corrigir e criar quando + precisar — mas o conjunto de telas e informações já existe. Isso confirma a forma do plano + (**fiação e correção**, não construção de cômodo novo) e mantém o **G-01 parqueado**. +- **Não há ritual trimestral nesta jornada.** O **E7-D** deixa de ser decisão pendente: sai do + backlog até que a jornada peça. + +--- + +## 1. As quatro decisões travadas em 20/ago + +| # | Decisão | O que fica travado | +|---|---|---| +| **D1** | **Autonomia total desde o dia 1.** O consultor é serviço opcional vendido na mesa, nunca dependência do produto. O mundo-alvo inclui **vídeos explicativos** — a cliente entra sabendo o que fazer e por quê | Todo passo da jornada precisa ter caminho de cliente. Um 🟡 "exige alguém da ATR" é 🔴 | +| **D2** | **E6-A — ato próprio no overview.** Um "travar minha meta" em `/plano-voo` chamando `approvePlanoVooV2Action`; `/documento` continua ATR-only com o console editorial (10 narrativas + parecer) | A dona nunca vê a superfície editorial da ATR | +| **D3** | **E3-F — aposentar a escada de publicação.** O gate de fechamento é o único ato; um mês fechado **é** a publicação | Remover `onPublishDRE`/`PublishDREModal`; repontar `get-analysis-diagnosis` para ler mês fechado, não `status='concluido'` | +| **D4** | **E5b — Caixa & Projeção entra depois da leva "o número está certo".** Lucas desenha o visual (padrão do Capital de giro); os motores são preparados em paralelo | A única das 7 perguntas sem veredito continua devendo até lá | +| **D10** | **O modelo de 30 análises está morto.** *"O sistema vivo funciona só com o que tem nas telas; cada tela e os motores servem para mostrar aquilo. Nada que não está ali deve ter influência ou peso."* | `analyses_completed` sai como sinal do sistema — não é trocar limiar, é aposentar. Detalhe em §3 | +| **D11** | **Mexer na meta = mexer na RECEITA.** O cliente digita o faturamento-alvo (ou o % de crescimento) dos 12 meses; o motor redistribui pela sazonalidade e **recalcula resultado, margem e endividamento em cima** | Um campo, uma decisão. O sistema continua dono da coerência entre as 4 métricas — o cliente não consegue travar combinação impossível | + +--- + +## 2. O que a D1 inverte no backlog original + +O §9 do relatório pôs **"baratear a entrada e a repetição"** no **Bloco 3 (P4)**, com o argumento de +que a corrente quebrada vem antes. O argumento continua certo **para a corrente** — e errado para a +ordem, sob autonomia total: + +> A jornada da cliente sozinha é **ingestão → categorização → apuração**, tudo na **primeira sessão**. +> Se fechar 32 meses são 32 wizards, ela desiste **antes de ver o primeiro número**. Consertar a Curva +> ABC de uma DRE que ela nunca vai gerar não a segura. + +**Três achados sobem de P4 para P2:** + +| Achado | Por que vira bloqueio sob D1 | +|---|---| +| **E3-C** — não existe fechamento em lote | 32 meses = 32 wizards. É o ponto de abandono da sessão 1 | +| **E3-A** — a antecipação não herda nada do mês anterior | A mesma pergunta 32 vezes, dentro dos 32 wizards. Compõe com o E3-C | +| **E3-D** — refechar histórico é script com **service key** | **O mais grave dos três.** Medido: 4 refechamentos em massa em 2 meses — a ATR rodou para os próprios clientes. Sob D1 **não há quem rode**: a cliente que recategoriza depois de fechar fica travada para sempre | + +E um quarto sobe junto, da categorização: + +| Achado | Por que vira bloqueio sob D1 | +|---|---| +| **E2-B** — regras da casa são regex, regras da cliente são igualdade exata | A assimetria **é** a causa da cauda. Sob D1, a cauda inteira é hora da cliente: **1.497 decisões para 20% das linhas** (medido em PRUDENTE & SILVA) | + +--- + +## 3. Duas correções ao relatório (conferidas no código vivo, 20/ago) + +**E6-A é menor do que está escrito.** Classificado como "construção (média)". O server já é +client-first: [`actions.ts:411-431`](../../../src/app/projetos/[id]/plano-voo/actions.ts#L411-L431) +usa `canOperateProject` (vínculo, não papel) **e** já tem gate de qualidade (`planoVooDestravado`). +A única barreira é o `redirect` por papel em +[`documento/page.tsx:53-58`](../../../src/app/projetos/[id]/plano-voo/documento/page.tsx#L53-L58). +**O motor de aprovação já está pronto; falta a porta** — e a D2 diz onde ela fica. + +**E6-D não é um limiar errado — é um modelo morto ainda pilotando a IA.** *(Decisão D10, Lucas, +20/ago: "30 análises é coisa do passado; o sistema vivo funciona só com o que tem nas telas. Nada que +não está ali deve ter influência ou peso.")* + +O `30` vem do modelo de 3 módulos × 10 análises. Hoje `MODULOS_EXTRAS` está **OFF** — comercial e +gestão não existem no produto vivo — então o teto é 10. Só que **o número real é zero**: os únicos +escritores de `analyses.status='concluido'` no financeiro são +[`/financeiro/concluir:96`](../../../src/app/api/projetos/[id]/financeiro/concluir/route.ts#L96) e +[`/financeiro/publicar:94`](../../../src/app/api/projetos/[id]/financeiro/publicar/route.ts#L94) — as +duas rotas que o E3-F mediu **sem caller no front**. É a mesma escada que a **D3 manda aposentar**. + +**A consequência é uma mordaça, não uma limitação.** Com `analysesCompleted = 0`, o primeiro `if` da +máquina de fases vence sempre (`0 < onboardingCutoff = 3`): **a fase nunca sai de `onboarding`** — +nem com 32 meses fechados, plano aprovado e semana 5 em execução. E +[`ai-prompts.ts:53-56`](../../../src/lib/ai-prompts.ts#L53-L56) manda para a IA: +*"modo AUXILIAR. NÃO faça análises estratégicas. NÃO interprete dados financeiros."* +A falta de ferramentas (`>= 30`) é a **segunda** camada; a mordaça é a primeira, e independente. + +**Os pontos a limpar:** + +| Onde | O quê | +|---|---| +| `consultor/route.ts:174,184,191` · `htr/chat/route.ts:186,198,205` | `>= 30` gateia memórias e tools → passa a `consultorPronto` | +| `htr/chat/route.ts:383,397,401` | copy que **mente**: *"X/30 análises feitas"* na cara da cliente | +| `phase-detection.ts:73` | `totalAnalysesExpected = 30` cravado (o compiler já corrigiu para `módulos × 10`) | +| `context-compiler-service.ts:928-936` | fases `onboarding`/`diagnostico` derivam de contagem de análise → devem derivar de **meses fechados na janela** | +| `ai-prompts.ts:25,53-56` | *"concluir as 30 análises para destravar o Consultor Pleno"* + o guardrail que proíbe interpretar dado financeiro | +| `company-context-service.ts:166` | injeta `PROGRESSO (X/Y análises)` no prompt | +| `anti-jargon.ts` | filtro que **raspa "X/30" da saída da IA** — esconde o sintoma; fica como cinto, mas a fonte sai | + +**O que substitui:** o estado financeiro vivo — meses fechados na janela de 12m (`consultorPronto`), +que já existe, já está calculado e já está carimbado no dossiê. `execucao`/`revisao` continuam vindo +do plano aprovado (semana), que é sinal de tela real. + +### 3.1 O sistema tranca os 12 meses — não os cobra (passo 5 da jornada) + +A janela **já está certa e é exatamente a que você descreveu**: `janelaPorMesDeEntrada` toma o mês de +entrada, recua um (o mês corrente não fecha) e volta 12. Entrada em **ago/2026 ⇒ jul/2026 … ago/2025** +— sua conta, ao mês. E a âncora na entrada (não no mês corrente) é decisão sua de 28/jul, com o motivo +certo documentado: quem sobe 12 meses retroativos na primeira semana destrava na primeira semana, e a +janela não desliza re-trancando o Plano toda vez que o cliente atrasa um fechamento. + +**O que falta é a cobrança.** `consultorPronto` recebe +[`{ snapshotsAtivos: number }`](../../../src/lib/consultor-readiness.ts#L43-L49) — **só a contagem**. +A lista dos meses que faltam existe em `janela.meses` e é **descartada na chamada**. Por isso o +sistema só sabe dizer *"faltam 5 meses fechados"*; ele é estruturalmente incapaz de dizer *"faltam +jul/2026, mar/2026, fev/2026…"*. E o único lugar onde isso aparece é o **cadeado** (tooltip da +sidebar + estado travado do `/plano-voo`) — passivo, esperando ela clicar. + +Conserto: `Readiness` passa a carregar `mesesFaltantes: string[]`, e a tela de Apuração ganha o +chamado — quais meses, em que ordem. É pré-requisito do passo 5 e barato. + +### 3.2 A meta não é editável (passo 10 da jornada) + +*"O cliente vai analisar e definir a meta com base no que o sistema indica, mas ele pode mexer nessa +meta caso queira."* **Esse ato não existe.** As sete actions do Plano de Voo são: gerar, deletar, ler, +histórico, `saveRevisedVersionAction` (edita **narrativas**), `setCenarioAction` (escolhe entre +conservador/moderado/agressivo) e `approvePlanoVooV2Action` (trava). **Nenhuma move o número da meta.** + +O pente fino não pegou isto — a E6 parou no "ela não consegue nem abrir a porta", então nunca chegou +a testar o que teria depois dela. **Achado novo, fora dos 52.** + +Isso expande o **E6-A**: não é só destravar a aprovação, é `escolher cenário → ajustar → travar` no +mesmo lugar. **A D11 define o ajuste:** um campo de receita (valor-alvo ou % de crescimento); o motor +redistribui pela sazonalidade e recalcula as outras três métricas. O `construirMeta12m` já é puro e já +recebe a série — ele aceita a receita-alvo como entrada sem reescrita. + +**A D3 e a D10 são a mesma aposentadoria.** A escada de publicação é o que escreveria as análises; o +modelo de análises é o que a escada alimentaria. Cortar os dois na mesma leva evita repontar duas +vezes — e é o que a lei de método manda (repoint ANTES de deletar, um leitor por vez). + +--- + +## 3.3 Log de execução — A1, item 1 (D10 + D3) + +| Fatia | O quê | Estado | +|---|---|---| +| **1 — repontar** | fase derivada da janela de 12m · 6 gates → `isModoConsultor` · copy dos prompts · bloco PROGRESSO fora | ✅ feita | +| **2a — a escada** | 8 caminhos deletados; manifestos de tools corrigidos | ✅ feita | +| **2b — o RPC** | `get_dre_readiness` para de ler `module_analyses.publish_status` | ⏳ **migration escrita, Lucas aplica** | +| **3a — o placar** | `dossier.analyses_completed` + `module_progress` (escritos, nunca lidos) e a query que os alimentava | ✅ feita | +| **3b — a view** | `vw_company_context` perde `analyses_completed` e `30 AS analyses_total` | ⏳ **migration escrita, Lucas aplica** | + +**Deletados na 2a** (cada um com grep-de-prova de que só servia à escada): `/financeiro/publicar` · +`/financeiro/concluir` · `PublishDREModal` · `ProjectStatusBadge` (zero callers) · +`dre-publishing-service` · `auto-conclusion-service` (+ teste) · `ai-tools/get-analysis-diagnosis`. + +**Confirmado no código vivo** (não herdado de doc): o mês fecha por `apurar → gate → preview → +concluir`, e quem grava o snapshot é o `POST /dre/fechar-mes` +([`route.ts:341`](../../../src/app/api/projetos/[id]/dre/fechar-mes/route.ts#L341)) — a lógica da +jornada §0, ao pé da letra. A escada de publicação era caminho **paralelo e morto**. + +**Achado da fatia 2, fora dos 52 — e o único que morava no banco:** a tool `getDreReadiness` tirava o +`publish_status` de `module_analyses`, o depósito da escada. Como nada publicava, era **sempre +`'rascunho'`** — e a descrição da tool ensina o modelo que rascunho é *"0-2 meses fechados, sistema +bloqueado"*. Uma cliente com 32 meses apurados perguntava ao chat e era informada de que o sistema +estava travado. + +**Por que a tool `get-analysis-diagnosis` foi removida e não repontada:** ela lia `module_analyses`, +que só a escada escrevia. Repontá-la para a DRE criaria um **terceiro** leitor do que +`getFinancialSnapshot` e `getFinancialTimeline` já servem — duplicar, não consertar. + +### A cobrança dos 12 meses — feita (passos 5 e 9 da jornada) + +`Readiness` passou a carregar **`mesesFaltantes: string[]`**, e a entrada de `consultorPronto` deixou +de ser a contagem (`snapshotsAtivos: number`) para ser a **lista**. Trocar em vez de adicionar campo +opcional foi decisão: opcional faria um caller esquecido apagar a cobrança em silêncio — e o +compilador encontrou, sozinho, os 5 pontos que montavam `Readiness` à mão. + +Novo helper puro `mesesFaltantes(janela, fechados)` em `janela-12m.ts` — o complemento de `cobertura`. +Existe porque **contar não basta**: com a contagem o sistema só sabia trancar ("faltam 5 meses"); com +a lista ele cobra ("faltam jul/2026, mar/2026…"), que é o passo 5. + +A faixa `CobrancaJanela12m` entra na **Apuração**, entre o header do ano e os 12 chips — onde a dona +já está trabalhando, e não no cadeado da sidebar, que é passivo. **O mesmo componente faz os dois +lados:** ao chegar em 12/12 vira o sinal de destravamento com o caminho para o Plano (passo 9) — +porque um sinal que só aparece quando falta algo deixa a dona sem saber que chegou lá. + +Detalhe de desenho que quase escapou: **os chips são de UM ano e a janela cruza dois** (entrada em +ago/2026 ⇒ jul/2026 … ago/2025). Por isso cada mês da faixa carrega o ano e navega sozinho — sem +isso, metade da cobrança apontaria para meses fora da tela. + +O `janela12m` é derivado no `useDreController` do que a tela **já carregou** (`project` + +`snapshots`): nenhuma leitura nova. + +**Falta o check ao vivo desta faixa** — nenhuma prova visual até aqui. + +### E6-A ampliado — feito (passos 10 e 11 da jornada) + +**A porta.** `TravarMetaCard` no overview `/plano-voo`, sem gate de papel: `travarMetaAction` checa +vínculo (`canOperateProject`) e qualidade (`planoVooDestravado`) no caminho de **escrita**, que é onde +o gate tem de estar. O `/plano-voo/documento` continua ATR-only (D2) — é o console editorial (10 +narrativas + parecer), que não é assunto da dona. O ato vem **antes** do bloco da meta: a decisão é o +que ela veio fazer; a curva e o racional sustentam a decisão, não o contrário. + +**O ajuste (D11).** `ajustarReceitaDaMeta` — função pura em `src/lib/plano-voo/ajuste-meta.ts`, 12 +testes. A **mesma função roda no client** (pré-visualização enquanto ela digita) **e no server** (ao +gravar): o número que ela vê é o número que trava, não uma aproximação de tela. + +**Uma correção à D11, achada no motor.** A decisão dizia "o motor redistribui pela sazonalidade e +recalcula resultado, margem e endividamento em cima". No motor como está: + +| Métrica | Segue a receita? | Por quê | +|---|---|---| +| **receita** | — | é o campo | +| **resultado** | **só em `aproximacao`** (`baseline × (1+g)`) | em `recuperacao` mira o ZERO e ignora o crescimento; em `fora_icp` fica parado | +| **margem** | **não** | meta de **nível**, ancorada no melhor histórico da empresa | +| **endividamento** | **não** | idem | + +Isso não é bug: o `modelo-meta-pacing.md` §4 define receita/resultado como **crescimento** e +margem/endividamento como **nível**. Derivar margem da receita seria mudar a lei do modelo, não a +tela. A tela **diz** o que não se move, em vez de deixar a dona achar que mexeu. + +**A gravação.** Aceitar a proposta = `approvePlanoVooV2Action` (v3: só vira o status). Ajustar = +**nova versão** com o número dela, já travada, e a anterior arquivada — assim *o que o motor propôs* +continua registrado ao lado de *o que ela escolheu*. Sobrescrever apagaria a origem do número, e meta +é o que trava Cockpit e Iniciativas. O clone é da **linha inteira** (menos identidade e carimbos de +aprovação), não campo a campo: v2 e v3 têm formas diferentes de documento, e montar o insert a partir +da forma do documento é como se erra numa das duas. + +**Também saiu daqui:** o `GenerateButton` mostrava *"Coletando dados das 30 análises"* para a dona +enquanto o Plano era gerado — copy do modelo morto (D10), no meio deste fluxo. + +**Falta o check ao vivo** — nem a faixa nem este card foram vistos rodando. + +**Depois de aplicar as duas migrations, nesta ordem:** regerar `src/types/supabase.ts` e +`db/ESTRUTURA.md` · **anunciar `getDreReadiness` nos dois manifestos de tools** (hoje ela está +registrada e não anunciada — o modelo tem a ferramenta e não sabe; anunciar antes da migration faria +a IA repetir "sistema bloqueado") · **check ao vivo em projeto novo**, lembrando que o dossiê tem +TTL de 24h: feche um mês antes de perguntar ao chat, senão você lê a fase velha. + +--- + +## 4. Eixo A — a corrente do produto + +**Regra de fechamento de toda leva:** só fecha com **check ao vivo** num projeto real, percorrendo o +caminho da cliente. Toda a prova do pente fino é estática, e o [`metodo.md`](../../atros-v3/metodo.md) +diz que o gate **é** o check ao vivo. Sem isso, a leva não está fechada — está escrita. + +| Leva | O quê | Achados | Fecha quando | +|---|---|---|---| +| **A1 — a corrente completa** | **E6-D + D10 + D3** (aposentar `analyses_completed` e a escada de publicação juntas — 7 arquivos, §3) → **a cobrança dos 12 meses** (§3.1: `mesesFaltantes` + a faixa na Apuração — passos 5 e 9 da jornada) → **E6-A ampliado** (porta no overview + `escolher → ajustar receita (D11) → travar` — passos 10 e 11) → E3-B (switch da antecipação; a conta está em §10.1 do relatório) | 3 🔴 + 2 achados novos (§3.1, §3.2) | a jornada §0 do passo 3 ao 11 percorrida **inteira** num projeto real: mês com antecipação fechado, os meses que faltam nomeados na tela, meta ajustada e travada, Cockpit populado, chat interpretando o caixa dela | +| **A2 — a dona fecha o histórico sozinha** | E3-C lote · E3-A herança · E3-D refechar como produto · E3-E dizer a ordem certa antes, não depois | 3 🔴 + 1 🟠 | N meses fechados **e** um refechamento feito pela UI, sem service key | +| **A3 — a categorização não come a semana dela** | E2-B simetria de regras · E2-H `client_socios` sem escritor · E2-J detector fora da ingestão · **E2-L (trava de ordem: token só depois de partes relacionadas)** | 4 🔴 | a cauda re-medida num projeto real, contra a linha de base do E2 | +| **A4 — o número está certo** | Os 5 do Bloco 1: paginação ×2 (E5-A/E4-J) · PE (E4-C) · ABC (E4-A) · Resultado lê snapshot (E4-B) · `lerMeta` usa `snapshotAprovadoVigente` (E5-B) | 5 🔴, todos fiação | conferência campo a campo contra o snapshot | +| **A5 — Caixa & Projeção** | Redesenho (D4). Lucas desenha; motores preparados antes | ⏸️ E5b | a 7ª pergunta ganha veredito | +| **A6 — a decisão termina em ação (R4)** | E6-B CTA do Cockpit · E5-C cenário vira iniciativa · E4-E veredito aponta alavanca · E4-D concentração no Financeiro | 4 🔴 | — | +| **A7 — o sistema chama de volta** | E7-A e-mail mensal · E7-B desempilhar os 4 bloqueios | 2 🔴 | — | +| **A8 — acabamento** | Os 15 🟠: copy do modelo antigo, órfãos de IA, comentários que mentem, cron da tela desligada | 15 🟠 | — | + +**G-01 (aging de recebível)** — maior valor do backlog — entra **depois da A4**, quando a corrente +já não quebra e o número já não mente. + +--- + +## 5. Eixo B — a máquina de entrada (não estava no relatório) + +O pente fino auditou a **jornada do produto**. Não auditou **como a cliente entra e paga**. Conferido +no código em 20/ago: + +| O quê | Estado real | +|---|---| +| Dependência de pagamento (Stripe/Asaas/Iugu/…) | **nenhuma** no `package.json` | +| Tabela de assinatura / plano / fatura | **nenhuma** no schema | +| Auto-cadastro | **não existe** — `/login` não tem signup; só `/convite` | +| Como uma cliente nasce | consultor cria e aperta "boas-vindas" → `api/onboarding/welcome` gera usuário + link | +| `projects.mode` default | `'consultoria'` (`schema.sql:4710`) — inclusive num signup self-service | + +O próprio comentário de `welcome/route.ts` reconhece o buraco: *"o webhook de pagamento vai precisar +dela também"*. **O webhook não existe.** E a [proposta ao cliente final](../../business/proposta-cliente-final.md) +vende três formas de contratar — a primeira, *Sistema* avulso, é exatamente aquela em que ninguém da +ATR entra. Hoje ela não tem como ser vendida nem cobrada. + +| Leva | O quê | +|---|---| +| **B1 — comprar e entrar** | checkout · webhook de pagamento (o `acesso-service` já foi escrito prevendo isso) · `projects.mode='saas'` no nascimento (E7-G) | +| **B2 — a camada de ensino** | os vídeos explicativos da D1, ancorados no produto. O [guia do cliente novo](../../atros-v3/guia-cliente-conteudo.md) já está em produção e é o gancho | +| **B3 — capacidade e limite** | o que cada plano permite, se e quando a oferta exigir | + +**Os eixos não competem:** A é motor e fiação, B é comercial e conteúdo. + +--- + +## 6. As próximas decisões na mesa + +| # | Decisão | Quando trava | +|---|---|---| +| **D5** | **E2-E — o "resto imaterial".** Agrupar a cauda abaixo de um limiar de valor numa decisão só, sem furar a régua do gate? Sob D1 a cauda é hora da cliente: **1.497 decisões para 20% das linhas** | **antes da A3** | +| **D6** | **Os vídeos** — quem produz, e o produto linka ou embute? | antes da B2 | +| **D8** | **E7-E — Command Center**: ligar a tela ou aposentar o cron semanal | antes da A8 | +| **D9** | G-02 vigilância do crédito (escopo) · G-08 DROP de `learned_rules` | quando couber | + +--- + +## 7. O que continua fora + +**G-10** (lint em `features/dre`, 40 problemas estáveis, não afeta cliente) · **E4-I** +(`PainelAnaliseSections` órfão, parqueado de propósito para o G7 — **não deletar dentro de sessão de +feature**, anti-padrão 6 da lei de método). diff --git a/docs/valor/pente-fino/README.md b/docs/valor/pente-fino/README.md new file mode 100644 index 00000000..a153d5e4 --- /dev/null +++ b/docs/valor/pente-fino/README.md @@ -0,0 +1,66 @@ +# Pente fino da jornada do cliente — índice + +**O que é:** a varredura ponta a ponta do ATR OS pela ótica de um cliente SaaS real, do primeiro +extrato à ação da segunda-feira. Feita entre **17 e 20 de agosto de 2026**, contra o código vivo. + +**O veredito, em uma frase:** *o produto não está imaturo — está desconectado.* + +--- + +## Por onde começar + +| Você quer… | Leia | +|---|---| +| **O veredito, os números e o plano** | **[`RELATORIO-TECNICO.md`](RELATORIO-TECNICO.md)** — autocontido | +| **A ordem de execução decidida (20/ago)** | **[`PLANO-DE-CORRECAO.md`](PLANO-DE-CORRECAO.md)** — substitui a ordem do §9 | +| A ordem de trabalho, só ela | [`RELATORIO-TECNICO.md` §9](RELATORIO-TECNICO.md) ou [`08-veredito.md` §4](08-veredito.md) | +| Todos os 52 achados numa tabela | [`RELATORIO-TECNICO.md` §7](RELATORIO-TECNICO.md) | +| Como uma etapa foi testada, peça por peça | o arquivo da etapa, abaixo | +| A persona e a régua de selos | [`00-cliente-e-regua.md`](00-cliente-e-regua.md) | + +--- + +## Os arquivos + +| Arquivo | Etapa | Veredito de uma linha | +|---|---|---| +| [`RELATORIO-TECNICO.md`](RELATORIO-TECNICO.md) | **compilado** | o documento completo — método, custos, 52 achados, backlog | +| [`00-cliente-e-regua.md`](00-cliente-e-regua.md) | E0 | a Casa Pilar, a régua e os 4 critérios de maturidade | +| [`01-ingestao.md`](01-ingestao.md) | E1 | ✅ a porta é madura e barata (~2 h) | +| [`02-categorizacao.md`](02-categorizacao.md) | E2 | motor maduro; a economia não fecha para todo perfil de cliente | +| [`03-fechamento.md`](03-fechamento.md) | E3 | motor sólido; o caminho do cliente não | +| [`04-onde-e-porque.md`](04-onde-e-porque.md) | E4 | duas telas passam, duas erram o número | +| [`05-simulador.md`](05-simulador.md) | E5 | a melhor peça do produto, e uma mesa sem saída | +| — | E5b | ⏸️ **Caixa & Projeção não foi auditada** — redesenho (decisão 19/ago) | +| [`06-pra-onde-e-o-que-faco.md`](06-pra-onde-e-o-que-faco.md) | E6 | **a cadeia das 7 perguntas quebra aqui** | +| [`07-cadencia.md`](07-cadencia.md) | E7 | R9 (hábito-leve) passa; R6 (proativo) reprova | +| [`08-veredito.md`](08-veredito.md) | E8 | veredito de maturidade + backlog priorizado | + +--- + +## As regras que a varredura seguiu + +``` +simular o uso → perguntar ao código → selar ✅/🟡/🔴 → medir o tempo do CLIENTE +``` + +- **Nenhuma afirmação sem `arquivo:linha`.** +- **Documento não é evidência** — tudo herdado de doc foi reverificado no artefato vivo. +- **Estimativa é rotulada como estimativa**, com a premissa junto. Onde havia dado de produção, o + dado substituiu o modelo — e isso mudou conclusões (a E2 estava errada por um fator de ~4). +- **Sob SaaS, 🟡 é defeito**, não ressalva: "exige alguém da ATR" significa trabalho sem dono. + +--- + +## O que fazer com isto + +**Bloco 0 do backlog** — sem estes três, a promessa não existe: + +1. **E3-B** — o switch da antecipação *(decisão travada em 19/ago; falta implementar)* +2. **E6-A** — a dona travar a própria meta +3. **E6-D** — repontar o gate do Consultor IA *(duas rotas, um `if` cada)* + +Detalhe e ordem completa em [`RELATORIO-TECNICO.md` §9](RELATORIO-TECNICO.md). + +> **Dívida de método:** toda a prova desta trilha é **estática**. O `metodo.md` diz que o gate É o +> check ao vivo — o Bloco 0 só fecha com ele. diff --git a/docs/valor/pente-fino/RELATORIO-TECNICO.md b/docs/valor/pente-fino/RELATORIO-TECNICO.md new file mode 100644 index 00000000..6a970039 --- /dev/null +++ b/docs/valor/pente-fino/RELATORIO-TECNICO.md @@ -0,0 +1,723 @@ +# Pente fino da jornada do cliente — Relatório técnico completo + +**Sistema auditado:** ATR OS · **Período da varredura:** 17 a 20 de agosto de 2026 +**Escopo:** a jornada ponta a ponta de um cliente SaaS, do primeiro extrato à ação da segunda-feira +**Método:** simulação de uso × leitura do código vivo, com `arquivo:linha` obrigatório +**Cliente-teste:** Casa Pilar (persona construída, [E0](00-cliente-e-regua.md)) + +--- + +## Como usar este documento + +Este arquivo é **autocontido**: quem ler só ele tem o veredito, os números e o plano. Os arquivos +`00-…` a `08-…` desta mesma pasta são o **detalhe por etapa** — a simulação, o mecanismo verificado +peça por peça, a medição e a lista de achados de cada uma. Toda afirmação aqui tem origem rastreável +lá, e de lá para o código. + +| Se você quer… | Vá para | +|---|---| +| O veredito e o plano | §1 e §9 deste arquivo | +| A conta do que custa ao cliente | §5 | +| Todos os achados numa tabela só | §7 | +| O detalhe de uma etapa (como foi testada, o que o código faz) | `0N-*.md` correspondente | +| A régua e a persona | [`00-cliente-e-regua.md`](00-cliente-e-regua.md) | + +--- + +## 1. Sumário executivo + +### 1.1 O veredito + +> **O produto não está imaturo. Está desconectado.** + +A promessa comercial do ATR OS é uma **cadeia de sete perguntas em ordem obrigatória**, que termina +em *"o que eu faço segunda-feira"*. A varredura percorreu a cadeia inteira como um cliente a +percorreria. Resultado: **a cadeia quebra em quatro dos sete elos** — na entrada, no meio e no fim — +e o fim é o que mata, porque tudo antes dele existe para chegar lá. + +**Mas a natureza da quebra é o achado.** De **24 defeitos críticos**, **10 são fiação**: a peça +existe, está construída e correta, e está ligada na fonte errada — ou não está ligada em nada. Três +dos consertos **já estão escritos no repositório** e nunca foram repontados. + +Os motores são bons e, em geral, corretos: amortização SAC/Price com tolerância de match, +imutabilidade versionada de snapshot, invalidação por evento, what-if com preço ≠ volume, algoritmo +de três níveis para antecipação, detecção de parte relacionada, pacing sazonal. **Quase nada precisa +ser reescrito.** + +### 1.2 Os números + +| | | +|---|---| +| Etapas auditadas | **8 de 9** (E0–E8; a E5b ficou de fora por decisão) | +| Achados registrados | **52** — 24 🔴 · 15 🟠 · 9 🔵 · 2 🟢 · 2 ✅ (1 retirado, 1 executado) | +| Críticos que são **fiação** (repoint/ligação) | **10 de 24** | +| Críticos que são **construção** (falta peça) | **12 de 24** | +| Custo de entrada do cliente (perfil Casa Pilar) | **~13 h 40** | +| Custo recorrente mensal | **< 1 h/mês** | +| Elos da cadeia com veredito 🔴 | **4 de 7** | +| Critérios de maturidade aprovados | **0 de 4** | + +### 1.3 As três coisas que precisam acontecer primeiro + +1. **O switch da antecipação** ([E3-B](03-fechamento.md)) — hoje um campo de formulário tranca as + sete perguntas para todo cliente que desconta duplicata, que é o ICP. **Decisão de produto já + travada em 19/ago; falta implementar.** +2. **A dona travar a própria meta** ([E6-A](06-pra-onde-e-o-que-faco.md)) — destrava as perguntas 6 e + 7 e a cadência semanal inteira. +3. **Repontar o gate do Consultor IA** ([E6-D](06-pra-onde-e-o-que-faco.md)) — trocar um limiar + estruturalmente inalcançável pela régua client-first que já existe, está calculada e está gravada + no dossiê. **Duas rotas, um `if` cada.** + +--- + +## 2. Método, escopo e limites + +### 2.1 O rito + +Cada etapa seguiu a mesma sequência, sem exceção: + +``` +simular o uso → perguntar ao código → selar ✅/🟡/🔴 → medir o tempo do CLIENTE +``` + +**Regras de evidência aplicadas:** + +- Nenhuma afirmação sobre o sistema entra sem `arquivo:linha`. +- Documento **não** é evidência. Toda afirmação herdada de doc foi reverificada no artefato vivo + (código via Grep/Read; banco via dump fresco `db/snapshot/`). +- Estimativa é rotulada como estimativa e carrega a premissa junto. Onde havia dado de produção, o + modelo foi substituído pela medição — e isso mudou conclusões (§2.4). + +### 2.2 A régua de selos + +| Selo | Significa | +|---|---| +| ✅ **Pronto** | a cliente faz sozinha, dentro do sistema | +| 🟡 **Assistido** | ~~funciona, mas exige alguém da ATR~~ → **é defeito** | +| 🔴 **Falta** | não existe | + +**Correção de premissa aplicada em 18/ago (Lucas):** o ATR OS é SaaS de uso próprio; o consultor não +é etapa do fluxo. Isso **rebaixou todo 🟡 a defeito** — onde a etapa depende de alguém da ATR, ela +não acontece. A medição passou a ser uma só: **tempo-cliente**. + +**Critério adicional, que virou o principal de E4 em diante:** + +> **O cliente sozinho consegue interpretar o que vê?** Se a tela precisa de alguém explicando, ela +> falhou. + +### 2.3 O que foi e o que não foi auditado + +| Auditado | Não auditado | +|---|---| +| E0 cliente e régua · E1 ingestão · E2 categorização · E3 fechamento · E4 diagnóstico (Resultado, Giro, Endividamento, Concentração) · E5 Simulador · E6 Plano/Cockpit/Iniciativas/chat · E7 cadência · E8 veredito | **E5b — Caixa & Projeção** (pergunta 4 da cadeia) | + +**Por que a E5b ficou de fora:** decisão do Lucas em 19/ago — a tela vai ser redesenhada, e auditar +código que será reescrito é desperdício. **É a única pergunta da cadeia sem veredito**, e este +relatório marca `⏸️` onde ela entra, nunca ✅. + +### 2.4 Ferramental de medição usado + +Todos read-only, todos contra o banco de produção: + +| Script | O que mediu | Onde entrou | +|---|---|---| +| `scripts/diag-grupos-por-transacao.ts` | linhas/grupo por cliente real | E2 §2.1 | +| `scripts/diag-cauda-normalizacao.ts` | cardinalidade real × normalização incompleta | E2 §2.2 | +| `scripts/diag-materialidade-cauda.ts` · `diag-tokens-cauda.ts` | cobertura da cauda por prefixo/sufixo/token | E2 §2.4 | +| `scripts/diag-manually-reviewed-null.ts` | 0 linhas NULL em 18.097 | E2-D (rebaixado) | +| `scripts/diag-fechamento-e3.ts` | meses fechados, versões, gate hard, contratos | E3 §2 | +| `scripts/saas/policies-da-tabela.ts` | conjunto de policies por tabela (elas se somam por OR) | E1 §2.2 | +| `db/snapshot/schema.sql` (dump 18/ago, autoritativo) | RPCs, colunas, defaults, constraints | E4, E7 | + +**Uma correção de método vale registro:** a primeira versão da E2 era um **modelo** (estimativa de +700–1.200 grupos para 20.000 transações). A medição no banco real derrubou o modelo **por um fator de +~4**. Desde então, onde havia dado, o dado ganhou. + +--- + +## 3. O cliente-teste + +**Casa Pilar Distribuidora de Materiais de Construção Ltda.** — Contagem/MG, fundada em 2009, Lucro +Presumido, R$ 520 mil/mês (R$ 6,2 mi/ano), margem líquida ~4,5%, 14 funcionários. + +**Quem decide:** Marlene Ramalho, 56, sócia-administradora. Sabe tudo do negócio e nada de número. +**Quem opera:** Kátia, planilha de entradas e saídas há sete anos. + +| Operação | Valor | Consequência | +|---|---|---| +| Mix | 60% a prazo (construtoras 30/60/90) · 40% balcão | quem manda no prazo é o cliente | +| PMR / PME / PMP | 48 / 55 / 25 dias | **ciclo financeiro 78 dias** | +| NCG | ~R$ 1,35 mi | 2,6 meses de faturamento presos | +| Sicoob | desconto de duplicatas ~R$ 180 mil/mês | **é o que trava a E3** | +| Itaú | conta garantida R$ 250 mil, usada quase todo mês | virou funding permanente | +| BB · CDC · Cartão BNDES | Pronampe ~R$ 40 mil · caminhão ~R$ 95 mil · compras | 5 instrumentos a cadastrar | + +**O que ela traz:** 32 meses (jan/2024 → ago/2026), 4 contas, **~20.000 transações**. + +**O que ninguém na empresa sabe dizer** — e é a régua do valor: quanto o desconto de duplicatas custa +por ano · que a conta garantida a 4,18%/mês virou funding · que **três construtoras são 41% do +faturamento** · se o mês passado deu lucro. + +--- + +## 4. A cadeia das 7 perguntas — veredito por elo + +| # | A pergunta do dono | Onde vive | Auditada em | Veredito | +|---|---|---|---|---| +| 1 | *"Isso aqui é verdade?"* | Apuração | E1·E2·E3 | 🔴 **trava** — o gate exige o valor de face da antecipação, que o extrato não traz | +| 2 | *"Deu lucro? Pra onde foi?"* | Resultado (DRE) | E4 | 🔴 **responde diferente de si mesmo** — gráfico ao vivo × DRE congelada; ABC com o motor errado | +| 3 | *"Por que o buraco existe?"* | Giro · Endividamento · Clientes | E4 | 🟡 **2 de 3** — Giro ✅ e Endividamento ✅; Clientes fora do Financeiro e com leitura truncada | +| 4 | *"Isso me mata? Quando?"* | Caixa & Projeção | — | ⏸️ **não auditada** | +| 5 | *"O que eu mexo primeiro?"* | Simulador | E5 | 🟡 **melhor peça do produto**, com o número-chave sobre amostra truncada e sem saída | +| 6 | *"Pra onde eu vou?"* | Plano de Voo | E6 | 🔴 **a dona gera e não consegue travar** | +| 7 | *"Estou indo? O que eu faço?"* | Cockpit · Iniciativas | E6 | 🔴 **não abre** | + +**A ordem é imposta no código, não é convenção de UX:** Iniciativas exige plano aprovado +(`iniciativas-gate.ts:44-58`); o Plano exige a janela de 12 meses fechada (`plano-voo-gate.ts:62-73`). +**O sistema recusa pular etapa** — o que é desenho correto, e é exatamente por isso que uma quebra no +elo 6 zera os elos 6 e 7 inteiros. + +### 4.1 Os quatro critérios de maturidade + +| # | Critério (do E0) | Veredito | +|---|---|---| +| 1 | Nenhum elo da cadeia é 🔴 | 🔴 **reprova** — quatro elos | +| 2 | Tempo até a 1ª resposta útil em dias, não meses | 🔴 **reprova hoje** — por bloqueio, não por lentidão | +| 3 | Nenhum elo exige alguém da ATR | 🔴 **reprova** — e isto corrige o E0 | +| 4 | A cliente sabe o que fazer sem alguém traduzindo | 🟡 **parcial** | + +**Critério 2 — o tempo não é o problema.** ~13h40 de entrada e <1h/mês depois é *dias*, não meses; o +critério passaria. **Só que a Casa Pilar não chega lá:** ela desconta ~R$ 180 mil/mês, e sem o valor +de face **nenhum** dos 32 meses fecha. Sem mês fechado não existe DRE, Giro, Endividamento, Simulador +nem Plano. + +**Critério 3 — a correção do próprio E0.** Em 18/ago ficou registrado que *"os itens 1 e 3 passam no +código"*. **A E6 provou que o 3 reprova:** `approvePlanoVooV2Action` só existe dentro de +`WorkflowControls`, montado numa única página que redireciona quem não é `PRIVILEGED_ROLES`. + +> **Por que passou despercebido — e é a lição de método da trilha:** a virada SaaS inverteu +> `canOperateProject` (vínculo, não papel) e reescreveu 139 policies. O gate que sobrou é **outro** — +> `resolveRole` + `PRIVILEGED_ROLES` + `redirect`. **Grep de um não acha o outro.** Verificar a +> mutação não basta; é preciso verificar o resultado **pelo caminho do usuário**. + +--- + +## 5. O custo da jornada — todos os números + +### 5.1 Entrada (uma vez) + +| Etapa | Trabalho | Tempo-cliente | Natureza | +|---|---|---:|---| +| **E1** | Gerar 32 meses de OFX em 3 internet bankings (20–30 min, **fora do sistema**) · subir em 1–3 lotes · distribuir 4 contas (~10 min) · conferir cobertura · montar cartão/folha | **~2 h** | estimativa, premissas em E1 §3 | +| **E2** | Categorizar ~20.000 transações | **~4 h 30** | derivado de medição (perfil B2B) | +| **E3** | Cadastrar 4 contratos · vincular parcelas · 32 wizards · **caçar o valor de face no banco (~2 h, fora do sistema)** | **~5 h** | estimativa sobre ritmo medido | +| **E4** | Ler Giro · Resultado · Retrato · **cadastrar 5 contratos de dívida (~1 h)** | **~1 h 35** | estimativa | +| **E5** | Entender a mesa, montar e salvar cenários | **~35 min** | estimativa | +| **E6** | Gerar o Plano — e parar | **~5 min** | verificado | +| **Total** | | **~13 h 40** | | + +### 5.2 A variação que governa tudo: o modelo de negócio do cliente + +Medido em 18.097 lançamentos de produção, agrupados pela mesma chave que a tela usa: + +| Projeto | Meses | Linhas | Grupos | **Linhas/grupo** | Grupos p/ 80% | Singletons | +|---|---:|---:|---:|---:|---:|---:| +| PRUDENTE & SILVA | 16 | 8.182 | 1.808 | **4,5** | 311 | 1.358 (**75%**) | +| Plano de Voo Completo | 12 | 5.859 | 976 | 6,0 | 104 | 685 (70%) | +| **Vertímetal** | 31 | 2.918 | **98** | **29,8** | 52 | 7 (**7%**) | +| DI FORNI PIZZARIA | 16 | 1.138 | 193 | 5,9 | 35 | 111 (58%) | + +**Variação de 6,6×, e não é aleatória.** B2B com contraparte recorrente → ~30 linhas/grupo → +barato. B2C com PIX nominal de pessoa física → ~4,5 → caro. + +> **Consequência comercial:** dá para estimar o custo de onboarding **antes de vender**, rodando +> `diag-grupos-por-transacao.ts` no primeiro extrato. Vira input de precificação, não surpresa no +> dia 10. *(Achado E2-G.)* + +**O que isso faz com a E2:** + +| Cenário | Decisões hoje | Com regra por token | Tempo hoje → depois | +|---|---:|---:|---| +| Balcão via adquirente | ~670 | ~670 | ~4 h 30 (já barato) | +| Misto | ~2.000 | **~700** | ~13 h → **~4 h 40** | +| Balcão em PIX nominal | ~4.400 | **~1.290** | ~29 h → **~8 h 40** | + +**Sete horas é uma semana em janelas. Trinta e uma horas é um mês — e é o ponto de desistência.** + +### 5.3 A cauda, medida + +| | Decisões | Linhas cobertas | +|---|---:|---:| +| Núcleo (Pareto 80%) | **311** | 6.546 (80%) | +| Cauda | **1.497** | 1.636 (20%) | + +**A parte mais cara é obrigatória e é a que menos informa** — o mês só fecha com pendentes = 0. + +E a cauda **tem um nome**: em PRUDENTE & SILVA, um único token (`MAQUININHA`, 100% de pureza de +sentido) cobre **85%** dela — 1.157 decisões idênticas que deveriam ser uma. Em outro cliente, três +tokens cobrem 91%. **O padrão é estrutural:** a cauda é sempre "recebimentos de muita gente +diferente pelo mesmo canal", e o nome do canal é um token estável na descrição. + +### 5.4 Recorrente (todo mês) + +| Horizonte | Custo | Existe? | +|---|---|---| +| Diário | — | não (por desenho — e o produto não diz isso) | +| Semanal | — | construído, **inerte** (depende do E6-A) | +| **Mensal** | **~30–60 min** | ✅ **único ciclo vivo** | +| Trimestral | — | **não existe** | + +### 5.5 O multiplicador escondido + +Medido na Vertímetal: **97% dos meses foram fechados mais de uma vez**, com 27 dos 31 na versão 5. A +distribuição revela o que aconteceu — **30 meses refechados no mesmo dia, quatro vezes em 2 meses**, +por mudança de catálogo ou de motor. + +> Sob consultoria, alguém da ATR rodava `refechar-snapshots-catalogo.ts`. **Sob SaaS não existe esse +> alguém**, e o cliente não tem caminho de lote: seriam 32 wizards de novo, por uma mudança que ele +> não pediu e não percebeu. *(Achado E3-D.)* + +--- + +## 6. Síntese técnica por etapa + +### E1 — A porta: ingestão dos 32 meses · **✅ madura e barata** + +**O que funciona, verificado:** upload múltiplo sem limite (`GroupIngestionTab.tsx:156-165`) · +roteamento automático por `BANK_ID + ACCT_ID`, com conta desconhecida **enfileirada** em vez de +recusada (`ofx-scan-service.ts:1-16`) · dedup por FITID que degrada com elegância quando o banco não +o fornece (`fitid-dedup.ts`) · **âncora de saldo automática do ``** +(`financial-document-service.ts:679-699`) · detecção de mês faltante com alta confiança +(`missing-months.ts`) · cobertura visível das 6 fontes (`central-dados/cobertura.ts`). + +**O achado que quase entrou errado no relatório:** `ofx-import-service.ts` **recusa** conta não +cadastrada com *"Entre em contato com seu consultor"*. Seria bloqueio de self-service — **mas é +código órfão**: zero callers para todos os exports. O fluxo vivo enfileira. *(E1-A.)* + +**A correção de 18/ago:** o 🟡 da distribuição de conta **nunca existiu**. A leitura original olhou +`pending_ofx_update`/`_delete` (staff) e não viu as irmãs `pending_ofx_cliente_*`, que já estavam na +baseline. **Policies permissivas se somam por OR — ler uma isolada engana.** + +**Leitura que desloca o risco:** a coleta não é gargalo (20–30 min), então Open Finance é +conveniência, não destravamento. **Todo o peso do onboarding está na E2.** + +### E2 — A verdade: categorização de ~20 mil transações · **motor maduro, economia não fecha** + +**O que funciona:** o colapso de descrição (`tratarLancamento`) faz três linhas do extrato virarem +uma decisão · agrupamento por lançamento tratado + sentido · **progresso e gate contados em linha, +não em grupo** (honestidade) · uma decisão toca os 32 meses · a regra nasce durável, derivada da +subcategoria · varredura retroativa em duas fases · 49 regras genéricas na importação · e o sistema +**mede a própria automação sem inflar**, com a fórmula na tela. + +**O gargalo, medido:** as 49 regras da casa são **regex**; as regras que o **cliente** cria são +**igualdade exata** — `.eq("lancamento_tratado", regra.pattern)` +(`financial-document-service.ts:721` e `:1952`). **O sistema sabe casar por padrão, mas só para si +mesmo.** É o achado E2-B, e foi o que tornou a regra por token contido existencial em vez de +otimização. + +**O pré-requisito que não pode ser separado:** 795 lançamentos com nome de sócio em um cliente só +(medido nos dois CNPJs da Di Forni, 9.320 transações). Hoje entram no bolo geral parecendo PIX de +qualquer pessoa. **A regra por token só pode rodar depois da detecção de parte relacionada** — sem +isso, aporte de sócio vira receita *(E2-L, trava de segurança)*. E faltam dois elos: `client_socios` +**não tem escritor** *(E2-H)* e o detector **só roda na importação** *(E2-J)*. + +### E3 — O fechamento: 32 meses apurados · **motor sólido, caminho do cliente não** + +**O que funciona, e é a parte difícil:** amortização SAC/Price · auto-linkagem parcela↔contrato por +tolerância (±10% / ±5d) que **sugere e pede confirmação** · vinculação retroativa em massa · +imutabilidade versionada · **stale-by-event** (o mês fechado que envelheceu se declara, em âmbar, com +o texto certo) · fechar dispara três motores (carteira, giro, Iniciativas). + +**O que falha, e é tudo do lado de fora:** + +- **O gate pede o valor de face da antecipação** — `derivarAntecipacaoDoLiquido(bruto, liquido, prazo)` + exige `bruto` e `prazo` sem fallback; sem eles `antValido = false` e **o mês não fecha** + (`useGateForm.ts:503`). O OFX credita o **líquido**; a face está no borderô. **O sistema pergunta à + cliente exatamente aquilo que ela veio perguntar ao sistema.** *(E3-B — decisão travada, §10.)* +- **Nada é herdado entre meses** — quem antecipa responde 32× a mesma coisa *(E3-A)*. +- **Não existe fechamento em lote** — 32 meses, 32 wizards *(E3-C)*. +- **Refechar o histórico é script com service key** — sob SaaS, ninguém roda *(E3-D)*. +- **A escada de publicação é inalcançável** — `ResultsTab` declara `onPublishDRE` e nunca o usa (1 + ocorrência no arquivo, a declaração); ` *"A queda máxima do mês não é exibida. Só X% 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."* + +**Endividamento** vem logo atrás: todo KPI carrega `oQueSignifica` com o limiar em português, e as +seções são perguntas — inclusive *"estou vivendo de adiantar o que ainda nem recebi?"*, que é +literalmente o buraco 3 da Casa Pilar. + +**Onde a régua não foi aplicada, o resultado não é feio — é errado:** + +| Achado | O defeito | Evidência | +|---|---|---| +| **E4-A** | A Curva ABC soma `SUM(t.debito)` cru, sem `linha_dre`/`sinal`/`destino`, sem excluir transferência própria, principal de empréstimo ou intercompany — **a versão do grupo exclui `mov_inter_*`, a individual não exclui nada** — e a chamada **não passa período**, somando os 32 meses | `schema.sql:1516-1530` · `PainelCasaTab.tsx:772` | +| **E4-B** | Na mesma tela, o gráfico lê o RPC **deprecado ao vivo** e o drill lê o **snapshot congelado**. Mexer numa regra move um e não o outro — e só o congelado tem o chip âmbar | `financial-dashboard-service.ts:39` × `dreService.ts:84` | +| **E4-C** | O Ponto de Equilíbrio fixa a referência em **hoje** e usa o proxy L30/L40. Em 19/ago, o PE sai de **19 dias de um mês aberto**. Não falha: **sai, e sai errado** | `financial.ts:350-351,379-382` | +| **E4-D** | A concentração de clientes — o terceiro sintoma declarado da Casa Pilar — **não está no Financeiro**. Mora no Retrato; nenhuma tela do Financeiro linka, e o `ConcentracaoReceitaCard` que existia para isso tem **zero callers** | `page.tsx:63` | +| **E4-J** | E a análise, boa, é alimentada por uma **leitura truncada em 1.000 linhas** | `retrato-service.ts:67` | + +**Três relógios na mesma dobra, nenhum rotulado:** selo de frescor (último mês fechado) · gráfico +(12 meses, ao vivo) · ABC (32 meses inteiros) · drill (mês a mês fechado). + +### E5 — QUAL ALAVANCA: o Simulador · **a melhor peça do produto, e uma mesa sem saída** + +**O que acerta, e que a E4 tinha errado:** parte do **mês fechado** (`lerRealizados` → +`dre_monthly_snapshots`), não do vivo. Os prazos não são PMR/PMP/PME — são *"Clientes pagam"*, *"Você +paga fornecedores"*, *"Estoque parado"*. Selo de regime por alavanca +(medido/rateado/declarado/estimado). Degrada por fonte, com o motivo escrito em cada card. + +**E as duas decisões que valem a tela inteira para a Casa Pilar:** + +- **Reduzir antecipação** — *"Você antecipa R$ X/mês e deixa R$ Y/mês na mesa (taxa Z%). Reduzir 50% + economiza R$ W/mês — mas o caixa espera o prazo normal uma vez: buraco de R$ K no 1º mês (prazo + medido: Nd)."* **Cobra o preço na mesma frase em que oferece o ganho.** +- **Perder o maior cliente** — o teste de estresse da concentração, com nome e % reais, e o selo + honesto de proxy. + +**Os três defeitos, e nenhum é usabilidade:** + +1. **E5-A — o maior cliente sai de 1.000 linhas.** `.select()` sem `.range()` **e sem `.order()`**. O + teto do PostgREST está escrito no próprio repositório — *"sem `.range()` a query volta 1000 linhas + em silêncio"* (`varredura-contraparte.ts:48`) — e é contornado em **11 outros leitores**. Casa + Pilar tem ~7.500 transações na janela. +2. **E5-B — "meta travada" pode ser rascunho.** `lerMeta` pega o snapshot de maior `version` **sem + filtrar `status`**, existindo ao lado `snapshotAprovadoVigente`, que filtra. **Quatro + consumidores:** Simulador, Giro, Iniciativas e o pacing do Cockpit. E o Giro imprime *"a meta … + **travada** no Plano de Voo"*. +3. **E5-C — o cenário não vai a lugar nenhum.** O parecer abre mão de prescrever **por decisão + declarada** (*"R4: NARRA consequência, nunca prescreve — ação mora no HTR"*, `parecer-mesa.ts:11`). + **Mas a ponte não existe:** `projection_scenarios` só é lido pelo próprio simulador. + +### E6 — PRA ONDE eu vou e O QUE eu faço · **a cadeia quebra aqui** + +**O loop fechado, verificado linha a linha:** + +``` +Cockpit: "Ver o que fazer → abrir no HTR" VeredictoCaixaCard.tsx:109 + ↓ +/htr → gate: sem plano aprovado → redirect MUDO htr/page.tsx:39-42 + ↓ +/plano-voo → "Abrir Plano completo" atrás de {isAdmin} page.tsx:402 + ↓ +não existe botão de aprovar para a dona + ↓ +volta ao Cockpit, que continua dizendo "ver o que fazer" +``` + +**E6-A.** A dona gera o plano (o botão **não** está atrás de `isAdmin`, `:340`) e **não consegue nem +abrir o documento**. O código diz o porquê: *"O deck é toolbar-first (aprovar/revisar): processo de +consultoria, que I3 não expõe ao dono. Fechado à equipe ATR até existir uma versão client-first."* +`documento/page.tsx:70` é o **único** mount de `WorkflowControls`, único caller de +`approvePlanoVooV2Action` — duas ocorrências no `src/` inteiro, ambas ali. Sem `status='aprovado'`: +Iniciativas redirecionam, Cockpit devolve `acoes: []`, `phase-detection` fixa `semana = 0`. + +**E6-C — o Consultor IA nunca terá ferramentas.** As duas rotas de chat gateiam em +`analysesCompleted >= 30`. Os dois caminhos para uma análise virar `concluido` estão fechados: o do +Financeiro é a escada de publicação (**morta**, E3-F) e o outro é Comercial/Gestão, com +`ENABLE_MODULOS_EXTRAS: false`. O próprio código sabe da conta: *"com os extras OFF o financeiro maxa +em 10 análises"* (`phase-detection.ts:38-42`). **Teto estrutural 10, limiar 30.** + +**E6-D — e o conserto já está no repositório.** `consultor-readiness.ts` existe exatamente para matar +esse limiar (*"a empresa vira Consultor quando os 12 meses da janela fecham, não quando o checklist +soma 30"*), a flag `CONSULTOR_V2` está **ON global desde 20/mai**, e `compileDossier` **carimba** +`consultor_readiness` no dossiê. **Quem lê a régua nova:** o badge da sidebar e o gate do Plano de +Voo. **Quem não lê:** as duas rotas de chat. + +### E7 — A cadência de uso · **R9 passa, R6 reprova** + +**O que funciona — e é o mecanismo de cadência real do produto:** o `FrescorSelo`. *"Fechado até +**jul** · falta fechar **ago**"* + link **Fechar ↗**; e o âmbar honesto *"ago mudou depois de +fechado"*. Três acertos: o mês **corrente** nunca é cobrado; o verde nunca mente; e ele está montado +onde ela olha. A sidebar complementa, carregando o **motivo** de cada cadeado. + +**O que não funciona: nada chama ela de volta.** O produto inteiro tem **dois e-mails** — +boas-vindas e acesso liberado. + +**E a máquina foi construída.** O cron `monthly-update-reminder` roda diariamente às 10h BRT, com +dedup por chave e escalonamento por atraso. Está parada por **quatro bloqueios empilhados**: + +| # | Bloqueio | Evidência | +|---|---|---| +| 1 | A flag está OFF | `ENABLE_FINANCIAL_SNAPSHOTS_V3: false` (`feature-flags.ts:129`) | +| 2 | Escreve em `project_notifications` — **zero leitores** no `src/` | `notifications-service.ts:105` | +| 3 | O sino do TopHeader lê **`notifications`**, por `user_id` — outra tabela, outro modelo de destinatário | `notification-service.ts:24-26` | +| 4 | A escalação vai para `recipient_role='admin'` — o consultor removido | `route.ts:262` | + +**Ligar só a flag não resolveria:** a notificação cairia num buraco. + +**Por horizonte:** diário não existe (e provavelmente está certo — mas o produto não diz isso) · +semanal é inerte (E6-A), e o cron semanal de health score alimenta uma tela atrás de +`ENABLE_COMMAND_CENTER: false` · **mensal é o único ciclo vivo, e é 100% pull** · trimestral **não +existe** — fora rótulos de seletor de período, nada. + +--- + +## 7. Inventário completo dos 52 achados + +**Legenda de gravidade:** 🔴 crítico · 🟠 relevante · 🔵 observar/latente · 🟢 barato · ✅ retirado +**Natureza:** `fiação` = a peça existe e lê a fonte errada (ou não está ligada) · `construção` = falta peça · `decisão` = precisa de decisão de produto antes de código + +| # | Achado | Grav. | Natureza | +|---|---|:---:|---| +| **E1-A** | `ofx-import-service.ts` é código órfão completo — zero callers; duplica o fluxo vivo com semântica **pior** (recusa em vez de enfileirar) | 🟠 | construção (deleção) | +| **E1-B** | Sem Open Finance a coleta é manual (20–30 min). Conveniência, não bloqueio | 🔵 | construção | +| **E1-C** | Falta guia de exportação de OFX por banco dentro da aba Ingestão | 🟢 | construção | +| **E1-D** | ~~Distribuição de conta exige consultor~~ — **RETIRADO**: o 🟡 nunca existiu (policies se somam por OR) | ✅ | — | +| **E2-A** | ~~`ai-categorizer.ts` morto~~ — **removido em `66771e8`**; sobra o DROP de `learned_rules`, adiado por decisão | ✅ | feito | +| **E2-B** | **A assimetria que explica a cauda:** regras da casa são regex, regras do cliente são igualdade exata | 🔴 | construção | +| **E2-C** | Fase 1 da varredura só toca `categoria_id IS NULL` — corrigir um pattern depois **não re-propaga** | 🟠 | construção | +| **E2-D** | Guarda de NULL inconsistente — **medido e rebaixado**: 0 linhas NULL em 18.097. Dívida latente (coluna sem `NOT NULL`) | 🔵 | fiação | +| **E2-E** | A cauda é 83% das decisões para 17% das linhas, e o gate exige pendentes = 0. Vale desenhar "resto imaterial" | 🟠 | decisão | +| **E2-F** | Após a regra por token sobra a cauda irrepetível (~15%/~9%) — manual **por decisão de produto** | 🔵 | aceito | +| **E2-G** | **Custo de onboarding é previsível antes da venda** — linhas/grupo varia 6,6× e é função de como o cliente recebe | 🟢 | comercial | +| **E2-H** | `client_socios` **não tem escritor** — nada popula a tabela que o detector lê. O QSA já está em `companies.dados_receita` | 🔴 | construção (pequena) | +| **E2-I** | `extrairSocios` descarta `cnpj_cpf_do_socio` — o campo que dá confiança `alta`. Representante legal ignorado | 🟠 | fiação | +| **E2-J** | O detector roda **só** em `confirmarImportacao` — sócio cadastrado depois não alcança o histórico | 🔴 | fiação | +| **E2-K** | Empresa cadastrada à mão tem QSA sem máscara — precisa degradar para nome (`media`), não quebrar | 🔵 | construção | +| **E2-L** | **Trava de segurança:** a regra por token só pode rodar **depois** da detecção de parte relacionada. Sem isso, aporte de sócio vira receita | 🔴 | ordem de execução | +| **E3-A** | A antecipação **não herda nada** do mês anterior — 32× a mesma pergunta | 🔴 | construção | +| **E3-B** | **O valor de face não tem de onde vir** — o OFX credita o líquido. **DECIDIDO 19/ago:** vira switch do cliente no gate (§10) | 🔴 | construção | +| **E3-C** | **Não existe fechamento em lote** — 32 meses, 32 wizards | 🔴 | construção | +| **E3-D** | **Refechar o histórico é script com service key** — medido: 4 refechamentos em massa em 2 meses. Sob SaaS, ninguém roda | 🔴 | construção | +| **E3-E** | A ordem certa (categorizar tudo → depois fechar) **não é dita** — o sistema só avisa depois | 🟠 | construção (1 linha) | +| **E3-F** | **A escada de publicação é inalcançável** — `onPublishDRE` declarado e nunca usado; `PublishDREModal` sem mount; **0 projetos** saíram de `rascunho`. É a causa raiz do E6-C | 🟠 | decisão | +| **E3-G** | Movimentação de sócio é opcional no gate — a DRE pode fechar com `socio_natureza` nulo. Vale medir quantos | 🔵 | medir | +| **E4-A** | **A Curva ABC soma débito cru de todo o histórico**, sem as colunas da DRE e sem período | 🔴 | fiação | +| **E4-B** | **Gráfico ao vivo × DRE congelada na mesma tela**, sem rótulo | 🔴 | fiação | +| **E4-C** | **O Ponto de Equilíbrio calcula sobre o mês corrente parcial** e usa o proxy L30/L40 | 🔴 | fiação | +| **E4-D** | **A concentração de clientes não está no Financeiro**; o card que existia tem zero callers | 🔴 | construção | +| **E4-E** | **Nenhuma das 4 telas de diagnóstico termina em ação** (R4) | 🔴 | construção | +| **E4-F** | A concentração não vira **risco em reais** — mesma lacuna do G-01, pelo lado da receita | 🟠 | construção | +| **E4-G** | Copy que trai o modelo: badge `Live Data`, *"Inicie o diagnóstico DRE Gerencial"*, `DL/EBITDA` cru, *"indicador-rei de alavancagem"* | 🟠 | acabamento | +| **E4-H** | Cadastrar 5 contratos custa ~1 h e pede **"SAC ou Price"** — o gate já faz engenharia reversa de dívida e poderia propor | 🟠 | construção | +| **E4-I** | `PainelAnaliseSections` órfão com `eslint-disable` — parqueado de propósito para o G7. **Não deletar fora de sessão própria** | 🔵 | nota | +| **E4-J** | **A concentração é calculada sobre amostra truncada** (1.000 linhas, sem `order`) — rebaixa o ✅ do motor | 🔴 | fiação | +| **E5-A** | **`carregarMaiorCliente` lê sem `.range()` e sem `.order()`** — mesmo defeito do E4-J, segundo call site | 🔴 | fiação | +| **E5-B** | **`lerMeta` não filtra `status`** — quatro consumidores podem chamar rascunho de "meta travada" | 🔴 | fiação | +| **E5-C** | **O cenário não vira ação** — `projection_scenarios` só é lido por quem o escreveu | 🔴 | construção | +| **E5-D** | 3 das 5 decisões prontas nascem travadas para a Casa Pilar (estoque, folha, antecipação) | 🟠 | dependência | +| **E5-E** | `alvoLucro` R$ 10.000 hardcoded; `colchao` local não persistido | 🔵 | acabamento | +| **E6-A** | **A dona não consegue aprovar (travar) a própria meta — nem abrir o documento.** Quebra a cadeia na 6ª pergunta | 🔴 | construção | +| **E6-B** | **O loop fechado do CTA** — `acaoNoHtr` não consulta o gate; o redirect do HTR é mudo | 🔴 | fiação | +| **E6-C** | **O Consultor IA nunca tem ferramentas** — teto estrutural 10, limiar 30 | 🔴 | fiação | +| **E6-D** | **A régua substituta não foi repontada** — `consultorPronto` existe, flag ON, dossiê carimbado, e as rotas leem `analyses_completed` | 🔴 | fiação | +| **E6-E** | Três rotas de IA sem consumidor (`ai/htr/chat`, `/api/chat` proxy deprecado, `ai/htr/suggest`) — risco de consertar a errada | 🟠 | construção (deleção) | +| **E6-F** | Copy e comentários do modelo antigo no caminho da dona ("só equipe ATR") | 🟠 | acabamento | +| **E6-G** | Sem plano aprovado, `semana = 0` e a máquina de 12/13 semanas fica inerte — consequência do E6-A | 🔵 | nota | +| **E7-A** | **O sistema nunca chama a cliente de volta** — só 2 e-mails no produto inteiro | 🔴 | construção | +| **E7-B** | **O lembrete mensal tem 4 bloqueios empilhados** (flag OFF · tabela sem leitor · sino em outra tabela · escalação para o consultor removido) | 🔴 | fiação | +| **E7-C** | Comentário que mente: *"Esta rota DISPARA E-MAIL"* — zero hits de email no arquivo | 🟠 | acabamento | +| **E7-D** | **Não existe cadência trimestral** — a meta é 12m rolante e nada marca o trimestre | 🔴 | decisão | +| **E7-E** | O cron semanal alimenta uma tela desligada (`ENABLE_COMMAND_CENTER: false`) | 🟠 | decisão | +| **E7-F** | **O produto não ensina o próprio ritmo** — a ausência de ritual diário parece abandono em vez de desenho | 🟠 | construção (1 linha) | +| **E7-G** | Todo projeto novo nasce com `projects.mode = 'consultoria'` — inclusive um signup self-service | 🔵 | nota | + +--- + +## 8. O padrão: os motores × a fiação + +Classificando os **24 críticos** por natureza do conserto: + +| Natureza | Qtd | Quais | +|---|---:|---| +| **Fiação** — a peça existe, lê a fonte errada ou não está ligada | **10** | E4-A · E4-B · E4-C · E4-J · E5-A · E5-B · E6-B · E6-C · E6-D · E7-B · E2-J | +| **Construção** — falta a peça | **12** | E6-A · E5-C · E4-D · E4-E · E3-A · E3-B · E3-C · E3-D · E7-A · E2-B · E2-H · E7-D | +| **Ordem/decisão** | **2** | E2-L (ordem de execução) · E7-D (decisão de produto) | + +### 8.1 As três correções que já existem no repositório e não foram repontadas + +| Peça pronta | Quem a usa | Quem deveria usar e não usa | +|---|---|---| +| `snapshotAprovadoVigente` (filtra `status='aprovado'`) | Cockpit | **`lerMeta`** → Simulador, Giro, Iniciativas, pacing *(E5-B)* | +| `consultorPronto` (12 meses da janela; flag **ON global**; carimbado no dossiê) | badge da sidebar, gate do Plano | **as duas rotas de chat** *(E6-C/E6-D)* | +| Padrão de paginação `.range()` | 11 leitores | **`carregarMaiorCliente` e `carregarRetrato`** *(E5-A/E4-J)* | + +### 8.2 As leituras que ninguém faz + +- **`project_notifications`** — um escritor (o cron), **zero leitores** *(E7-B)*. +- **`projection_scenarios`** — lido só por quem o escreve *(E5-C)*. +- **`onPublishDRE` / `PublishDREModal`** — prop declarado e nunca usado; modal sem mount *(E3-F)*. +- **`ConcentracaoReceitaCard`** — componente sem callers *(E4-D)*. +- **`ofx-import-service`, `ai-categorizer`, `ai/htr/suggest`, `ai/htr/chat`** — órfãos *(E1-A, E2-A, E6-E)*. + +### 8.3 A lição de método + +**Sete dos oito achados mais graves são invisíveis a quem lê o código por dentro.** Eles só aparecem +percorrendo a jornada: tentar fechar 32 meses, clicar no único CTA de ação, perguntar ao chat sobre o +próprio caixa, esperar o sistema chamar de volta. + +`SUM(t.debito)` está certo como SQL. `analysesCompleted >= 30` está certo como código. +**Só estão errados como resposta a uma pergunta que alguém fez.** + +--- + +## 9. Backlog priorizado + +**Princípio da ordem:** primeiro **destravar a cadeia** (nada a jusante importa se a promessa quebra +antes), depois **consertar o que responde errado** (credibilidade sustenta tudo), depois **fechar o +ciclo em ação** (R4), depois **baratear a entrada**, por fim acabamento. + +### 🟥 Bloco 0 — sem isto não existe promessa + +| # | Item | Por quê agora | Natureza | +|---|---|---|---| +| 1 | **E3-B — o switch da antecipação** | Um campo tranca as 7 perguntas para todo cliente que antecipa (o ICP). Decisão travada; falta implementar | construção (média) | +| 2 | **E6-A — a dona trava a própria meta** | Destrava as perguntas 6 e 7 **e** a cadência semanal | construção (média) | +| 3 | **E6-D — repontar o gate do Consultor IA** | Teto estrutural 10 × limiar 30; a régua nova já está calculada e gravada. **Duas rotas, um `if` cada** | fiação (pequena) | + +> **O item 3 é o melhor retorno por esforço da trilha inteira.** + +### 🟧 Bloco 1 — o número está errado + +| # | Item | Natureza | +|---|---|---| +| 4 | **E5-A / E4-J** — paginar as duas leituras de `transactions` (padrão pronto em 11 lugares) | fiação (2 call sites) | +| 5 | **E4-C** — PE: referência = último mês fechado; motor = MC canônica | fiação (pequena) | +| 6 | **E4-A** — ABC lê as linhas da DRE v3 e recebe o período do Shell | fiação (média) | +| 7 | **E4-B** — Resultado lê o snapshot (ou rotula a diferença tão visivelmente quanto o chip âmbar) | fiação (pequena) | +| 8 | **E5-B** — `lerMeta` usa `snapshotAprovadoVigente` | fiação (pequena) | + +### 🟨 Bloco 2 — a decisão precisa terminar em ação (R4) + +| # | Item | Natureza | +|---|---|---| +| 9 | **E6-B** — o CTA consulta `iniciativasDestravadas`; o redirect carrega o motivo (a sidebar já o tem) | fiação (pequena) | +| 10 | **E5-C** — "virar iniciativa" a partir do cenário salvo, com premissas e delta esperado | construção (média) | +| 11 | **E4-E** — o veredito de cada tela de diagnóstico aponta para a alavanca ou para uma Iniciativa | construção (média) | + +### 🟦 Bloco 3 — baratear a entrada e a repetição + +**E3-A** herança de antecipação entre meses (32× → 1×) · **E3-C** fechamento em lote · **E3-D** +refechar histórico como produto · **E4-H** propor o contrato detectado no extrato · **E2-J/G-06** +reprocessar contraparte pela UI · **E2-B + E2-H + E2-L** o pacote da regra por token **na ordem +certa** (sócio primeiro). + +### 🟩 Bloco 4 — o sistema chamar de volta + +**E7-A** e-mail mensal · **E7-B** desempilhar os 4 bloqueios do lembrete. + +**Depende do Bloco 0:** chamar a Marlene de volta antes de ela conseguir fechar o mês é chamá-la para +a mesma porta trancada. + +### ⬜ Bloco 5 — acabamento + +Os 15 🟠: jargão e copy do modelo antigo (E4-G, E6-F) · órfãos de IA e de ingestão (E1-A, E6-E) · comentários que mentem (E7-C) · cron alimentando tela desligada (E7-E) · o produto não +ensinar o próprio ritmo (E7-F) · e a escada de publicação (E3-F), que é **decisão**: religar ou +aposentar — manter os dois lados vivos e desconectados é o pior caso. + +--- + +## 10. Decisões travadas durante a trilha + +| Data | Decisão | Onde mora | +|---|---|---| +| 17/ago | **A categorização é determinística, sem IA.** Regra escrita, não inferência | E2 §5.3 | +| 17/ago | A coleta de OFX leva 20–30 min, não 3–6 h — Open Finance é conveniência | E1 §3 | +| 18/ago | **O ATR OS é SaaS de uso próprio** — todo 🟡 vira defeito | E0 §2 | +| 18/ago | **`COMPROMISSO_ATR` fica** — o fecho promete acompanhamento humano, e isso é **oferta** (consultoria de 90d vendida à parte). **Não re-levantar** | handoff 18b §8 | +| 19/ago | **E3-B — a face deixa de ser obrigatória.** Switch do cliente **dentro do gate**: "tenho o borderô" (face por operação → juros na vírgula) × "sei a taxa que pago" (o sistema deriva a face do líquido) | E3 §5 | +| 19/ago | O campo pede **% ao mês** (como o banco cota), com o prazo fazendo a conversão | E3 §5 | +| 19/ago | **Caixa & Projeção sai do escopo da varredura** — vai ser redesenhada | E5 cabeçalho | + +### 10.1 A conta do switch da antecipação (E3-B), para implementação + +``` +t_período = taxa_a.m. × prazo_dias / 30 (desconto comercial, como o banco cobra) +face = líquido / (1 − t_período) +deságio = face − líquido +``` + +Exemplo: entrou R$ 100.000, taxa 2% a.m., prazo 45 dias → face **R$ 103.092,78**, deságio +**R$ 3.092,78**, custo efetivo **~27,6% a.a.** + +**Invariantes que o teste de regressão deve travar:** + +1. **O líquido nunca é recalculado** — segue sendo o crédito do extrato, ao centavo. +2. No modo taxa, cada linha deriva a própria face pelo próprio crédito (o toggle agregado × detalhado + fica irrelevante). +3. Números derivados de taxa declarada entram com `FonteDado` de estimativa e são rotulados na tela. + +> ⚠️ **Armadilha documentada:** o caminho "cliente informa a taxa" **já existe no motor** (Nível 1, +> `nivelGeral.taxa_media`) e está **morto** (o hook passa `nivelGeral = null`). Religá-lo como está +> erra **em silêncio e para menos**: sem face, `valorBrutoDe` cai no `valor_bruto_real`, que o hook +> preenche com **o líquido do extrato** — e aí `deságio = líquido × taxa` e `líquido resolvido = +> líquido − deságio`, que não bate com o extrato. **A implementação certa é a derivação inversa, não +> o forward.** + +--- + +## 11. Dívidas declaradas e o que não fazer agora + +| O quê | Por quê | +|---|---| +| **E5b — Caixa & Projeção (pergunta 4)** | **Única pergunta da cadeia sem veredito.** Fica devendo até o redesenho | +| **Check ao vivo** | **Toda a prova desta trilha é estática.** O `metodo.md` diz que o gate É o check ao vivo — o Bloco 0 só fecha com ele | +| **G-01 — aging de recebível** | Segue sendo o item de **maior valor** do backlog (a F1 mediu: a dívida que estoura no Brasil é comercial, não bancária). Mas é **feature nova** numa cadeia que quebra em quatro pontos. Construir o 8º cômodo antes de destrancar a porta da frente é o anti-padrão que esta trilha documenta. **Entra logo depois do Bloco 1** | +| **E7-D — cadência trimestral** | 🔴 real, mas é **decisão de produto** antes de código. Sem ela, qualquer implementação é chute | +| **G-10 — débito de lint em `features/dre`** | 40 problemas, estável há meses, não afeta cliente | +| **E4-I — `PainelAnaliseSections` órfão** | Parqueado de propósito para o G7. **Não deletar dentro de sessão de feature** (anti-padrão 6 da lei de método) | + +--- + +## 12. Anexos + +### 12.1 Arquivos-chave citados + +| Camada | Arquivo | Papel | +|---|---|---| +| Ingestão | `lib/ofx-scan-service.ts` · `lib/fitid-dedup.ts` · `lib/missing-months.ts` · `lib/central-dados/cobertura.ts` | fluxo vivo de OFX | +| Categorização | `lib/financial-document-service.ts` (`:721`, `:1952`) · `lib/partes-relacionadas/detector.ts` | regras e detecção | +| Fechamento | `features/dre/hooks/useGateForm.ts` · `lib/dre-detalhamento/antecipacao-multinivel.ts` · `api/…/dre/fechar-mes/route.ts` | gate e motores | +| DRE | `db/snapshot/schema.sql` (`calculate_monthly_dre_v3`, `get_abc_buckets_despesas`) · `features/dre/services/dreService.ts` | motor único v3 × snapshot | +| Diagnóstico | `components/giro/GiroView.tsx` · `components/endividamento/*` · `lib/retrato/client-revenue-analysis.ts` | as telas do "por quê" | +| Simulador | `components/central-dados/SimuladorCasaTab.tsx` · `lib/simulador/parecer-mesa.ts` · `api/…/simulador-dados/route.ts` | a mesa de decisão | +| Plano/Cockpit | `app/…/plano-voo/documento/page.tsx` · `lib/iniciativas-gate.ts` · `lib/plano-voo/snapshot-vigente.ts` · `api/…/cockpit/route.ts` | a cadeia final | +| IA | `api/ai/chat/consultor/route.ts` · `lib/consultor-readiness.ts` · `lib/ai-tools/*` | o chat e seu gate | +| Cadência | `components/financeiro/FrescorSelo.tsx` · `api/cron/monthly-update-reminder/route.ts` · `lib/notifications-service.ts` × `lib/notification-service.ts` | pull × push | + +### 12.2 Índice dos detalhes por etapa + +| Etapa | Arquivo | Veredito de uma linha | +|---|---|---| +| E0 | [`00-cliente-e-regua.md`](00-cliente-e-regua.md) | a persona, a régua e os 4 critérios | +| E1 | [`01-ingestao.md`](01-ingestao.md) | ✅ madura e barata (~2 h) | +| E2 | [`02-categorizacao.md`](02-categorizacao.md) | motor maduro; economia não fecha para todo perfil | +| E3 | [`03-fechamento.md`](03-fechamento.md) | motor sólido; o caminho do cliente não | +| E4 | [`04-onde-e-porque.md`](04-onde-e-porque.md) | duas telas passam, duas erram o número | +| E5 | [`05-simulador.md`](05-simulador.md) | a melhor peça do produto, e uma mesa sem saída | +| E5b | — | ⏸️ não auditada (redesenho) | +| E6 | [`06-pra-onde-e-o-que-faco.md`](06-pra-onde-e-o-que-faco.md) | **a cadeia quebra aqui** | +| E7 | [`07-cadencia.md`](07-cadencia.md) | R9 passa, R6 reprova | +| E8 | [`08-veredito.md`](08-veredito.md) | não maduro: desconectado, não imaturo | + +### 12.3 Reconciliação com o backlog G-01…G-11 + +| G | Estado em 20/ago | Relação com os achados desta trilha | +|---|---|---| +| G-01 aging de recebível | 🔴 aberto | maior valor do backlog; ver E4-F (mesma lacuna pelo lado da receita) | +| G-02 vigilância do crédito | 🔴 aberto (decisão de escopo) | — | +| G-03 ponteiro do Cockpit | 🔴 aberto | **= E6-B**, agora com a consequência medida | +| G-04 rota órfã `ai/htr/suggest` | 🔴 aberto | **⊂ E6-E** | +| G-05 comentários de gate mentindo | ✅ fechado | mesma família de E7-C | +| G-06 partes relacionadas na tomada | 🟡 quase | **= E2-J** | +| G-07 regra por token contido | ✅ fechado | responde a **E2-B** (falta a ordem do E2-L) | +| G-08 remover categorizador por IA | 🟡 metade | **= E2-A** (DROP de `learned_rules` adiado por decisão) | +| G-09 SaaS sem consultor | ✅ fechado | executado; **mas E6-A mostra que o gate de aprovação escapou** | +| G-10 lint em `features/dre` | 🔴 aberto | fora de escopo do cliente | +| G-11 dono não aprova a meta | 🔴 aberto | **= E6-A**, promovido de item parqueado a bloqueio nº 1 | + +--- + +**Fim do relatório.** Detalhe por etapa nos arquivos `00-…` a `08-…` desta pasta. diff --git a/scripts/diag-cauda-normalizacao.ts b/scripts/diag-cauda-normalizacao.ts new file mode 100644 index 00000000..b9e690cd --- /dev/null +++ b/scripts/diag-cauda-normalizacao.ts @@ -0,0 +1,126 @@ +/** + * READ-ONLY. Diagnostica POR QUE um projeto tem cauda longa de grupos com uma + * única ocorrência (achado da E2 do pente fino). + * + * Duas causas possíveis, com remédios opostos: + * (A) CARDINALIDADE REAL — são contrapartes genuinamente distintas (PIX de + * pessoas físicas diferentes). Nada a consertar no código; é a natureza + * do negócio. O remédio é produto (sugestão por IA / agrupar imateriais). + * (B) NORMALIZAÇÃO INCOMPLETA — o mesmo evento gera descrições diferentes + * porque sobrou um token variável que NÃO é dígito (e portanto escapou do + * `.replace(/[0-9]/g,"")` de `tratarLancamento`). Aqui há bug, e cada + * token consertado colapsa dezenas de grupos. + * + * O teste: pegar os grupos de 1 ocorrência e re-agrupar por PREFIXO de N + * palavras. Se muitos singletons colapsam num prefixo comum, é (B). + * + * Uso: npx tsx scripts/diag-cauda-normalizacao.ts [--projeto=] + */ +import { readFileSync } from "node:fs"; +import { createClient } from "@supabase/supabase-js"; + +const env: Record = {}; +for (const line of readFileSync(".env.local", "utf8").split(/\r?\n/)) { + const m = line.match(/^([A-Z0-9_]+)\s*=\s*(.*)$/); + if (!m) continue; + let v = m[2].trim(); + if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1); + env[m[1]] = v; +} +const supabase = createClient(env.NEXT_PUBLIC_SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY); + +const PAGE = 1000; +const alvo = process.argv.find((a) => a.startsWith("--projeto="))?.split("=")[1] ?? "PRUDENTE"; + +/** Máscara para não vazar nome de contraparte no relatório. */ +function mascarar(s: string): string { + return s + .split(" ") + .map((w, i) => (i < 2 || w.length <= 3 ? w : w[0] + "·".repeat(Math.min(w.length - 1, 4)))) + .join(" "); +} + +/** Linha de `projects` usada só para resolver o alvo por id ou nome. */ +interface ProjetoRef { id: string; nome: string | null } + +async function main() { + const { data: projetos } = await supabase.from("projects").select("id, nome"); + const proj = (projetos ?? []).find( + (p: ProjetoRef) => p.id === alvo || String(p.nome ?? "").toUpperCase().includes(alvo.toUpperCase()), + ); + if (!proj) { + console.error(`Projeto não encontrado para "${alvo}".`); + process.exit(1); + } + + console.log("=".repeat(78)); + console.log(`E2 — anatomia da cauda: ${proj.nome}`); + console.log("=".repeat(78)); + + const linhas: { lancamento_tratado: string | null; credito: number | null }[] = []; + for (let from = 0; ; from += PAGE) { + const { data, error } = await supabase + .from("transactions") + .select("lancamento_tratado, credito") + .eq("project_id", proj.id) + .order("id", { ascending: true }) + .range(from, from + PAGE - 1); + if (error) throw new Error(error.message); + const lote = data ?? []; + linhas.push(...(lote as { lancamento_tratado: string | null; credito: number | null }[])); + if (lote.length < PAGE) break; + } + + const grupos = new Map(); + for (const l of linhas) { + const d = l.lancamento_tratado ?? ""; + if (!d) continue; + grupos.set(`${d}|${(l.credito ?? 0) > 0 ? "C" : "D"}`, (grupos.get(`${d}|${(l.credito ?? 0) > 0 ? "C" : "D"}`) ?? 0) + 1); + } + + const singletons = [...grupos.entries()].filter(([, n]) => n === 1).map(([k]) => k.split("|")[0]); + console.log(`\n linhas: ${linhas.length} · grupos: ${grupos.size} · singletons: ${singletons.length}`); + + // ── Teste (B): re-agrupar singletons por prefixo de 1..4 palavras ──────── + console.log("\n── TESTE: singletons re-agrupados por PREFIXO ──"); + console.log(" (se o nº de grupos despenca, sobrou token variável → normalização incompleta)\n"); + console.log(" palavras grupos resultantes redução"); + for (const n of [1, 2, 3, 4]) { + const prefixos = new Set(singletons.map((s) => s.split(" ").slice(0, n).join(" "))); + const red = ((1 - prefixos.size / singletons.length) * 100).toFixed(0); + console.log(` ${String(n).padStart(4)} ${String(prefixos.size).padStart(10)} ${red}%`); + } + + // ── Os prefixos de 2 palavras que mais concentram singletons ──────────── + const por2 = new Map(); + for (const s of singletons) { + const k = s.split(" ").slice(0, 2).join(" "); + if (!por2.has(k)) por2.set(k, []); + por2.get(k)!.push(s); + } + const top = [...por2.entries()].sort((a, b) => b[1].length - a[1].length).slice(0, 12); + + console.log("\n── TOP PREFIXOS (2 palavras) QUE CONCENTRAM SINGLETONS ──\n"); + for (const [pref, membros] of top) { + console.log(` ${String(membros.length).padStart(4)}× "${pref}"`); + for (const ex of membros.slice(0, 2)) console.log(` ex: ${mascarar(ex)}`); + } + + console.log("\n── LEITURA ──"); + const p2 = new Set(singletons.map((s) => s.split(" ").slice(0, 2).join(" "))).size; + const reducao = 1 - p2 / singletons.length; + if (reducao > 0.7) { + console.log(` 🟠 ${(reducao * 100).toFixed(0)}% dos singletons colapsam em 2 palavras de prefixo.`); + console.log(" Olhe os exemplos: se a 3ª palavra em diante é NOME DE CONTRAPARTE,"); + console.log(" é cardinalidade real (causa A). Se é token variável (código, agência,"); + console.log(" terminal), é normalização incompleta (causa B) e vale consertar."); + } else { + console.log(` ✅ Só ${(reducao * 100).toFixed(0)}% colapsam — a cauda é cardinalidade real (causa A).`); + } + console.log("=".repeat(78)); +} + +main().catch((e) => { + console.error("FALHOU:", e); + process.exit(1); +}); diff --git a/scripts/diag-fechamento-e3.ts b/scripts/diag-fechamento-e3.ts new file mode 100644 index 00000000..28d59bbd --- /dev/null +++ b/scripts/diag-fechamento-e3.ts @@ -0,0 +1,164 @@ +/** + * DIAGNÓSTICO SOMENTE-LEITURA — E3 do pente fino: o que custa FECHAR os meses. + * + * Mede, em produção, o que a etapa E3 precisa afirmar sem estimar: + * 1. quantos meses cada projeto fechou, e quantas VEZES fechou o mesmo mês + * (version > 1 = refechamento — retrabalho real, não hipótese); + * 2. quantos meses fechados estão STALE (`desatualizado_em`) — o mês fechado + * cujas transações mudaram depois, que precisa ser reapurado; + * 3. em quantos meses o GATE abre por pendência HARD (empréstimo/antecipação/ + * intercompany) — é o que decide se o gate é exceção ou rotina; + * 4. quantas linhas de ANTECIPAÇÃO por mês — cada uma precisa de valor bruto + * e prazo, que NÃO vêm no OFX; + * 5. quantos contratos o projeto cadastrou (o trabalho que se paga uma vez); + * 6. o publish_status real da DRE (a escada rascunho→preliminar→consolidada). + * + * NÃO ESCREVE NADA. + */ +import { readFileSync } from "node:fs"; +import { createClient } from "@supabase/supabase-js"; + +const env: Record = {}; +for (const line of readFileSync(".env.local", "utf8").split(/\r?\n/)) { + const m = line.match(/^([A-Z0-9_]+)\s*=\s*(.*)$/); + if (!m) continue; + let v = m[2].trim(); + if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1); + env[m[1]] = v; +} +const URL = env.NEXT_PUBLIC_SUPABASE_URL; +const KEY = env.SUPABASE_SERVICE_ROLE_KEY; +if (!URL || !KEY) { console.error("Faltam credenciais em .env.local"); process.exit(1); } +const db = createClient(URL, KEY, { auth: { autoRefreshToken: false, persistSession: false } }); + +/** Impeditivos HARD — bloqueiam o fechamento no servidor (fechar-mes/route.ts:306). */ +const HARD = ["captacao_emprestimo", "pagamento_emprestimo", "recebimento_antecipacao", "mov_inter_receita", "mov_inter_mutuo"]; +const ANTECIPACAO = "recebimento_antecipacao"; + +/** PostgREST devolve no máximo 1000 linhas em silêncio — paginar sempre. */ +/** Filtro simples — evita tipar o builder do PostgREST (que muda de forma por overload). */ +interface Filtro { coluna: string; dentro?: readonly string[]; igual?: string | number } + +async function todos(tabela: string, colunas: string, filtro?: Filtro): Promise { + const out: T[] = []; + for (let from = 0; ; from += 1000) { + const base = db.from(tabela).select(colunas).range(from, from + 999); + const q = filtro?.dentro + ? base.in(filtro.coluna, filtro.dentro as string[]) + : filtro?.igual !== undefined + ? base.eq(filtro.coluna, filtro.igual) + : base; + const { data, error } = await q; + if (error) throw new Error(`${tabela}: ${error.message}`); + out.push(...((data ?? []) as T[])); + if (!data || data.length < 1000) break; + } + return out; +} + +interface Snap { project_id: string; mes_referencia: string; version: number; invalidated_at: string | null; desatualizado_em: string | null; frozen_at: string | null } +interface Tx { project_id: string; data: string; subcategoria_id: string | null } + +async function main() { + const projetos = await todos<{ id: string; nome: string }>("projects", "id, nome"); + const snaps = await todos("dre_monthly_snapshots", "project_id, mes_referencia, version, invalidated_at, desatualizado_em, frozen_at"); + const txs = await todos("transactions", "project_id, data, subcategoria_id", { coluna: "subcategoria_id", dentro: HARD }); + const contratos = await todos<{ project_id: string }>("loan_contracts", "project_id"); + // module_analyses não tem project_id — o vínculo é via project_modules. + const modulos = await todos<{ id: string; project_id: string }>("project_modules", "id, project_id"); + const moduloDoProjeto = new Map(modulos.map(m => [m.id, m.project_id])); + const analisesRaw = await todos<{ module_id: string; publish_status: string | null }>("module_analyses", "module_id, publish_status", { coluna: "numero", igual: 1 }); + const analises = analisesRaw + .map(a => ({ project_id: moduloDoProjeto.get(a.module_id) ?? "", publish_status: a.publish_status })) + .filter(a => a.project_id); + + const nome = new Map(projetos.map(p => [p.id, p.nome])); + const porProjeto = new Map; // mes -> maior version + stale: Set; + mesesHard: Set; + antecipacaoPorMes: Map; + contratos: number; + publish: string | null; + }>(); + const pega = (pid: string) => { + if (!porProjeto.has(pid)) porProjeto.set(pid, { + mesesAtivos: new Map(), stale: new Set(), mesesHard: new Set(), + antecipacaoPorMes: new Map(), contratos: 0, publish: null, + }); + return porProjeto.get(pid)!; + }; + + for (const s of snaps) { + if (s.invalidated_at) continue; + const p = pega(s.project_id); + p.mesesAtivos.set(s.mes_referencia, Math.max(p.mesesAtivos.get(s.mes_referencia) ?? 0, s.version)); + if (s.desatualizado_em) p.stale.add(s.mes_referencia); + } + for (const t of txs) { + if (!t.data) continue; + const mes = t.data.slice(0, 7); + const p = pega(t.project_id); + p.mesesHard.add(mes); + if (t.subcategoria_id === ANTECIPACAO) p.antecipacaoPorMes.set(mes, (p.antecipacaoPorMes.get(mes) ?? 0) + 1); + } + for (const c of contratos) pega(c.project_id).contratos++; + for (const a of analises) pega(a.project_id).publish = a.publish_status; + + const linhas = [...porProjeto.entries()] + .filter(([, p]) => p.mesesAtivos.size > 0) + .sort((a, b) => b[1].mesesAtivos.size - a[1].mesesAtivos.size); + + console.log("\n=== E3 — o custo de FECHAR, medido em produção ===\n"); + console.log("projeto fech refech stale gateHard ant/mês contr publish"); + console.log("-".repeat(100)); + let totFech = 0, totRefech = 0, totStale = 0, totHard = 0; + for (const [pid, p] of linhas) { + const fech = p.mesesAtivos.size; + const refech = [...p.mesesAtivos.values()].filter(v => v > 1).length; + const hardNosFechados = [...p.mesesHard].filter(m => p.mesesAtivos.has(m)).length; + const antMeses = [...p.antecipacaoPorMes.entries()].filter(([m]) => p.mesesAtivos.has(m)); + const antMedia = antMeses.length ? (antMeses.reduce((a, [, n]) => a + n, 0) / antMeses.length) : 0; + totFech += fech; totRefech += refech; totStale += p.stale.size; totHard += hardNosFechados; + console.log( + (nome.get(pid) ?? pid.slice(0, 8)).slice(0, 30).padEnd(32) + + String(fech).padStart(4) + String(refech).padStart(8) + String(p.stale.size).padStart(7) + + `${hardNosFechados}/${fech}`.padStart(10) + + (antMeses.length ? `${antMedia.toFixed(1)} (${antMeses.length}m)` : "—").padStart(11) + + String(p.contratos).padStart(7) + " " + (p.publish ?? "—"), + ); + } + console.log("-".repeat(100)); + console.log(`TOTAL: ${totFech} meses fechados · ${totRefech} refechados (v>1) · ${totStale} stale · ${totHard} com gate hard`); + console.log(`\nRefechamento: ${(100 * totRefech / Math.max(totFech, 1)).toFixed(0)}% dos meses fechados foram fechados MAIS DE UMA VEZ.`); + console.log(`Gate hard: ${(100 * totHard / Math.max(totFech, 1)).toFixed(0)}% dos meses fechados tinham pendência que BLOQUEIA o fechamento.`); + const comPublish = [...porProjeto.values()].filter(p => p.publish && p.publish !== "rascunho").length; + console.log(`Escada: ${comPublish} projeto(s) passaram de 'rascunho' na escada de publicação.`); + + // ── Honestidade sobre o refechamento ──────────────────────────────────── + // `version > 1` sozinho não separa "o cliente reapurou" de "o dev iterou". + // A distribuição e a concentração no tempo separam: retrabalho de operação + // é esparso; iteração de desenvolvimento se amontoa em poucos dias. + const porMesTodas = new Map(); + for (const s of snaps) { + const k = `${s.project_id}|${s.mes_referencia}`; + if (!porMesTodas.has(k)) porMesTodas.set(k, []); + porMesTodas.get(k)!.push(s); + } + const dist = new Map(); + for (const v of porMesTodas.values()) { + const mx = Math.max(...v.map(x => x.version)); + dist.set(mx, (dist.get(mx) ?? 0) + 1); + } + console.log(""); + console.log("=== distribuicao da MAIOR version por (projeto, mes) ==="); + for (const [v, n] of [...dist.entries()].sort((a, b) => a[0] - b[0])) console.log(` v${v}: ${n} mês(es)`); + const datas = snaps.map(s => s.frozen_at?.slice(0, 10)).filter(Boolean).sort() as string[]; + const porDia = new Map(); + for (const d of datas) porDia.set(d, (porDia.get(d) ?? 0) + 1); + console.log(""); + console.log(`janela dos fechamentos: ${datas[0]} → ${datas[datas.length - 1]}`); + console.log("dias mais densos:", [...porDia.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([d, n]) => `${d}=${n}`).join(" · ")); +} + +main().catch(e => { console.error(e); process.exit(1); }); diff --git a/scripts/diag-grupos-por-transacao.ts b/scripts/diag-grupos-por-transacao.ts new file mode 100644 index 00000000..31a02a19 --- /dev/null +++ b/scripts/diag-grupos-por-transacao.ts @@ -0,0 +1,158 @@ +/** + * READ-ONLY. Mede a variável sensível da E2 do pente fino: quantas DECISÕES de + * categorização um cliente real precisa tomar para N transações. + * + * O modelo da E2 (`docs/valor/pente-fino/02-categorizacao.md`) estimou ~700 a + * 1.200 grupos únicos para 20.000 transações. Este script substitui a estimativa + * por medição: agrupa por (lancamento_tratado, sentido) — a MESMA chave da RPC + * `get_lancamentos_unicos` e do UPDATE de `atualizarCategoriaEmLote` — e mede o + * Pareto: quantos grupos cobrem 80% e 95% das LINHAS. + * + * Pagina com `.range()` — o PostgREST devolve 1000 linhas em silêncio sem isso. + * + * Uso: npx tsx scripts/diag-grupos-por-transacao.ts + */ +import { readFileSync } from "node:fs"; +import { createClient } from "@supabase/supabase-js"; + +const env: Record = {}; +for (const line of readFileSync(".env.local", "utf8").split(/\r?\n/)) { + const m = line.match(/^([A-Z0-9_]+)\s*=\s*(.*)$/); + if (!m) continue; + let v = m[2].trim(); + if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1); + env[m[1]] = v; +} +const supabase = createClient(env.NEXT_PUBLIC_SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY); + +const PAGE = 1000; + +interface Linha { + project_id: string; + lancamento_tratado: string | null; + credito: number | null; + debito: number | null; + data: string | null; +} + +async function puxarTudo(): Promise { + const out: Linha[] = []; + for (let from = 0; ; from += PAGE) { + const { data, error } = await supabase + .from("transactions") + .select("project_id, lancamento_tratado, credito, debito, data") + .order("id", { ascending: true }) + .range(from, from + PAGE - 1); + if (error) throw new Error(error.message); + const lote = (data ?? []) as Linha[]; + out.push(...lote); + process.stdout.write(`\r lendo… ${out.length}`); + if (lote.length < PAGE) break; + } + process.stdout.write("\n"); + return out; +} + +function pareto(ocorrencias: number[], alvoPct: number): number { + const ordenado = [...ocorrencias].sort((a, b) => b - a); + const total = ordenado.reduce((s, n) => s + n, 0); + let acc = 0; + for (let i = 0; i < ordenado.length; i++) { + acc += ordenado[i]; + if (acc / total >= alvoPct) return i + 1; + } + return ordenado.length; +} + +async function main() { + console.log("=".repeat(76)); + console.log("E2 — decisões de categorização por transação (READ-ONLY, dado real)"); + console.log("=".repeat(76)); + + const linhas = await puxarTudo(); + + const { data: projetos } = await supabase.from("projects").select("id, nome"); + const nomePorId = new Map( + ((projetos ?? []) as { id: string; nome: string | null }[]).map((p) => [p.id, p.nome ?? p.id] as const), + ); + + // project_id → chave(lancamento_tratado|sentido) → ocorrências + const porProjeto = new Map>(); + const totalPorProjeto = new Map(); + const mesesPorProjeto = new Map>(); + + for (const l of linhas) { + if (!l.project_id) continue; + totalPorProjeto.set(l.project_id, (totalPorProjeto.get(l.project_id) ?? 0) + 1); + + if (l.data) { + if (!mesesPorProjeto.has(l.project_id)) mesesPorProjeto.set(l.project_id, new Set()); + mesesPorProjeto.get(l.project_id)!.add(l.data.slice(0, 7)); + } + + const desc = l.lancamento_tratado ?? ""; + if (!desc) continue; + const sentido = (l.credito ?? 0) > 0 ? "C" : "D"; + const chave = `${desc}|${sentido}`; + if (!porProjeto.has(l.project_id)) porProjeto.set(l.project_id, new Map()); + const m = porProjeto.get(l.project_id)!; + m.set(chave, (m.get(chave) ?? 0) + 1); + } + + const linhasResultado = [...porProjeto.entries()] + .map(([pid, grupos]) => { + const total = totalPorProjeto.get(pid) ?? 0; + const ocorr = [...grupos.values()]; + return { + nome: String(nomePorId.get(pid) ?? pid).slice(0, 26), + meses: mesesPorProjeto.get(pid)?.size ?? 0, + total, + grupos: grupos.size, + razao: grupos.size > 0 ? total / grupos.size : 0, + p80: pareto(ocorr, 0.8), + p95: pareto(ocorr, 0.95), + unicos: ocorr.filter((n) => n === 1).length, + }; + }) + .filter((r) => r.total >= 200) + .sort((a, b) => b.total - a.total); + + console.log("\n(projetos com ≥200 transações)\n"); + console.log( + "PROJETO MESES LINHAS GRUPOS LINHA/GRP 80% 95% GRP c/ 1 ocorr.", + ); + console.log("-".repeat(94)); + for (const r of linhasResultado) { + console.log( + `${r.nome.padEnd(26)} ${String(r.meses).padStart(5)} ${String(r.total).padStart(8)} ` + + `${String(r.grupos).padStart(7)} ${r.razao.toFixed(1).padStart(10)} ` + + `${String(r.p80).padStart(5)} ${String(r.p95).padStart(5)} ` + + `${String(r.unicos).padStart(10)} (${((r.unicos / r.grupos) * 100).toFixed(0)}%)`, + ); + } + + // ── Extrapolação para a Casa Pilar (20.000 transações) ────────────────── + const maior = linhasResultado[0]; + if (maior) { + console.log("\n── EXTRAPOLAÇÃO PARA A CASA PILAR (20.000 transações) ──"); + console.log(` Base: "${maior.nome}" — o maior cliente real (${maior.total} linhas, ${maior.meses} meses).`); + const gruposEst = Math.round(20000 / maior.razao); + const p80Est = Math.round((maior.p80 / maior.grupos) * gruposEst); + console.log(` razão medida ......................... ${maior.razao.toFixed(1)} linhas por grupo`); + console.log(` grupos estimados p/ 20.000 linhas .... ~${gruposEst.toLocaleString("pt-BR")} decisões`); + console.log(` decisões p/ cobrir 80% das linhas .... ~${p80Est.toLocaleString("pt-BR")}`); + const seg = (n: number) => { + const s = Math.round(n * 0.8 * 15 + n * 0.2 * 60); + const h = Math.floor(s / 3600); + const mm = Math.round((s % 3600) / 60); + return h > 0 ? `${h} h ${mm} min` : `${mm} min`; + }; + console.log(` tempo (80% fáceis×15s + 20% difíceis×60s): ${seg(gruposEst)}`); + } + console.log("=".repeat(76)); +} + +main().catch((e) => { + console.error("FALHOU:", e); + process.exit(1); +}); diff --git a/scripts/diag-manually-reviewed-null.ts b/scripts/diag-manually-reviewed-null.ts new file mode 100644 index 00000000..37ed427a --- /dev/null +++ b/scripts/diag-manually-reviewed-null.ts @@ -0,0 +1,139 @@ +/** + * READ-ONLY. Dimensiona o achado E2-D do pente fino + * (`docs/valor/pente-fino/02-categorizacao.md`). + * + * O achado: `atualizarCategoriaEmLote` (financial-document-service.ts:1341) + * filtra `.eq("manually_reviewed", false)`, enquanto a varredura + * `aplicarRegrasNoProjeto` usa `.or("manually_reviewed.is.null,...is.false")`. + * + * A coluna é `boolean DEFAULT false` SEM NOT NULL (baseline:7424), e o resto do + * código já faz `COALESCE(manually_reviewed, FALSE)` — sinal de que o caso NULL + * é real. Se existirem linhas NULL, elas são SILENCIOSAMENTE puladas quando o + * cliente categoriza um grupo na tela: o grupo fica parcialmente pendente e o + * mês não fecha, sem erro nenhum. Falha PRA MENOS. + * + * Este script NÃO corrige nada. Só conta, para decidir se vale corrigir. + * + * Uso: npx tsx scripts/diag-manually-reviewed-null.ts + */ +import { readFileSync } from "node:fs"; +import { createClient } from "@supabase/supabase-js"; + +const env: Record = {}; +for (const line of readFileSync(".env.local", "utf8").split(/\r?\n/)) { + const m = line.match(/^([A-Z0-9_]+)\s*=\s*(.*)$/); + if (!m) continue; + let v = m[2].trim(); + if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1); + env[m[1]] = v; +} +const supabase = createClient(env.NEXT_PUBLIC_SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY); + +/** + * Conta via `head: true` + `count: 'exact'` — a contagem é server-side, então + * o teto de 1000 linhas do PostgREST não morde (armadilha conhecida da casa). + * + * O tipo do filtro sai do PRÓPRIO `.select()`: o builder antes do select + * (`PostgrestQueryBuilder`) não expõe `.is`/`.eq`, e tipar por ele quebra o + * `next build`, que type-checa `scripts/` junto com `src/`. + */ +function baseQuery() { + return supabase.from("transactions").select("id", { count: "exact", head: true }); +} +type FiltroTransacoes = ReturnType; + +async function contar( + aplicar: (q: FiltroTransacoes) => FiltroTransacoes, +): Promise { + const { count, error } = await aplicar(baseQuery()); + if (error) { + console.error(" ERRO:", error.message); + return -1; + } + return count ?? 0; +} + +async function main() { + console.log("=".repeat(72)); + console.log("E2-D — manually_reviewed NULL em `transactions` (READ-ONLY)"); + console.log("=".repeat(72)); + + // ── Global ────────────────────────────────────────────────────────────── + const total = await contar((q) => q); + const nulos = await contar((q) => q.is("manually_reviewed", null)); + const verdadeiros = await contar((q) => q.eq("manually_reviewed", true)); + const falsos = await contar((q) => q.eq("manually_reviewed", false)); + + // O subconjunto que dói: NULL **e** ainda sem categoria. São essas que + // ficariam penduradas como pendentes quando o cliente categoriza o grupo. + const nulosSemCategoria = await contar((q) => + q.is("manually_reviewed", null).is("categoria_id", null), + ); + + const pct = (n: number) => (total > 0 ? ((n / total) * 100).toFixed(2) : "0.00"); + + console.log("\n── GLOBAL ──"); + console.log(` total de transações .................. ${total.toLocaleString("pt-BR")}`); + console.log(` manually_reviewed = false ........... ${falsos.toLocaleString("pt-BR")} (${pct(falsos)}%)`); + console.log(` manually_reviewed = true ............ ${verdadeiros.toLocaleString("pt-BR")} (${pct(verdadeiros)}%)`); + console.log(` manually_reviewed IS NULL ........... ${nulos.toLocaleString("pt-BR")} (${pct(nulos)}%) ← o risco`); + console.log(` └─ destas, sem categoria ......... ${nulosSemCategoria.toLocaleString("pt-BR")}`); + + const soma = falsos + verdadeiros + nulos; + if (soma !== total) { + console.log(` ⚠ soma dos três (${soma}) ≠ total (${total}) — investigar`); + } + + // ── Por projeto (só os que têm NULL) ──────────────────────────────────── + if (nulos > 0) { + const { data: projetos, error } = await supabase + .from("projects") + .select("id, nome") + .order("nome"); + + if (error) { + console.log("\n (não consegui listar projetos:", error.message, ")"); + } else { + console.log("\n── POR PROJETO (só os afetados) ──"); + let afetados = 0; + for (const p of projetos ?? []) { + const n = await contar((q) => + q.eq("project_id", p.id).is("manually_reviewed", null), + ); + if (n > 0) { + afetados++; + const t = await contar((q) => q.eq("project_id", p.id)); + const semCat = await contar((q) => + q.eq("project_id", p.id).is("manually_reviewed", null).is("categoria_id", null), + ); + console.log( + ` ${(p.nome ?? p.id).padEnd(34)} ${String(n).padStart(7)} NULL de ${String(t).padStart(7)} (sem categoria: ${semCat})`, + ); + } + } + if (afetados === 0) console.log(" (nenhum)"); + } + } + + // ── Veredito ──────────────────────────────────────────────────────────── + console.log("\n── VEREDITO ──"); + if (nulos === 0) { + console.log(" ✅ Zero linhas NULL. O `.eq(false)` de atualizarCategoriaEmLote não pula"); + console.log(" nada hoje. O achado E2-D vira dívida LATENTE: continua sendo"); + console.log(" inconsistente com a varredura e volta a morder se alguma inserção"); + console.log(" futura gravar NULL explícito. Correção barata, prioridade baixa."); + } else { + console.log(` 🟠 ${nulos.toLocaleString("pt-BR")} linhas seriam puladas em silêncio ao categorizar em lote.`); + console.log(` Destas, ${nulosSemCategoria.toLocaleString("pt-BR")} estão sem categoria — ou seja, ficariam`); + console.log(" penduradas como pendentes e travariam o fechamento do mês."); + console.log(" Correção: trocar `.eq(\"manually_reviewed\", false)` por"); + console.log(" `.or(\"manually_reviewed.is.null,manually_reviewed.is.false\")`,"); + console.log(" igual à varredura. Uma linha."); + } + console.log("=".repeat(72)); +} + +main().catch((e) => { + console.error("FALHOU:", e); + process.exit(1); +}); diff --git a/scripts/diag-materialidade-cauda.ts b/scripts/diag-materialidade-cauda.ts new file mode 100644 index 00000000..d161265f --- /dev/null +++ b/scripts/diag-materialidade-cauda.ts @@ -0,0 +1,119 @@ +/** + * READ-ONLY. Mede a MATERIALIDADE da cauda de categorização. + * + * A E2 do pente fino mediu que a cauda (grupos de 1 ocorrência) é ~75% das + * DECISÕES. Este script responde a outra metade da pergunta: quanto do DINHEIRO + * ela representa — e, principalmente, **quantas decisões seriam eliminadas por + * uma regra de PREFIXO** (a alavanca E2-B), que é o que o motor hoje não faz + * para regra de cliente (só `.eq` exato). + * + * Uso: npx tsx scripts/diag-materialidade-cauda.ts [--projeto=] + */ +import { readFileSync } from "node:fs"; +import { createClient } from "@supabase/supabase-js"; + +const env: Record = {}; +for (const line of readFileSync(".env.local", "utf8").split(/\r?\n/)) { + const m = line.match(/^([A-Z0-9_]+)\s*=\s*(.*)$/); + if (!m) continue; + let v = m[2].trim(); + if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1); + env[m[1]] = v; +} +const supabase = createClient(env.NEXT_PUBLIC_SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY); + +const PAGE = 1000; +const alvo = process.argv.find((a) => a.startsWith("--projeto="))?.split("=")[1] ?? "PRUDENTE"; +const brl = (n: number) => n.toLocaleString("pt-BR", { style: "currency", currency: "BRL", maximumFractionDigits: 0 }); + +interface L { lancamento_tratado: string | null; credito: number | null; debito: number | null } + +/** Linha de `projects` usada só para resolver o alvo por id ou nome. */ +interface ProjetoRef { id: string; nome: string | null } + +async function main() { + const { data: projetos } = await supabase.from("projects").select("id, nome"); + const proj = (projetos ?? []).find( + (p: ProjetoRef) => p.id === alvo || String(p.nome ?? "").toUpperCase().includes(alvo.toUpperCase()), + ); + if (!proj) { console.error(`Projeto não encontrado: ${alvo}`); process.exit(1); } + + const linhas: L[] = []; + for (let from = 0; ; from += PAGE) { + const { data, error } = await supabase + .from("transactions") + .select("lancamento_tratado, credito, debito") + .eq("project_id", proj.id) + .order("id", { ascending: true }) + .range(from, from + PAGE - 1); + if (error) throw new Error(error.message); + const lote = (data ?? []) as L[]; + linhas.push(...lote); + if (lote.length < PAGE) break; + } + + // grupo = (descrição, sentido) + const grupos = new Map(); + for (const l of linhas) { + const desc = l.lancamento_tratado ?? ""; + if (!desc) continue; + const sentido: "C" | "D" = (l.credito ?? 0) > 0 ? "C" : "D"; + const valor = sentido === "C" ? (l.credito ?? 0) : (l.debito ?? 0); + const k = `${desc}|${sentido}`; + const g = grupos.get(k) ?? { n: 0, valor: 0, desc, sentido }; + g.n++; g.valor += valor; + grupos.set(k, g); + } + + const todos = [...grupos.values()]; + const totalValor = todos.reduce((s, g) => s + g.valor, 0); + const singles = todos.filter((g) => g.n === 1); + const valorSingles = singles.reduce((s, g) => s + g.valor, 0); + + console.log("=".repeat(78)); + console.log(`E2 — materialidade da cauda: ${proj.nome}`); + console.log("=".repeat(78)); + console.log(`\n linhas ................ ${linhas.length.toLocaleString("pt-BR")}`); + console.log(` grupos ................ ${todos.length.toLocaleString("pt-BR")}`); + console.log(` movimentação total .... ${brl(totalValor)}`); + console.log(`\n── A CAUDA (grupos de 1 ocorrência) ──`); + console.log(` decisões .............. ${singles.length.toLocaleString("pt-BR")} (${((singles.length / todos.length) * 100).toFixed(0)}% do total)`); + console.log(` linhas ................ ${singles.length.toLocaleString("pt-BR")} (${((singles.length / linhas.length) * 100).toFixed(1)}% das linhas)`); + console.log(` dinheiro .............. ${brl(valorSingles)} (${((valorSingles / totalValor) * 100).toFixed(1)}% da movimentação)`); + const ticket = singles.length ? valorSingles / singles.length : 0; + console.log(` ticket médio da cauda . ${brl(ticket)}`); + + // ── A alavanca: quantas decisões um PREFIXO eliminaria ────────────────── + console.log(`\n── ALAVANCA E2-B: REGRA POR PREFIXO ──`); + console.log(` (hoje impossível: regra de cliente casa por igualdade exata)\n`); + + for (const nPal of [2, 3]) { + const porPrefixo = new Map(); + for (const g of todos) { + const pref = g.desc.split(" ").slice(0, nPal).join(" ") + `|${g.sentido}`; + const p = porPrefixo.get(pref) ?? { grupos: 0, linhas: 0, valor: 0, sentido: g.sentido }; + p.grupos++; p.linhas += g.n; p.valor += g.valor; + porPrefixo.set(pref, p); + } + const candidatos = [...porPrefixo.entries()] + .filter(([, p]) => p.grupos >= 5) + .sort((a, b) => b[1].grupos - a[1].grupos); + + const gruposCobertos = candidatos.reduce((s, [, p]) => s + p.grupos, 0); + console.log(` ▸ prefixo de ${nPal} palavras — ${candidatos.length} regras cobririam ${gruposCobertos.toLocaleString("pt-BR")} grupos`); + console.log(` (${((gruposCobertos / todos.length) * 100).toFixed(0)}% das decisões viram ${candidatos.length} decisões)`); + if (nPal === 2) { + console.log(`\n TOP 10 candidatas:`); + for (const [k, p] of candidatos.slice(0, 10)) { + const [pref, sent] = k.split("|"); + console.log( + ` ${String(p.grupos).padStart(5)} grupos · ${String(p.linhas).padStart(5)} linhas · ${brl(p.valor).padStart(16)} · ${sent === "C" ? "entrada" : "saída "} · "${pref}"`, + ); + } + } + console.log(""); + } + console.log("=".repeat(78)); +} + +main().catch((e) => { console.error("FALHOU:", e); process.exit(1); }); diff --git a/scripts/diag-tokens-cauda.ts b/scripts/diag-tokens-cauda.ts new file mode 100644 index 00000000..1bb707ce --- /dev/null +++ b/scripts/diag-tokens-cauda.ts @@ -0,0 +1,147 @@ +/** + * READ-ONLY. Terceiro e decisivo teste da cauda de categorização (E2 do pente fino). + * + * Já sabemos: + * - a cauda é cardinalidade real (nomes de pessoa física), não bug de normalização; + * - regra por PREFIXO resolve só ~4% — porque o nome da pessoa vem PRIMEIRO. + * + * Hipótese desta rodada: o token estável está no MEIO ou no FIM da descrição + * ("... PIX MOBILE", "... TED", "... COBRANCA"). Se for verdade, a regra que + * falta não é por prefixo — é por **token contido** (`ILIKE '%TOKEN%'`), que o + * PostgREST suporta nativamente e o motor hoje não usa para regra de cliente. + * + * Mede, sobre os grupos de 1 ocorrência: quais tokens (e pares de tokens) + * aparecem em mais grupos, quantas decisões cada um eliminaria, e quanto + * dinheiro carrega — para saber se a regra seria segura. + * + * Uso: npx tsx scripts/diag-tokens-cauda.ts [--projeto=] + */ +import { readFileSync } from "node:fs"; +import { createClient } from "@supabase/supabase-js"; + +const env: Record = {}; +for (const line of readFileSync(".env.local", "utf8").split(/\r?\n/)) { + const m = line.match(/^([A-Z0-9_]+)\s*=\s*(.*)$/); + if (!m) continue; + let v = m[2].trim(); + if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1); + env[m[1]] = v; +} +const supabase = createClient(env.NEXT_PUBLIC_SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY); + +const PAGE = 1000; +const alvo = process.argv.find((a) => a.startsWith("--projeto="))?.split("=")[1] ?? "PRUDENTE"; +const brl = (n: number) => n.toLocaleString("pt-BR", { style: "currency", currency: "BRL", maximumFractionDigits: 0 }); + +/** Palavras que são nome próprio/conectivo — ruído para este teste. */ +const STOP = new Set(["DE", "DA", "DO", "DOS", "DAS", "E"]); + +interface L { lancamento_tratado: string | null; credito: number | null; debito: number | null } + +/** Linha de `projects` usada só para resolver o alvo por id ou nome. */ +interface ProjetoRef { id: string; nome: string | null } + +async function main() { + const { data: projetos } = await supabase.from("projects").select("id, nome"); + const proj = (projetos ?? []).find( + (p: ProjetoRef) => p.id === alvo || String(p.nome ?? "").toUpperCase().includes(alvo.toUpperCase()), + ); + if (!proj) { console.error(`Projeto não encontrado: ${alvo}`); process.exit(1); } + + const linhas: L[] = []; + for (let from = 0; ; from += PAGE) { + const { data, error } = await supabase + .from("transactions").select("lancamento_tratado, credito, debito") + .eq("project_id", proj.id).order("id", { ascending: true }) + .range(from, from + PAGE - 1); + if (error) throw new Error(error.message); + const lote = (data ?? []) as L[]; + linhas.push(...lote); + if (lote.length < PAGE) break; + } + + const grupos = new Map(); + for (const l of linhas) { + const desc = l.lancamento_tratado ?? ""; + if (!desc) continue; + const sentido: "C" | "D" = (l.credito ?? 0) > 0 ? "C" : "D"; + const valor = sentido === "C" ? (l.credito ?? 0) : (l.debito ?? 0); + const k = `${desc}|${sentido}`; + const g = grupos.get(k) ?? { n: 0, valor: 0, desc, sentido }; + g.n++; g.valor += valor; grupos.set(k, g); + } + + const todos = [...grupos.values()]; + const cauda = todos.filter((g) => g.n === 1); + + console.log("=".repeat(80)); + console.log(`E2 — tokens da cauda: ${proj.nome}`); + console.log("=".repeat(80)); + console.log(`\n grupos totais: ${todos.length} · cauda (1 ocorrência): ${cauda.length}\n`); + + // ── ÚLTIMAS palavras (o token estável costuma fechar a descrição) ──────── + for (const nUlt of [1, 2]) { + const mapa = new Map(); + for (const g of cauda) { + const pal = g.desc.split(" ").filter(Boolean); + if (pal.length < nUlt) continue; + const suf = pal.slice(-nUlt).join(" "); + const m = mapa.get(suf) ?? { grupos: 0, valor: 0, c: 0, d: 0 }; + m.grupos++; m.valor += g.valor; + if (g.sentido === "C") m.c++; else m.d++; + mapa.set(suf, m); + } + const top = [...mapa.entries()].sort((a, b) => b[1].grupos - a[1].grupos).slice(0, 8); + console.log(`── SUFIXO de ${nUlt} palavra(s) — top 8 ──`); + for (const [suf, m] of top) { + const pureza = Math.max(m.c, m.d) / m.grupos; + console.log( + ` ${String(m.grupos).padStart(5)} grupos · ${brl(m.valor).padStart(15)} · ${(pureza * 100).toFixed(0)}% mesmo sentido · "…${suf}"`, + ); + } + console.log(""); + } + + // ── TOKEN CONTIDO em qualquer posição ─────────────────────────────────── + const porToken = new Map(); + for (const g of cauda) { + const tokens = new Set(g.desc.split(" ").filter((t) => t.length >= 3 && !STOP.has(t))); + for (const t of tokens) { + const m = porToken.get(t) ?? { grupos: 0, valor: 0, c: 0, d: 0 }; + m.grupos++; m.valor += g.valor; + if (g.sentido === "C") m.c++; else m.d++; + porToken.set(t, m); + } + } + const topTok = [...porToken.entries()].sort((a, b) => b[1].grupos - a[1].grupos).slice(0, 12); + + console.log("── TOKEN CONTIDO (qualquer posição) — top 12 ──"); + console.log(" (pureza = % dos grupos no mesmo sentido; 100% = regra segura)\n"); + let cobertura = 0; + for (const [tok, m] of topTok) { + const pureza = Math.max(m.c, m.d) / m.grupos; + console.log( + ` ${String(m.grupos).padStart(5)} grupos · ${brl(m.valor).padStart(15)} · pureza ${(pureza * 100).toFixed(0)}% · "${tok}"`, + ); + } + + // Cobertura combinada dos 3 tokens mais frequentes com pureza 100% + const seguros = topTok.filter(([, m]) => Math.max(m.c, m.d) / m.grupos === 1).slice(0, 3); + if (seguros.length) { + const cobertos = new Set(); + let valor = 0; + for (const g of cauda) { + const tks = new Set(g.desc.split(" ")); + if (seguros.some(([t]) => tks.has(t))) { cobertos.add(g.desc); valor += g.valor; } + } + cobertura = cobertos.size; + console.log(`\n── COMBINANDO as ${seguros.length} regras de token mais frequentes e 100% puras ──`); + console.log(` tokens: ${seguros.map(([t]) => `"${t}"`).join(", ")}`); + console.log(` cobrem ${cobertura.toLocaleString("pt-BR")} de ${cauda.length.toLocaleString("pt-BR")} grupos da cauda (${((cobertura / cauda.length) * 100).toFixed(0)}%)`); + console.log(` ${brl(valor)} de movimentação`); + console.log(` → ${cauda.length.toLocaleString("pt-BR")} decisões viram ${seguros.length}.`); + } + console.log("\n" + "=".repeat(80)); +} + +main().catch((e) => { console.error("FALHOU:", e); process.exit(1); }); diff --git a/scripts/dry-run-sincronizar-socios.ts b/scripts/dry-run-sincronizar-socios.ts new file mode 100644 index 00000000..409b9e1c --- /dev/null +++ b/scripts/dry-run-sincronizar-socios.ts @@ -0,0 +1,69 @@ +/** + * Dry-run da sincronização de sócios (LEVA 1 de `feat/partes-relacionadas-e-regra-token`). + * + * Roda `sincronizarSociosDoCliente` em modo simulação para TODOS os clientes e + * mostra exatamente o que seria escrito. Nada é gravado. + * + * Método da casa: prova o alcance ANTES da mutação. + * + * Uso: + * npx tsx scripts/dry-run-sincronizar-socios.ts # simula + * npx tsx scripts/dry-run-sincronizar-socios.ts --executar # grava de verdade + */ +import { readFileSync } from "node:fs"; +import { createClient } from "@supabase/supabase-js"; +import { sincronizarSociosDoCliente } from "../src/lib/partes-relacionadas/sincronizar-socios"; + +const env: Record = {}; +for (const line of readFileSync(".env.local", "utf8").split(/\r?\n/)) { + const m = line.match(/^([A-Z0-9_]+)\s*=\s*(.*)$/); + if (!m) continue; + let v = m[2].trim(); + if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1); + env[m[1]] = v; +} +const supabase = createClient(env.NEXT_PUBLIC_SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY); +const executar = process.argv.includes("--executar"); + +async function main() { + console.log("=".repeat(78)); + console.log(executar ? "SINCRONIZAÇÃO DE SÓCIOS — EXECUTANDO (grava)" : "SINCRONIZAÇÃO DE SÓCIOS — DRY-RUN (não grava)"); + console.log("=".repeat(78)); + + const { data: clientes, error } = await supabase.from("clients").select("id, nome").order("nome"); + if (error) throw new Error(error.message); + + let totCriados = 0, totAtualizados = 0, totPreservados = 0, totInalterados = 0, comSocio = 0; + + for (const c of clientes ?? []) { + const r = await sincronizarSociosDoCliente(supabase, c.id, { dryRun: !executar }); + if (r.socios.length === 0) continue; + + comSocio++; + totCriados += r.criados; + totAtualizados += r.atualizados; + totPreservados += r.preservadosManuais; + totInalterados += r.inalterados; + + console.log(`\n▸ ${c.nome} (${r.empresasComQsa} empresa(s) com QSA)`); + for (const s of r.socios) { + const selo = + s.acao === "criado" ? "+ criar " : + s.acao === "atualizado" ? "~ atualizar" : + s.acao === "preservado_manual" ? "= manual " : "= igual "; + const mask = s.cpfMascara ? `cpf ***${s.cpfMascara}**` : "sem máscara (confiança média)"; + console.log(` ${selo} ${s.nome.padEnd(38).slice(0, 38)} ${mask}`); + } + } + + console.log("\n" + "─".repeat(78)); + console.log(` clientes com sócio no QSA ....... ${comSocio}`); + console.log(` a criar ......................... ${totCriados}`); + console.log(` a atualizar ..................... ${totAtualizados}`); + console.log(` preservados (cadastro manual) ... ${totPreservados}`); + console.log(` já em dia ....................... ${totInalterados}`); + if (!executar) console.log("\n NADA FOI GRAVADO. Rode com --executar para aplicar."); + console.log("=".repeat(78)); +} + +main().catch((e) => { console.error("FALHOU:", e); process.exit(1); }); diff --git a/scripts/dry-run-varredura-contraparte.ts b/scripts/dry-run-varredura-contraparte.ts new file mode 100644 index 00000000..329ed235 --- /dev/null +++ b/scripts/dry-run-varredura-contraparte.ts @@ -0,0 +1,74 @@ +/** + * Dry-run da varredura retroativa de contraparte (LEVA 2). + * + * Roda o detector sobre TODO o histórico de cada projeto e mostra o que seria + * marcado — sem gravar. Método da casa: provar o alcance antes da mutação. + * + * Uso: + * npx tsx scripts/dry-run-varredura-contraparte.ts # simula + * npx tsx scripts/dry-run-varredura-contraparte.ts --executar # grava + */ +import { readFileSync } from "node:fs"; +import { createClient } from "@supabase/supabase-js"; +import { aplicarContrapartesNoProjeto } from "../src/lib/partes-relacionadas/varredura-contraparte"; + +const env: Record = {}; +for (const line of readFileSync(".env.local", "utf8").split(/\r?\n/)) { + const m = line.match(/^([A-Z0-9_]+)\s*=\s*(.*)$/); + if (!m) continue; + let v = m[2].trim(); + if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1); + env[m[1]] = v; +} +const supabase = createClient(env.NEXT_PUBLIC_SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY); +const executar = process.argv.includes("--executar"); + +/** Máscara para não vazar nome de contraparte no log. */ +function mascarar(s: string): string { + return s.split(" ").map((w, i) => (i < 2 || w.length <= 3 ? w : w[0] + "·".repeat(Math.min(w.length - 1, 4)))).join(" "); +} + +async function main() { + console.log("=".repeat(80)); + console.log(executar ? "VARREDURA DE CONTRAPARTE — EXECUTANDO (grava)" : "VARREDURA DE CONTRAPARTE — DRY-RUN (não grava)"); + console.log("=".repeat(80)); + + const { data: projetos, error } = await supabase.from("projects").select("id, nome").order("nome"); + if (error) throw new Error(error.message); + + let tMarcadas = 0, tDoc = 0, tNome = 0, tAnalisadas = 0; + + for (const p of projetos ?? []) { + const r = await aplicarContrapartesNoProjeto(supabase, p.id, { dryRun: !executar }); + if (!r.tinhaCadastro) continue; + if (r.analisadas === 0) continue; + + tAnalisadas += r.analisadas; + tMarcadas += r.marcadas; + tDoc += r.porDocumento; + tNome += r.porNome; + + const nome = String(p.nome ?? p.id).slice(0, 30); + console.log( + `\n▸ ${nome.padEnd(30)} analisadas ${String(r.analisadas).padStart(6)} · ` + + `marcar ${String(r.marcadas).padStart(5)} ` + + `(doc ${r.porDocumento} · nome ${r.porNome}) · ` + + `própria ${r.porTipo.propria} · grupo ${r.porTipo.empresa_grupo} · sócio ${r.porTipo.socio}`, + ); + + for (const s of r.sugestoes.slice(0, 3)) { + console.log(` por nome [${s.tipo}] ${mascarar(s.descritor).slice(0, 60)}`); + } + if (r.porNome > 3) console.log(` … e mais ${r.porNome - 3} por nome (a tela pede confirmação)`); + } + + console.log("\n" + "─".repeat(80)); + console.log(` analisadas ......................... ${tAnalisadas.toLocaleString("pt-BR")}`); + console.log(` seriam marcadas (total) ............ ${tMarcadas.toLocaleString("pt-BR")}`); + console.log(` ├─ por documento (age sozinho) ... ${tDoc.toLocaleString("pt-BR")}`); + console.log(` └─ por nome (tela pede confirmar) ${tNome.toLocaleString("pt-BR")}`); + if (!executar) console.log("\n NADA FOI GRAVADO. Rode com --executar para aplicar."); + console.log("=".repeat(80)); +} + +main().catch((e) => { console.error("FALHOU:", e); process.exit(1); }); diff --git a/scripts/saas/auditar-acesso-do-dono.ts b/scripts/saas/auditar-acesso-do-dono.ts new file mode 100644 index 00000000..2cf0fb65 --- /dev/null +++ b/scripts/saas/auditar-acesso-do-dono.ts @@ -0,0 +1,116 @@ +/** + * Auditor de acesso do DONO do projeto — o gate do SaaS sem consultor. + * + * Pergunta única: para cada (tabela × comando), o dono do projeto tem ALGUM + * caminho na RLS? Policies permissivas se somam por OR, então basta uma — é + * por isso que olhar policy a policy engana: a irmã `_cliente_*` pode já cobrir. + * + * Lê TODAS as migrations em ordem de timestamp e resolve a definição vigente de + * cada policy — inclusive as reescritas pelas migrations do SaaS. Rodar com e + * sem `--sem-saas` dá o diff: exatamente o que elas destravaram. + * + * Um DONO é: linha em `project_members` (role='cliente') + `profiles.role='cliente'`. + * + * Uso: npx tsx scripts/saas/auditar-acesso-do-dono.ts [--sem-saas] [--vazamento] + */ +import { readdirSync, readFileSync } from "node:fs"; +import { join, basename } from "node:path"; +import { extrairPolicies, type Policy } from "./transformar-policies"; + +const SEM_SAAS = process.argv.includes("--sem-saas"); +const DIR = "supabase/migrations"; +const FILES = readdirSync(DIR) + .filter(f => f.endsWith(".sql")) + .filter(f => !(SEM_SAAS && /^20260818/.test(f))) + .sort() + .map(f => join(DIR, f)); + +/** + * Estado vigente = replay das migrations em ordem. Processar só os `CREATE` + * não serve: uma migration que DERRUBA uma policy sem recriar (é o caso do lote + * do vazamento) deixaria a versão antiga viva no mapa, e o gate mediria um banco + * que não existe. Dentro de um arquivo, aplicar os DROPs antes dos CREATEs dá o + * resultado certo tanto pro par DROP+CREATE quanto pro DROP sozinho. + */ +const vigente = new Map(); +for (const f of FILES) { + const sql = readFileSync(f, "utf8"); + for (const m of sql.matchAll(/DROP POLICY IF EXISTS\s+"([^"]+)"\s+ON\s+"?public"?\."?([a-z_]+)"?/gi)) { + vigente.delete(`${m[2]}.${m[1]}`); + } + for (const p of extrairPolicies(sql, basename(f))) vigente.set(`${p.table}.${p.name}`, p); +} +const atuais = [...vigente.values()]; + +/** + * `user_id`/`id` comparado a `auth.uid()` **sem qualificador de tabela**. + * + * O `(? + /\bproject_access\b/.test(b) || + /client_project_access\b(?!_deprecated)/.test(b) || + (/project_members/.test(b) && !/"?pm"?\."?role"?\s*=\s*'consultor'/i.test(b)) || + /auth"?\."?role"?\(\)\s*=\s*'authenticated'/.test(b) || + /USING \(true\)|WITH CHECK \(true\)/.test(b) || + // "Linha própria" É um caminho: o dono alcança o que é dele (log, notificação, + // configuração, perfil). Sem isto, toda policy own-row aparecia como bloqueio + // e o gate acusava regressão onde havia conserto. + // + // A coluna precisa estar SEM QUALIFICADOR. Um `"p"."id" = auth.uid()` é o + // teste de papel dentro do subselect em `profiles` — não é a linha do dono, e + // ler isso como acesso próprio fazia 5 policies admin-only parecerem abertas. + ehLinhaPropria(b); + +/** A policy libera QUALQUER logado, sem escopo de projeto/tenant? */ +const semEscopo = (b: string): boolean => + /auth"?\."?role"?\(\)\s*=\s*'authenticated'/.test(b) || /USING \(true\)|WITH CHECK \(true\)/.test(b); + +if (process.argv.includes("--vazamento")) { + const abertas = atuais.filter(p => semEscopo(p.body)); + console.log(`### Policies que liberam por ESTAR LOGADO (sem escopo de projeto/tenant): ${abertas.length}\n`); + const byT: Record = {}; + for (const p of abertas) (byT[p.table] ??= []).push(p); + for (const t of Object.keys(byT).sort()) { + console.log(` ${t}`); + for (const p of byT[t]!) console.log(` ${p.cmd.padEnd(7)} ${p.name}`); + } + process.exit(0); +} + +const CMDS = ["SELECT", "INSERT", "UPDATE", "DELETE"]; +const tabelas = [...new Set(atuais.map(p => p.table))].sort(); +const bloqueado: { t: string; cmd: string; vias: string }[] = []; +for (const t of tabelas) { + const ps = atuais.filter(p => p.table === t); + for (const cmd of CMDS) { + const aplicaveis = ps.filter(p => p.cmd === cmd || p.cmd === "ALL"); + if (!aplicaveis.length) continue; + if (!aplicaveis.some(p => donoPassa(p.body))) { + bloqueado.push({ t, cmd, vias: aplicaveis.map(p => p.name).join(", ") }); + } + } +} + +console.log(`migrations lidas: ${FILES.length} · policies vigentes: ${atuais.length}\n`); +console.log(`### O DONO DO PROJETO NÃO CONSEGUE: ${bloqueado.length} combinações (tabela × comando)\n`); +let last = ""; +for (const b of bloqueado) { + if (b.t !== last) { console.log(`\n ${b.t}`); last = b.t; } + console.log(` ${b.cmd.padEnd(7)} — policies existentes: ${b.vias}`); +} diff --git a/scripts/saas/gerar-migration-fatia2.ts b/scripts/saas/gerar-migration-fatia2.ts new file mode 100644 index 00000000..fd881ed5 --- /dev/null +++ b/scripts/saas/gerar-migration-fatia2.ts @@ -0,0 +1,122 @@ +/** + * Emite a migration da fatia 2+3 do SaaS sem consultor. + * + * view `project_access` (project_members sem filtro de papel) + * + repoint/colapso das policies que `transformar-policies.ts` provou. + * + * Uso: npx tsx scripts/saas/gerar-migration-fatia2.ts # stdout + * npx tsx scripts/saas/gerar-migration-fatia2.ts --out + */ +import { writeFileSync } from "node:fs"; +import { carregar, classificar } from "./transformar-policies"; + +const { alvo } = carregar(); +const { ok, suspeitas, inertes } = classificar(alvo); +const fora = suspeitas.length + inertes.length; + +const L: string[] = []; +const w = (s: string) => L.push(s); + +w(`-- =============================================================================`); +w(`-- SaaS sem consultor — fatia 2+3: a autoridade vem do VÍNCULO, não do papel`); +w(`-- Lei: docs/atros-v3/saas-sem-consultor.md · Método: docs/atros-v3/metodo.md`); +w(`--`); +w(`-- GERADA por scripts/saas/gerar-migration-fatia2.ts (que usa o transformador em`); +w(`-- transformar-policies.ts). Não editar à mão: reveja o gerador e regere, senão`); +w(`-- o SQL e o script divergem.`); +w(`-- =============================================================================`); +w(`--`); +w(`-- O QUE MUDA`); +w(`--`); +w(`-- 1. Nasce a view \`project_access\` — igual à \`client_project_access\`, SEM o`); +w(`-- filtro \`role = 'cliente'\`. É o que o modelo novo pede: consultor e cliente`); +w(`-- têm o MESMO acesso, então a view que 97 policies consomem não pode mais`); +w(`-- excluir o consultor.`); +w(`--`); +w(`-- 2. O disjunto de bypass por papel GLOBAL sai das policies`); +w(`-- (\`profiles.role IN ('owner','admin','consultor')\`). \`is_admin()\` já cobre`); +w(`-- owner/admin; o consultor passa a entrar pelo vínculo — mesma porta do`); +w(`-- cliente, que é exatamente a decisão de 18/ago.`); +w(`--`); +w(`-- 3. \`AND pm.role = 'consultor'\` sai dos testes de membership: qualquer MEMBRO`); +w(`-- do projeto opera. Era o padrão que a lei não tinha mapeado e o que de fato`); +w(`-- travava o dono (36 policies em 22 tabelas).`); +w(`--`); +w(`-- \`client_project_access\` FICA VIVA (método F2: repoint antes de deletar). Ela`); +w(`-- deixa de ter leitor em policy, mas ainda é lida por funções e pelo código —`); +w(`-- a aposentadoria é passo próprio, com grep-de-prova na hora.`); +w(`--`); +w(`-- FORA desta migration, de propósito:`); +w(`-- · Comercial/leads (\`commercial_*\`, \`leads_owner_insert\`) — staff-only por`); +w(`-- desenho e módulo desligado; remover o bypass deixaria a policy sem nenhum`); +w(`-- disjunto, ou seja, negaria tudo.`); +w(`-- · \`client_project_access_deprecated\` — tabela morta, policies próprias.`); +w(`-- · as policies que liberam por "estar logado" (\`auth.role() = 'authenticated'\`)`); +w(`-- — furo de multi-tenant, escopo próprio e mais urgente que este.`); +w(`--`); +w(`-- Policies reescritas nesta migration: ${ok.length}`); +w(`-- =============================================================================`); +w(``); +w(``); +w(`-- ---------------------------------------------------------------------------`); +w(`-- 1. A view sem filtro de papel`); +w(`--`); +w(`-- Espelha \`client_project_access\` no que importa: MESMAS colunas`); +w(`-- (\`created_at AS granted_at\`), MESMO owner e SEM \`security_invoker\` — a view`); +w(`-- roda como owner e por isso não bate na RLS de \`project_members\` (que só`); +w(`-- permite \`user_id = auth.uid()\`). Sem isso, toda policy que a consome passaria`); +w(`-- a enxergar apenas o próprio vínculo e quebraria em silêncio.`); +w(`--`); +w(`-- \`role\` entra como coluna (a irmã não tem) para quem precise distinguir dono`); +w(`-- de convidado sem ir na tabela — a autorização NÃO usa.`); +w(`-- ---------------------------------------------------------------------------`); +w(`CREATE OR REPLACE VIEW "public"."project_access" AS`); +w(` SELECT "id",`); +w(` "user_id",`); +w(` "project_id",`); +w(` "role",`); +w(` "granted_by",`); +w(` "created_at" AS "granted_at"`); +w(` FROM "public"."project_members";`); +w(``); +w(`ALTER VIEW "public"."project_access" OWNER TO "postgres";`); +w(``); +w(`COMMENT ON VIEW "public"."project_access" IS 'Vínculo usuário×projeto SEM filtro de papel — cliente (dono) e consultor (convidado) têm o mesmo acesso (SaaS sem consultor, 18/ago/2026). Substitui client_project_access nas policies. NÃO usar para inserções — use project_members.';`); +w(``); +w(`GRANT ALL ON TABLE "public"."project_access" TO "anon";`); +w(`GRANT ALL ON TABLE "public"."project_access" TO "authenticated";`); +w(`GRANT ALL ON TABLE "public"."project_access" TO "service_role";`); +w(``); +w(``); +w(`-- ---------------------------------------------------------------------------`); +w(`-- 2. As policies`); +w(`-- ---------------------------------------------------------------------------`); + +let tabelaAtual = ""; +for (const { p, r } of [...ok].sort((a, b) => (a.p.table + a.p.name).localeCompare(b.p.table + b.p.name))) { + if (p.table !== tabelaAtual) { + tabelaAtual = p.table; + w(``); + w(`-- ····· ${p.table} ·····`); + } + w(``); + w(`DROP POLICY IF EXISTS "${p.name}" ON "public"."${p.table}";`); + w(r.body.trim() + ";"); +} + +w(``); +w(``); +w(`-- ---------------------------------------------------------------------------`); +w(`-- PostgREST: recarregar o schema cache`); +w(`-- ---------------------------------------------------------------------------`); +w(`NOTIFY pgrst, 'reload schema';`); + +const saida = L.join("\n") + "\n"; +const iOut = process.argv.indexOf("--out"); +if (iOut !== -1 && process.argv[iOut + 1]) { + writeFileSync(process.argv[iOut + 1]!, saida); + process.stderr.write(`[gerador] escrito em ${process.argv[iOut + 1]}\n`); +} else { + process.stdout.write(saida); +} +process.stderr.write(`[gerador] ${ok.length} policies emitidas · ${fora} fora (suspeitas/inertes)\n`); diff --git a/scripts/saas/policies-da-tabela.ts b/scripts/saas/policies-da-tabela.ts new file mode 100644 index 00000000..9e520d85 --- /dev/null +++ b/scripts/saas/policies-da-tabela.ts @@ -0,0 +1,35 @@ +/** + * Mostra as policies VIGENTES de uma ou mais tabelas, lidas do DUMP. + * + * Existe porque olhar policy a policy na migration engana: o que vale é o estado + * real, e policies permissivas se somam por **OR** — a irmã `_cliente_*` pode já + * dar o caminho que a outra nega. Aqui você vê o conjunto inteiro de uma vez. + * + * Uso: npx tsx scripts/saas/policies-da-tabela.ts projects profiles [...] + * npx tsx scripts/saas/policies-da-tabela.ts --dump projects + */ +import { readFileSync } from "node:fs"; +import { extrairPolicies } from "./transformar-policies"; + +const args = process.argv.slice(2); +const iDump = args.indexOf("--dump"); +const DUMP = iDump !== -1 ? args[iDump + 1]! : "db/snapshot/schema.sql"; +// Sem `--dump`, iDump é -1 e `i !== iDump + 1` derrubaria o PRIMEIRO argumento. +const tabelas = args.filter((a, i) => a !== "--dump" && !(iDump !== -1 && i === iDump + 1)); + +if (!tabelas.length) { + console.error("uso: npx tsx scripts/saas/policies-da-tabela.ts [tabela...]"); + process.exit(1); +} + +const pols = extrairPolicies(readFileSync(DUMP, "utf8"), DUMP); + +for (const t of tabelas) { + const ps = pols.filter(p => p.table === t); + console.log(`\n##### ${t} (${ps.length} policies)`); + if (!ps.length) { console.log(" (nenhuma — se a RLS estiver ligada, a tabela nega tudo)"); continue; } + for (const p of ps) { + console.log(`\n ${p.cmd.padEnd(6)} ${p.name}`); + console.log(` ${p.body.replace(/\s+/g, " ").replace(/^CREATE POLICY[^(]*/, "")}`); + } +} diff --git a/scripts/saas/transformar-policies.ts b/scripts/saas/transformar-policies.ts new file mode 100644 index 00000000..36e1abc0 --- /dev/null +++ b/scripts/saas/transformar-policies.ts @@ -0,0 +1,255 @@ +/** + * SaaS sem consultor — transformador das policies RLS. + * + * Lei: docs/atros-v3/saas-sem-consultor.md · Método: docs/atros-v3/metodo.md + * + * Lê as migrations em ordem de timestamp, resolve a definição VIGENTE de cada + * policy (a última a definir (tabela, nome) ganha) e aplica três mudanças: + * + * 1. o disjunto de bypass por papel GLOBAL (profiles.role contendo 'consultor') + * é REMOVIDO — is_admin() já cobre owner/admin, e o consultor passa a entrar + * pelo vínculo, mesma porta do cliente; + * 2. `client_project_access` (view que filtra role='cliente') → `project_access` + * (a mesma view, sem filtro de papel); + * 3. `AND pm.role = 'consultor'` dentro do teste de membership é REMOVIDO — + * qualquer membro do projeto opera. + * + * O item 1 NÃO é regex cega: a expressão é dividida em disjuntos de primeiro + * nível (respeitando parênteses, colchetes e literais) e só o disjunto INTEIRO + * que casa o padrão sai. Regex sobre o texto todo comeria pedaço de vizinho. + * + * Prova automática embutida: toda policy transformada passa por checagem de + * balanceamento de parênteses e de não-vacuidade. Qualquer uma que não bata é + * listada como suspeita e NÃO entra no SQL emitido. + * + * Uso: npx tsx scripts/saas/transformar-policies.ts # relatório + * npx tsx scripts/saas/transformar-policies.ts --amostra 3 + */ +import { readFileSync } from "node:fs"; + +/** As migrations que compõem o estado ANTES do reposicionamento SaaS. */ +export const FILES = [ + "supabase/migrations/00000000000000_baseline.sql", + "supabase/migrations/20260812000000_rls_tabelas_orfas_e_search_path.sql", + "supabase/migrations/20260812010000_employees_delete_volta_para_admin.sql", + "supabase/migrations/20260817000000_giro_monthly_snapshots.sql", + "supabase/migrations/20260818010000_saas_dono_opera_o_proprio_plano.sql", +]; + +export interface Policy { + file: string; + name: string; + table: string; + cmd: string; + body: string; +} + +export interface Transformacao { + body: string; + removidos: number; + vazio: boolean; + mexeu: boolean; + suspeita: string | null; +} + +/** + * Anda pela string contando profundidade de `(`/`[`, ciente de "aspas" e + * 'literais'. `fn` recebe (índice, profundidade DEPOIS do char, char) e + * devolver `true` interrompe a varredura naquele índice. + */ +function varrer(s: string, ini: number, fn: (k: number, depth: number, c: string) => boolean | void): number { + let depth = 0, inQ = false, inS = false; + for (let k = ini; k < s.length; k++) { + const c = s[k]; + if (inQ) { if (c === '"') inQ = false; continue; } + if (inS) { if (c === "'") inS = false; continue; } + if (c === '"') { inQ = true; continue; } + if (c === "'") { inS = true; continue; } + if (c === "(" || c === "[") { depth++; continue; } + if (c === ")" || c === "]") { depth--; if (fn(k, depth, c) === true) return k; continue; } + if (fn(k, depth, c) === true) return k; + } + return -1; +} + +/** Extrai os CREATE POLICY completos de um arquivo SQL. */ +export function extrairPolicies(sql: string, file: string): Policy[] { + const out: Policy[] = []; + let i = 0; + while ((i = sql.indexOf("CREATE POLICY", i)) !== -1) { + const end = varrer(sql, i, (_k, depth, c) => c === ";" && depth === 0); + if (end === -1) break; + const body = sql.slice(i, end); + const m = body.match(/CREATE POLICY\s+(?:"([^"]+)"|([^\s"]+))\s+ON\s+"?public"?\."?([a-z_]+)"?/i); + const cmd = (body.match(/\bFOR\s+(SELECT|INSERT|UPDATE|DELETE|ALL)\b/i) ?? [, "ALL"])[1]!.toUpperCase(); + out.push({ file, name: m ? m[1] ?? m[2]! : "?", table: m ? m[3]! : "?", cmd, body }); + i = end + 1; + } + return out; +} + +/** Remove camadas de parênteses que embrulham a expressão INTEIRA. */ +export function desembrulhar(expr: string): string { + let e = expr.trim(); + for (;;) { + if (!e.startsWith("(") || !e.endsWith(")")) return e; + let fecha = -1; + varrer(e, 0, (k, depth, c) => { if (c === ")" && depth === 0) { fecha = k; return true; } }); + if (fecha !== e.length - 1) return e; + e = e.slice(1, -1).trim(); + } +} + +/** Divide nos OR de primeiro nível. */ +export function disjuntos(expr: string): string[] { + const e = desembrulhar(expr); + const cortes: number[] = []; + varrer(e, 0, (k, depth) => { + if (depth !== 0) return; + if (!/^or\b/i.test(e.slice(k))) return; + const antes = k === 0 ? " " : e[k - 1]; + if (!/[\s)]/.test(antes)) return; + cortes.push(k); + }); + const parts: string[] = []; + let start = 0; + for (const k of cortes) { parts.push(e.slice(start, k)); start = k + 2; } + parts.push(e.slice(start)); + return parts.map(s => s.trim()).filter(Boolean); +} + +/** O disjunto é o bypass por papel GLOBAL? (profiles + role + 'consultor') */ +export const ehBypassGlobal = (d: string): boolean => + /profiles/i.test(d) && /'consultor'/.test(d) && /\brole\b/i.test(d); + +/** Conteúdo de USING(...) / WITH CHECK(...) com os limites certos. */ +function fatiarClausula(body: string, kw: string): { ini: number; fim: number; expr: string } | null { + const re = new RegExp(kw.replace(" ", "\\s+") + "\\s*\\(", "i"); + const m = body.match(re); + if (!m || m.index === undefined) return null; + const abre = m.index + m[0].length - 1; + let fim = -1; + varrer(body, abre, (k, depth, c) => { if (c === ")" && depth === 0) { fim = k; return true; } }); + if (fim === -1) return null; + return { ini: abre, fim, expr: body.slice(abre + 1, fim) }; +} + +function transformarExpr(expr: string): { novo: string; removidos: number; vazio: boolean; semantico: boolean } { + const antes = disjuntos(expr); + const depois = antes.filter(d => !ehBypassGlobal(d)); + let novo = depois.join("\n OR "); + const semDisjunto = novo; + // Vínculo: qualquer membro, não só consultor. Duas formas, cada uma com o + // parêntese CASADO — nunca um ")" órfão que desbalanceia a expressão. + novo = novo.replace(/\s+AND\s+\(\s*"?pm"?\."?role"?\s*=\s*'consultor'(?:::"?text"?)?\s*\)/gi, ""); + novo = novo.replace(/\s+AND\s+"?pm"?\."?role"?\s*=\s*'consultor'(?:::"?text"?)?/gi, ""); + const semPapel = novo; + // A view sem filtro de papel. + novo = novo.replace(/"client_project_access"/g, '"project_access"') + .replace(/\bclient_project_access\b/g, "project_access"); + // "Mexeu" tem de significar mudança SEMÂNTICA, não cosmética. `desembrulhar` + // tira parênteses redundantes, e medir o texto faria toda policy embrulhada + // parecer alterada — arrastando pra migration coisas fora do escopo dela + // (foi assim que a tabela deprecated quase entrou). + const semantico = + antes.length !== depois.length || + semPapel !== semDisjunto || + novo !== semPapel; + return { novo, removidos: antes.length - depois.length, vazio: depois.length === 0, semantico }; +} + +/** Parênteses/colchetes balanceados, ignorando literais? */ +export function balanceado(s: string): boolean { + let depth = 0, ok = true, inQ = false, inS = false; + for (const c of s) { + if (inQ) { if (c === '"') inQ = false; continue; } + if (inS) { if (c === "'") inS = false; continue; } + if (c === '"') { inQ = true; continue; } + if (c === "'") { inS = true; continue; } + if (c === "(" || c === "[") depth++; + else if (c === ")" || c === "]") { depth--; if (depth < 0) ok = false; } + } + return ok && depth === 0 && !inQ && !inS; +} + +export function transformarPolicy(p: Policy): Transformacao { + let body = p.body; + let removidos = 0, vazio = false, mexeu = false; + for (const kw of ["WITH CHECK", "USING"]) { + const f = fatiarClausula(body, kw); + if (!f) continue; + const r = transformarExpr(f.expr); + if (r.vazio) vazio = true; + if (r.semantico) mexeu = true; + removidos += r.removidos; + body = body.slice(0, f.ini + 1) + r.novo + body.slice(f.fim); + } + const suspeita = + vazio ? "ficaria sem nenhum disjunto" + : !balanceado(body) ? "parênteses desbalanceados" + : /'consultor'/.test(body) ? "ainda menciona 'consultor'" + : /client_project_access\b(?!_deprecated)/.test(body) ? "ainda aponta pra client_project_access" + : null; + return { body, removidos, vazio, mexeu, suspeita }; +} + +/** Estado vigente das policies + as que o reposicionamento alcança. */ +export function carregar(): { todas: Policy[]; pols: Policy[]; alvo: Policy[] } { + let todas: Policy[] = []; + for (const f of FILES) todas = todas.concat(extrairPolicies(readFileSync(f, "utf8"), f.split("/").pop()!)); + const vigente = new Map(); + for (const p of todas) vigente.set(`${p.table}.${p.name}`, p); + const pols = [...vigente.values()]; + const alvo = pols.filter(p => + (/profiles/i.test(p.body) && /'consultor'/.test(p.body) && /\brole\b/i.test(p.body)) || + /client_project_access/.test(p.body) || + /"?pm"?\."?role"?\s*=\s*'consultor'/i.test(p.body)); + return { todas, pols, alvo }; +} + +/** Separa o alvo em provadas / suspeitas / inertes. */ +export function classificar(alvo: Policy[]) { + const ok: { p: Policy; r: Transformacao }[] = []; + const suspeitas: { p: Policy; r: Transformacao }[] = []; + const inertes: { p: Policy; r: Transformacao }[] = []; + for (const p of alvo) { + const r = transformarPolicy(p); + if (r.suspeita) suspeitas.push({ p, r }); + else if (r.mexeu) ok.push({ p, r }); + else inertes.push({ p, r }); + } + return { ok, suspeitas, inertes }; +} + +function main(): void { + const { todas, pols, alvo } = carregar(); + const iA = process.argv.indexOf("--amostra"); + const nAmostra = iA !== -1 ? Number(process.argv[iA + 1] ?? 0) : 0; + const { ok, suspeitas, inertes } = classificar(alvo); + + console.log(`CREATE POLICY lidos: ${todas.length} · vigentes: ${pols.length}`); + console.log(`alvo: ${alvo.length} policies · ${new Set(alvo.map(p => p.table)).size} tabelas\n`); + console.log(` ✅ transformadas e provadas: ${ok.length}`); + console.log(` ⚠️ suspeitas (NÃO entram no SQL): ${suspeitas.length}`); + console.log(` ⭕ inertes (nada casou): ${inertes.length}`); + console.log(` disjuntos de bypass removidos: ${ok.reduce((a, x) => a + x.r.removidos, 0)}`); + + if (suspeitas.length) { + console.log("\n--- SUSPEITAS ---"); + for (const { p, r } of suspeitas) console.log(` ${p.cmd.padEnd(6)} ${p.table}.${p.name} → ${r.suspeita}`); + } + if (inertes.length) { + console.log("\n--- INERTES (revisar à mão) ---"); + for (const { p } of inertes) console.log(` ${p.cmd.padEnd(6)} ${p.table}.${p.name}`); + } + if (nAmostra) { + console.log(`\n=== AMOSTRA (${nAmostra}) ===`); + for (const { p, r } of ok.slice(0, nAmostra)) { + console.log(`\n######## ${p.cmd} ${p.table}.${p.name}`); + console.log(`--- ANTES\n${p.body.replace(/\s+/g, " ")}`); + console.log(`--- DEPOIS\n${r.body.replace(/\s+/g, " ")}`); + } + } +} + +if (process.argv[1] && /transformar-policies\.ts$/.test(process.argv[1])) main(); diff --git a/scripts/saas/verificar-dump.ts b/scripts/saas/verificar-dump.ts new file mode 100644 index 00000000..a6cca87d --- /dev/null +++ b/scripts/saas/verificar-dump.ts @@ -0,0 +1,87 @@ +/** + * Confere o SaaS sem consultor contra o DUMP — ou seja, contra a realidade. + * + * A migration é intenção; o dump é o que o banco faz. Este script lê o dump e + * responde as três perguntas que fecham as fatias 1–4: + * + * 1. sobrou alguma policy apontando pra `client_project_access`? (esperado: 0) + * 2. quantas passaram a usar `project_access`? (esperado: 97) + * 3. quais ainda mencionam 'consultor'? (esperado: as 4 + * de Comercial/leads, deixadas de fora de propósito) + * + * Uso: npx tsx scripts/saas/verificar-dump.ts [caminho-do-dump] + * (padrão: db/snapshot/schema.sql) + */ +import { readFileSync } from "node:fs"; +import { extrairPolicies } from "./transformar-policies"; + +const ALVO = process.argv[2] ?? "db/snapshot/schema.sql"; + +/** As 4 que ficam de fora por desenho: staff-only, módulo Comercial desligado. */ +const ESPERADAS_COM_CONSULTOR = new Set([ + "commercial_assumptions.commercial_assumptions_select", + "commercial_target_members.commercial_target_members_select", + "commercial_targets.commercial_targets_select", + "leads.leads_owner_insert", +]); + +const pols = extrairPolicies(readFileSync(ALVO, "utf8"), ALVO); + +const usamNova = pols.filter(p => /\bproject_access\b/.test(p.body)); +const usamAntiga = pols.filter(p => /client_project_access\b(?!_deprecated)/.test(p.body)); +const comConsultor = pols.filter(p => /'consultor'/.test(p.body)); + +console.log(`dump: ${ALVO}`); +console.log(`policies lidas: ${pols.length}\n`); + +let falhou = false; +const linha = (ok: boolean, txt: string) => { if (!ok) falhou = true; console.log(` ${ok ? "✅" : "❌"} ${txt}`); }; + +linha(usamAntiga.length === 0, `policies ainda em client_project_access: ${usamAntiga.length} (esperado 0)`); +for (const p of usamAntiga) console.log(` ${p.table}.${p.name}`); + +linha(usamNova.length > 0, `policies usando project_access: ${usamNova.length}`); + +const inesperadas = comConsultor.filter(p => !ESPERADAS_COM_CONSULTOR.has(`${p.table}.${p.name}`)); +linha(inesperadas.length === 0, `policies com 'consultor' fora das 4 previstas: ${inesperadas.length}`); +for (const p of inesperadas) console.log(` ${p.table}.${p.name}`); + +const faltando = [...ESPERADAS_COM_CONSULTOR].filter(k => !comConsultor.some(p => `${p.table}.${p.name}` === k)); +linha(faltando.length === 0, `as 4 previstas seguem intactas (faltando: ${faltando.length})`); +for (const k of faltando) console.log(` ${k}`); + +// A view precisa existir e NÃO pode filtrar papel — se filtrar, ela vira a antiga. +const sql = readFileSync(ALVO, "utf8"); +const mView = sql.match(/CREATE (?:OR REPLACE )?VIEW "public"\."project_access" AS[\s\S]*?;/); +linha(!!mView, "view project_access existe no dump"); +if (mView) { + linha(!/WHERE[\s\S]*'cliente'/.test(mView[0]), + "project_access NÃO filtra role='cliente' (senão excluiria o consultor)"); + linha(!/security_invoker/i.test(mView[0]), + "project_access SEM security_invoker (roda como owner, ignora a RLS de project_members)"); +} + +// --------------------------------------------------------------------------- +// Vazamento multi-tenant: policies que liberam por ESTAR LOGADO. +// +// As que podem ficar são SELECT em catálogo GLOBAL — tabela sem chave de tenant +// (`project_id`/`client_id`), que é conhecimento compartilhado da casa, não dado +// de cliente. Qualquer outra que apareça aqui é vazamento novo. +// --------------------------------------------------------------------------- +const CATALOGO_GLOBAL = new Set([ + "categorization_patterns", "class_lessons", "class_levels", "class_modules", + "dre_categories", "financial_benchmarks", "library_content", "subcategories", +]); + +const abertas = pols.filter(p => + /auth"?\."?role"?\(\)\s*=\s*'authenticated'/.test(p.body) || + /USING \(true\)|WITH CHECK \(true\)/.test(p.body)); +const vazando = abertas.filter(p => !(CATALOGO_GLOBAL.has(p.table) && p.cmd === "SELECT")); + +console.log(""); +linha(vazando.length === 0, + `policies abertas por "estar logado" fora de catálogo global: ${vazando.length} (total aberto: ${abertas.length})`); +for (const p of vazando) console.log(` ${p.cmd} ${p.table}.${p.name}`); + +console.log(falhou ? "\n❌ REPROVOU\n" : "\n✅ o dump confirma as fatias 1–4 e o fechamento do vazamento\n"); +process.exit(falhou ? 1 : 0); diff --git a/src/actions/conta/membership.ts b/src/actions/conta/membership.ts index a3785b27..1fe7d604 100644 --- a/src/actions/conta/membership.ts +++ b/src/actions/conta/membership.ts @@ -64,7 +64,7 @@ export async function getMembership(clientId: string): Promise : { data: [] as { user_id: string }[] }; if (!members || members.length === 0) { console.error("[conta] getMembership: não-membro", { uid: user.id, role, clientId, projetosDoGrupo: ids.length }); - return { ok: false, reason: "Você ainda não tem acesso a este grupo. Fale com o consultor." }; + return { ok: false, reason: "Você ainda não tem acesso a este grupo. Peça a quem administra a conta para te incluir em Minha Conta → Empresa → Pessoas." }; } return { ok: true, userId: user.id, role: role || "cliente" }; } diff --git a/src/app/api/ai/chat/consultor/route.ts b/src/app/api/ai/chat/consultor/route.ts index a27f9a16..bbc14fae 100644 --- a/src/app/api/ai/chat/consultor/route.ts +++ b/src/app/api/ai/chat/consultor/route.ts @@ -33,7 +33,7 @@ import { } from "@/lib/ai-prompts"; import { buildConsultantTools } from "@/lib/ai-tools"; import { recordAiCall } from "@/lib/ai-client"; -import { saveChatMessage, loadChatHistory } from "@/lib/htr-consultant-agent"; +import { saveChatMessage, loadChatHistory, isModoConsultor } from "@/lib/htr-consultant-agent"; import { buildConsultorIaSystemPrompt, decideDossierLayerFromIntent, @@ -164,31 +164,32 @@ export async function POST(request: NextRequest) { } const { dossier, sliced } = dossierResult; - const analysesCompleted = dossier.analyses_completed ?? 0; + // D10: a régua é a apuração (12 meses da janela fechados), não a contagem + // de análises do modelo antigo. `isModoConsultor` já encapsula isso. + const modoConsultor = isModoConsultor(dossier); const phase = dossier.phase; const semana = dossier.plan?.semana_atual; const factsBlock = formatFACTSBlock(intent.category, sliced); const memories = - analysesCompleted >= 30 ? await getRelevantMemories(projectId, intent, 5) : []; + modoConsultor ? await getRelevantMemories(projectId, intent, 5) : []; const systemPrompt = buildConsultorIaSystemPrompt({ factsBlock, memories, phaseFocus: getConsultantPromptByPhase(phase, semana), phaseGuardrails: getPhaseGuardrails(phase), - analysesCompleted, phase, semana, - toolsEnabled: analysesCompleted >= 30, + toolsEnabled: modoConsultor, }); // Persiste user message com source='consultor' await saveChatMessage(projectId, "user", lastUser.content, user.id, SOURCE); const tools = - analysesCompleted >= 30 ? buildConsultantTools({ projectId, supabase }) : undefined; + modoConsultor ? buildConsultantTools({ projectId, supabase }) : undefined; const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY }); const started = Date.now(); diff --git a/src/app/api/ai/htr/chat/route.ts b/src/app/api/ai/htr/chat/route.ts index 0034404f..194f14c2 100644 --- a/src/app/api/ai/htr/chat/route.ts +++ b/src/app/api/ai/htr/chat/route.ts @@ -36,6 +36,7 @@ import { recordAiCall } from "@/lib/ai-client"; import { saveChatMessage, loadChatHistory, + isModoConsultor, } from "@/lib/htr-consultant-agent"; export const runtime = "nodejs"; @@ -176,35 +177,34 @@ export async function POST(request: NextRequest) { } const { dossier, sliced } = dossierResult; - const analysesCompleted = dossier.analyses_completed ?? 0; + // D10: a régua é a apuração (12 meses da janela fechados), não a contagem + // de análises do modelo antigo. `isModoConsultor` já encapsula isso. + const modoConsultor = isModoConsultor(dossier); const phase = dossier.phase; const semana = dossier.plan?.semana_atual; const factsBlock = formatFACTSBlock(intent.category, sliced); - const memories = - analysesCompleted >= 30 - ? await getRelevantMemories(projectId, intent, 5) - : []; + const memories = modoConsultor + ? await getRelevantMemories(projectId, intent, 5) + : []; const systemPrompt = buildSystemPrompt({ factsBlock, memories, phaseFocus: getConsultantPromptByPhase(phase, semana), phaseGuardrails: getPhaseGuardrails(phase), - analysesCompleted, phase, semana, - toolsEnabled: analysesCompleted >= 30, + toolsEnabled: modoConsultor, }); // Persiste a user message antes do stream (a UI já a adicionou localmente). await saveChatMessage(projectId, "user", lastUser.content, user.id); - const tools = - analysesCompleted >= 30 - ? buildConsultantTools({ projectId, supabase }) - : undefined; + const tools = modoConsultor + ? buildConsultantTools({ projectId, supabase }) + : undefined; const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY }); const started = Date.now(); @@ -350,9 +350,9 @@ function buildSystemPrompt(opts: { }>; phaseFocus: string; phaseGuardrails: string; - analysesCompleted: number; phase: string; semana?: number; + /** D10: modo Consultor = 12 meses da janela fechados (`isModoConsultor`). */ toolsEnabled: boolean; }): string { const { @@ -360,7 +360,6 @@ function buildSystemPrompt(opts: { memories, phaseFocus, phaseGuardrails, - analysesCompleted, phase, semana, toolsEnabled, @@ -380,25 +379,24 @@ function buildSystemPrompt(opts: { .join("\n")}\n` : ""; - if (analysesCompleted < 30) { - // Modo Assistente — sem tool-use, sem análise estratégica. - return `Você é o Assistente do ATR OS — guia inteligente para diagnóstico empresarial. + if (!toolsEnabled) { + // Modo Assistente — a apuração ainda não fechou os 12 meses da janela. + // Sem tool-use. NÃO é mudez: o que já está apurado pode ser lido. + return `Você é o Assistente do ATR OS — o guia do dono da empresa enquanto os números entram. SEU PAPEL: -- Explicar o sistema ATR OS e guiar o preenchimento das 30 análises de diagnóstico -- Tirar dúvidas sobre terminologia empresarial (EBITDA, CMV, CAC, LTV, etc.) -- Orientar qual módulo priorizar (Financeiro → Comercial → Gestão) -- Mostrar o benefício de concluir o diagnóstico completo +- Guiar a apuração: subir os extratos OFX, organizar as contas por empresa, categorizar e fechar o mês no gate. +- Explicar o que cada passo destrava, em linguagem de negócio. +- Tirar dúvidas de terminologia (EBITDA, CMV, margem de contribuição, capital de giro). +- Ler o que JÁ está apurado. Mês fechado é número real — pode falar dele. REGRAS ABSOLUTAS: -- NÃO faça análises estratégicas, não interprete dados, não sugira ações de negócio. -- Se o cliente pedir algo estratégico, responda: - "Para ter acesso ao Consultor ATR com análises completas da sua empresa, - precisamos primeiro concluir o diagnóstico (${analysesCompleted}/30 análises feitas). - Posso te ajudar a entender quais análises faltam?" -- Respostas em português brasileiro, tom profissional e acessível. - -PROGRESSO ATUAL: ${analysesCompleted}/30 análises concluídas. +- NUNCA mencione processo interno: "análises", "fases", "ciclo", percentuais de progresso. O cliente fala de negócio. +- NÃO projete tendência, não compare períodos nem recomende decisão estrutural (preço, crédito, corte, investimento) enquanto os 12 meses não fecharem — a base ainda é curta e a leitura sairia torta. +- Se pedirem uma leitura que exige a série completa, seja concreto sobre o que falta: + "Ainda não tenho os 12 meses fechados — sem eles, uma leitura de tendência sairia torta. + Vamos fechar os meses que faltam na Apuração? Aí eu entro de verdade." +- Respostas em português brasileiro, tom direto e prático. ${factsBlock} @@ -416,7 +414,7 @@ FERRAMENTAS DISPONÍVEIS (use-as ativamente): - compareToBenchmark({ mes? }): compara os KPIs do mês com o benchmark do segmento do cliente (crítico/atenção/saudável/excelente por indicador). - getActiveHypothesesAndTests({ module? }): hipóteses aprovadas/em-teste e testes em execução. Use para panorama do HTR e para evitar sugerir duplicatas. - getHypothesisHistory({ module?, limit? }): hipóteses validadas/descartadas com testes e resultados — aprender com o passado. -- getAnalysisDiagnosis({ module? }): diagnóstico consolidado das análises concluídas (parecer + resultado por análise). +- getDreReadiness(): quanto da história já está apurado (meses fechados, consecutivos, anos completos) e \`bloqueios\` — o que trava o gate do próximo fechamento. Use para "minha DRE está pronta?" e "o que falta pra fechar o mês?". - getPlanoVoo(): snapshot mais recente do Plano de Voo (gaps, alavancas, plano 12 semanas, resumo executivo). - searchMemories({ query?, module?, memory_type?, tags?, limit? }): busca em memórias (aprendizados, insights, decisões, marcos). Use antes de sugerir algo novo. @@ -424,9 +422,9 @@ REGRAS DE USO DAS FERRAMENTAS: 1. SEMPRE chame getFinancialSnapshot (ou getFinancialTimeline) ANTES de citar qualquer número financeiro. O FACTS block abaixo é resumo — a tool é a fonte de verdade. 2. Para responder sobre "como estou vs. mercado", use compareToBenchmark. 3. Para andamento do ciclo HTR, use getActiveHypothesesAndTests; para aprender com o passado, getHypothesisHistory. -4. Para citar o diagnóstico inicial ou o plano aprovado, use getAnalysisDiagnosis e getPlanoVoo. +4. Para citar a meta travada e o plano vigente, use getPlanoVoo. 5. ANTES de propor uma nova ação, chame searchMemories para verificar se já foi tentada/descartada. -6. Se a tool retornar \`error\` ou lista vazia, responda explicitamente: "Não tenho esse dado no diagnóstico ainda." Nunca invente. +6. Se a tool retornar \`error\` ou lista vazia, responda explicitamente: "Não tenho esse dado apurado ainda." Nunca invente. 7. Cite a origem do número ao reportá-lo (ex.: "segundo o snapshot de ${phase === "execucao" ? "outubro" : "o mês mais recente"}..."). ` : ""; diff --git a/src/app/api/ai/plano-voo/v2/generate/route.ts b/src/app/api/ai/plano-voo/v2/generate/route.ts index d76b0f05..4686e0bc 100644 --- a/src/app/api/ai/plano-voo/v2/generate/route.ts +++ b/src/app/api/ai/plano-voo/v2/generate/route.ts @@ -17,6 +17,7 @@ import { NextRequest, NextResponse } from "next/server"; import { createClient } from "@/lib/supabase/server"; import { canOperateProject } from "@/lib/auth/project-access"; +import { planoVooDestravado } from "@/lib/plano-voo-gate"; import { generatePlanoVooV2 } from "@/lib/plano-voo-v2-service"; export const runtime = "nodejs"; @@ -51,11 +52,22 @@ export async function POST(request: NextRequest) { if (!(await canOperateProject(supabase, user.id, projectId))) { return NextResponse.json( - { error: "Apenas consultor/admin podem gerar o Plano de Voo." }, + { error: "Você não tem acesso a este projeto." }, { status: 403 } ); } + // Gate de QUALIDADE, não de papel — mesmo motivo da action equivalente: + // sob SaaS o dono gera a própria meta, então os 12 meses fechados + // precisam ser exigidos aqui, não só no render da página. + const readiness = await planoVooDestravado(projectId); + if (!readiness.pronto) { + return NextResponse.json( + { error: readiness.motivo ?? "Dado financeiro insuficiente para a meta de 12 meses." }, + { status: 409 } + ); + } + const result = await generatePlanoVooV2(projectId); return NextResponse.json({ diff --git a/src/app/api/projetos/[id]/cockpit/route.ts b/src/app/api/projetos/[id]/cockpit/route.ts index ffbdfcd3..94513eb3 100644 --- a/src/app/api/projetos/[id]/cockpit/route.ts +++ b/src/app/api/projetos/[id]/cockpit/route.ts @@ -12,6 +12,7 @@ import { NextRequest, NextResponse } from "next/server"; import { createClient } from "@/lib/supabase/server"; +import { snapshotAprovadoVigente } from "@/lib/plano-voo/snapshot-vigente"; interface Params { params: Promise<{ id: string }>; @@ -30,10 +31,27 @@ export async function GET(req: NextRequest, { params }: Params) { const url = new URL(req.url); const semanaParam = url.searchParams.get("semana"); + // O Cockpit mostra as ações do plano VIGENTE, não as do projeto inteiro. + // Aprovar um plano novo arquiva o anterior mas não mexe nas ações dele — + // lendo por `project_id` o dono via 24 ações, metade de um plano que já não + // vale. Filtrar aqui preserva o histórico (nada é apagado) e ainda descarta + // as órfãs de `snapshot_id` nulo. Ver `plano-voo/snapshot-vigente.ts`. + const snapshotId = await snapshotAprovadoVigente(supabase, projectId); + if (!snapshotId) { + // Sem plano aprovado não há semana nenhuma para executar. + return NextResponse.json({ + acoes: [], + por_semana: {}, + semana_atual: 1, + progresso: { total: 0, concluidas: 0, percentual: 0 }, + }); + } + let query = supabase .from("cockpit_actions") .select("*") .eq("project_id", projectId) + .eq("snapshot_id", snapshotId) .order("week", { ascending: true }) .order("fase", { ascending: true }); diff --git a/src/app/api/projetos/[id]/dre/detalhamento/antecipacao-linhas/route.ts b/src/app/api/projetos/[id]/dre/detalhamento/antecipacao-linhas/route.ts index caffba5f..7db17a95 100644 --- a/src/app/api/projetos/[id]/dre/detalhamento/antecipacao-linhas/route.ts +++ b/src/app/api/projetos/[id]/dre/detalhamento/antecipacao-linhas/route.ts @@ -15,7 +15,11 @@ * } * * Faz: - * 1. UPSERT em dre_detalhamento_antecipacao_linha pra cada linha. + * E3-B — quando vem `taxaDeclarada`, o mês inteiro roda no modo "sei a taxa que + pago": a face de cada operação é DERIVADA do próprio crédito do extrato, e + nenhuma linha individual é lida ou gravada (modo='por_taxa'). + + 1. UPSERT em dre_detalhamento_antecipacao_linha pra cada linha. * 2. Carrega transações de antecipação do mês. * 3. Roda resolverAntecipacaoMultinivel — motor puro. * 4. Persiste 1 registro dre_detalhamento (tipo='antecipacao', modo='por_linha'|'hibrido'). @@ -28,8 +32,10 @@ import { createClient as createServiceClient } from "@supabase/supabase-js"; import { agruparPendencias } from "@/lib/dre-detalhamento/pendencias"; import { resolverAntecipacaoMultinivel, + resolverAntecipacaoPorTaxaDeclarada, type AntecipacaoLinha, type NivelGeral, + type TaxaDeclarada, } from "@/lib/dre-detalhamento/antecipacao-multinivel"; import { SUBCATEGORIAS_PMT_ANTECIPACAO } from "@/lib/dre-detalhamento/decomposicao"; import type { LancamentoPendencia } from "@/lib/dre-detalhamento/types"; @@ -74,6 +80,9 @@ async function ensureMembership(projectId: string): Promise { interface AntecipacaoLinhasPayload { mes_referencia: string; + /** E3-B — modo "sei a taxa que pago". Presente ⇒ ignora face/linhas e deriva + * a face de cada crédito do extrato. É o switch do mês, não da linha. */ + taxaDeclarada?: TaxaDeclarada | null; nivelGeral?: NivelGeral; linhas: Array<{ transaction_id: string; @@ -120,7 +129,14 @@ export async function POST( const txsAntDoMes = allTxs.filter(t => subsAnt.includes(t.subcategoria_id || "")); const idsAntDoMes = new Set(txsAntDoMes.map(t => t.id)); - const linhasValidas = (body.linhas || []).filter(l => idsAntDoMes.has(l.transaction_id)); + // E3-B: no modo taxa não existe linha individual — a face de cada operação + // vem da taxa do mês. Gravar linha aqui deixaria resíduo que o modo borderô + // leria depois como "face declarada pelo dono". + const modoTaxa: TaxaDeclarada | null = body.taxaDeclarada ?? null; + + const linhasValidas = modoTaxa + ? [] + : (body.linhas || []).filter(l => idsAntDoMes.has(l.transaction_id)); if (linhasValidas.length > 0) { const rows = linhasValidas.map(l => ({ project_id: projectId, @@ -165,11 +181,14 @@ export async function POST( }; }); - const resolucao = resolverAntecipacaoMultinivel(input, body.nivelGeral ?? null); + const resolucao = modoTaxa + ? resolverAntecipacaoPorTaxaDeclarada(input, modoTaxa) + : resolverAntecipacaoMultinivel(input, body.nivelGeral ?? null); // 4) Persiste resultado consolidado em dre_detalhamento. - const modo: "por_linha" | "hibrido" = - resolucao.detalhadas === input.length ? "por_linha" : "hibrido"; + const modo: "por_linha" | "hibrido" | "por_taxa" = modoTaxa + ? "por_taxa" + : (resolucao.detalhadas === input.length ? "por_linha" : "hibrido"); const { error: detErr } = await service .from("dre_detalhamento") @@ -179,6 +198,10 @@ export async function POST( tipo: "antecipacao", modo, dados: { + // A taxa que o dono declarou, como ele digitou (% AO MÊS). É o + // que reconstitui a tela e o que permite auditar a face estimada + // depois — sem ela, o número derivado vira órfão. + taxaDeclarada: modoTaxa, nivelGeral: body.nivelGeral ?? null, detalhadas: resolucao.detalhadas, por_media: resolucao.por_media, diff --git a/src/app/api/projetos/[id]/financeiro/concluir/route.ts b/src/app/api/projetos/[id]/financeiro/concluir/route.ts deleted file mode 100644 index b70550d3..00000000 --- a/src/app/api/projetos/[id]/financeiro/concluir/route.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { createClient } from "@/lib/supabase/server"; -import { NextRequest, NextResponse } from "next/server"; -import { - concluirDREForense, - validarCriteriosDRE, - invalidarCacheDossie -} from "@/lib/auto-conclusion-service"; -import { KEY_TO_NUMERO_FINANCEIRO } from "@/lib/module-service"; -import { checkAndTriggerFinanceiroHypotheses } from "@/lib/pe35-auto-gen-service"; - -/** - * POST /api/projetos/[id]/financeiro/concluir - * - * Sprint 3: Endpoint atualizado com lógica de gate da DRE Gerencial. - * - * Para analysisKey === 'dre': - * 1. Valida critérios de conclusão (extratos, categorização, DRE salva, parecer) - * 2. Conclui a DRE Gerencial - * 3. Auto-conclui análises do Grupo A (#2, #5, #8, #10) - * 4. Invalida cache do dossiê de IA - * - * Para outras análises: - * 1. Verifica se DRE está concluída (gate) - * 2. Se não → retorna 403 - * 3. Se sim → conclui normalmente - * - * Body: { analysisKey: "dre" | "auditoria-custos" | "margem" | ... } - */ - -// slug→numero DERIVADO da fonte única (module-service.ts) — não redeclarar aqui -const KEY_TO_NUMERO = KEY_TO_NUMERO_FINANCEIRO; - -export async function POST( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const { id: projectId } = await params; - const body = await request.json(); - const { analysisKey } = body; - - if (!analysisKey || !KEY_TO_NUMERO[analysisKey]) { - return NextResponse.json( - { error: `Chave de análise inválida: ${analysisKey}` }, - { status: 400 } - ); - } - - const numero = KEY_TO_NUMERO[analysisKey]; - const supabase = await createClient(); - - // 1. Buscar o module_id do módulo financeiro deste projeto - const { data: modulo, error: moduloError } = await supabase - .from('project_modules') - .select('id') - .eq('project_id', projectId) - .eq('tipo', 'financeiro') - .single(); - - if (moduloError || !modulo) { - return NextResponse.json( - { error: 'Módulo financeiro não encontrado para este projeto' }, - { status: 404 } - ); - } - - // ════════════════════════════════════════════════════════ - // CASO ESPECIAL: Conclusão da DRE Gerencial (análise #1) - // Dispara validações + cascata do Grupo A - // ════════════════════════════════════════════════════════ - if (analysisKey === 'dre') { - const result = await concluirDREForense(projectId, modulo.id); - - if (!result.success) { - return NextResponse.json( - { - error: result.error, - type: 'validation_failed', - }, - { status: 422 } // Unprocessable Entity — dados insuficientes - ); - } - - // Plug C (V3): cold start das Iniciativas a partir do laudo do último - // mês fechado. O gatilho 30/30 (checkAndTriggerAutoGen) foi aposentado - // — geração por gaps morreu (D3) e o banco só aceita financeiro. - await checkAndTriggerFinanceiroHypotheses(projectId).catch((err) => { - console.error('[Concluir/DRE] Plug C financeiro falhou:', err); - }); - - return NextResponse.json({ - success: true, - analysis: { - numero: 1, - nome: "DRE Gerencial", - status: "concluido", - }, - cascade: result.cascade, - message: `DRE Gerencial concluída. ${result.cascade?.concluidas.length || 0} análises derivadas auto-concluídas.`, - }); - } - - // ════════════════════════════════════════════════════════ - // GATE: Verifica se a DRE está concluída antes de - // permitir conclusão de qualquer outra análise - // ════════════════════════════════════════════════════════ - const { data: dreAnalise } = await supabase - .from('module_analyses') - .select('status') - .eq('module_id', modulo.id) - .eq('numero', 1) - .single(); - - if (!dreAnalise || dreAnalise.status !== 'concluido') { - return NextResponse.json( - { - error: 'DRE Gerencial deve ser concluída antes de qualquer outra análise.', - type: 'gate_blocked', - }, - { status: 403 } - ); - } - - // ════════════════════════════════════════════════════════ - // CONCLUSÃO PADRÃO para análises 2-10 - // ════════════════════════════════════════════════════════ - const { data: analysis, error: updateError } = await supabase - .from('module_analyses') - .update({ - status: 'concluido', - data_conclusao: new Date().toISOString().split('T')[0], - }) - .eq('module_id', modulo.id) - .eq('numero', numero) - .select() - .single(); - - if (updateError) { - console.error('[Concluir] Erro ao atualizar análise:', updateError); - return NextResponse.json( - { error: updateError.message }, - { status: 500 } - ); - } - - // Invalida cache do dossiê sempre que uma análise é concluída - await invalidarCacheDossie(projectId); - - // Os triggers no banco (trg_analise_progresso + trg_modulo_progresso) - // recalculam automaticamente o progresso do módulo e do projeto. - - // Plug C (V3): idempotente — garante Iniciativas do financeiro a partir - // do laudo do último mês fechado (30/30 aposentado na V3). - await checkAndTriggerFinanceiroHypotheses(projectId).catch((err) => { - console.error('[Concluir] Plug C financeiro falhou:', err); - }); - - return NextResponse.json({ - success: true, - analysis: { - id: analysis.id, - numero: analysis.numero, - nome: analysis.nome, - status: analysis.status, - } - }); - } catch (err) { - console.error('[Concluir] Exceção:', err); - return NextResponse.json( - { error: 'Erro interno ao concluir análise' }, - { status: 500 } - ); - } -} - -/** - * GET /api/projetos/[id]/financeiro/concluir - * - * Retorna o status de validação da DRE para feedback em tempo real. - * Usado pelo frontend para mostrar progresso do checklist de conclusão. - */ -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const { id: projectId } = await params; - const validacao = await validarCriteriosDRE(projectId); - - return NextResponse.json({ - ready: validacao.valida, - motivo: validacao.motivo, - detalhes: validacao.detalhes, - }); - } catch (err) { - console.error('[Concluir/GET] Exceção:', err); - return NextResponse.json( - { error: 'Erro ao validar critérios' }, - { status: 500 } - ); - } -} diff --git a/src/app/api/projetos/[id]/financeiro/publicar/route.ts b/src/app/api/projetos/[id]/financeiro/publicar/route.ts deleted file mode 100644 index ccc266b9..00000000 --- a/src/app/api/projetos/[id]/financeiro/publicar/route.ts +++ /dev/null @@ -1,259 +0,0 @@ -/** - * POST /api/projetos/[id]/financeiro/publicar - * Body: { threshold_target: 'preliminar' | 'consolidada' } - * - * Publica a DRE Gerencial num nível-alvo. Backend: - * 1. Valida readiness via RPC `get_dre_readiness` (snapshots ≥ N + cat+sub 100%). - * 2. Atualiza `module_analyses[numero=1]`: publish_status, published_at/by/threshold, - * e o status legacy (mapeado via `legacyStatusFromPublish`) pra preservar - * triggers de progresso (`trg_analise_progresso`). - * 3. Dispara cascata Grupo A (#2, #5, #8, #10) — análises auto-concluíveis a partir - * da DRE, agora possíveis em estado preliminar (3+m), antes só em consolidada. - * 4. Invalida cache do dossier de IA. - * - * RLS bloqueia client de atualizar `module_analyses`, então usa SUPABASE_SERVICE_ROLE - * após validar membership. - */ - -import { NextRequest, NextResponse } from "next/server"; -import { createClient as createSsrClient } from "@/lib/supabase/server"; -import { createClient as createServiceClient } from "@supabase/supabase-js"; -import { - legacyStatusFromPublish, - type DreReadiness, - type ThresholdTarget, - type PublishResult, -} from "@/lib/dre-publishing-service"; -import { invalidarCacheDossie } from "@/lib/auto-conclusion-service"; - -function getServiceClient() { - const url = process.env.NEXT_PUBLIC_SUPABASE_URL; - const key = process.env.SUPABASE_SERVICE_ROLE_KEY; - if (!url || !key) { - throw new Error("NEXT_PUBLIC_SUPABASE_URL ou SUPABASE_SERVICE_ROLE_KEY não configurados"); - } - return createServiceClient(url, key, { - auth: { autoRefreshToken: false, persistSession: false }, - }); -} - -async function ensureMembership(projectId: string): Promise { - const supabase = await createSsrClient(); - const { data: { user } } = await supabase.auth.getUser(); - if (!user) return null; - - const { data: member } = await supabase - .from("project_members") - .select("user_id") - .eq("project_id", projectId) - .eq("user_id", user.id) - .maybeSingle(); - - return member ? user.id : null; -} - -/** - * Cascata Grupo A — análises derivadas da DRE que podem ser auto-concluídas. - * - * Inclui: - * #2 Auditoria de Estrutura de Custos - * #5 Cálculo de Ponto de Equilíbrio - * #8 Política de Pró-Labore e Lucro - * #10 Análise de CAC vs LTV - * - * Note: roda em ambos os thresholds (preliminar e consolidada). Se já estiver - * concluída de uma rodada anterior, pula sem erro. - */ -async function cascataGrupoA( - moduleId: string, - thresholdTarget: ThresholdTarget -): Promise<{ concluidas: string[]; erros: string[] }> { - const service = getServiceClient(); - const concluidas: string[] = []; - const erros: string[] = []; - - const { data: analyses } = await service - .from("module_analyses") - .select("numero, nome, status") - .eq("module_id", moduleId) - .in("numero", [2, 5, 8, 10]); - - const pendentes = (analyses || []).filter(a => a.status !== "concluido"); - - const pareceres: Record = { - 2: "Auditoria de Custos auto-concluída a partir da DRE publicada. Percentuais de despesas fixas, variáveis e financeiras extraídos do snapshot mensal.", - 5: "Ponto de Equilíbrio calculado a partir da margem de contribuição e custos fixos da DRE publicada.", - 8: "Análise de Remuneração de Sócios e Distribuição produzida a partir das transações categorizadas. Recomenda-se revisão pelo consultor.", - 10: "CAC vs LTV calculados a partir das transações classificadas. Concentração e dependência identificadas.", - }; - - for (const analise of pendentes) { - const { error } = await service - .from("module_analyses") - .update({ - status: "concluido", - data_conclusao: new Date().toISOString().split("T")[0], - parecer: pareceres[analise.numero] || "Auto-concluída pós-publicação da DRE.", - }) - .eq("module_id", moduleId) - .eq("numero", analise.numero); - - if (error) { - erros.push(`${analise.nome}: ${error.message}`); - } else { - concluidas.push(analise.nome); - } - } - - return { concluidas, erros }; -} - -export async function POST( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const { id: projectId } = await params; - const body = await request.json(); - const target = body?.threshold_target as ThresholdTarget; - - if (target !== "preliminar" && target !== "consolidada") { - return NextResponse.json( - { success: false, error: "threshold_target deve ser 'preliminar' ou 'consolidada'" }, - { status: 400 } - ); - } - - const userId = await ensureMembership(projectId); - if (!userId) { - return NextResponse.json( - { success: false, error: "Não autorizado" }, - { status: 403 } - ); - } - - const service = getServiceClient(); - - // 1. Lê readiness atual (RETURNS TABLE → array) - const { data: readinessData, error: readErr } = await service - .rpc("get_dre_readiness", { p_project_id: projectId }); - - if (readErr) { - return NextResponse.json( - { success: false, error: `Não foi possível ler readiness: ${readErr.message}` }, - { status: 500 } - ); - } - - const r = (Array.isArray(readinessData) ? readinessData[0] : readinessData) as DreReadiness | null; - if (!r) { - return NextResponse.json( - { success: false, error: "Readiness retornou vazio — função RPC pode não estar disponível" }, - { status: 500 } - ); - } - - // 2. Valida que o nível alvo é alcançável - const minSnapshots = target === "consolidada" ? 12 : 3; - if (r.snapshots_total < minSnapshots) { - return NextResponse.json( - { - success: false, - error: `Mínimo ${minSnapshots} meses fechados para "${target}" — atualmente ${r.snapshots_total}.`, - bloqueios: r.bloqueios, - }, - { status: 422 } - ); - } - - if (r.bloqueios.length > 0) { - return NextResponse.json( - { - success: false, - error: r.bloqueios[0], - bloqueios: r.bloqueios, - }, - { status: 422 } - ); - } - - // Promoção válida? rascunho→preliminar OK. preliminar→consolidada OK. - // preliminar→preliminar (re-publicar mesmo nível) bloqueado: idempotência - // se já está consolidada, não pode "voltar" pra preliminar (downgrade não suportado V1). - if (target === "preliminar" && r.publish_status !== "rascunho") { - return NextResponse.json( - { - success: false, - error: r.publish_status === "preliminar" - ? "DRE já está publicada como preliminar." - : "DRE consolidada não pode ser rebaixada para preliminar.", - }, - { status: 422 } - ); - } - if (target === "consolidada" && r.publish_status === "consolidada") { - return NextResponse.json( - { success: false, error: "DRE já está publicada como consolidada." }, - { status: 422 } - ); - } - - // 3. Localiza module_id do módulo financeiro - const { data: modulo, error: moduloErr } = await service - .from("project_modules") - .select("id") - .eq("project_id", projectId) - .eq("tipo", "financeiro") - .single(); - - if (moduloErr || !modulo) { - return NextResponse.json( - { success: false, error: "Módulo financeiro não encontrado para este projeto" }, - { status: 404 } - ); - } - - // 4. Atualiza module_analyses[numero=1] com publish_status + status legacy - const nowIso = new Date().toISOString(); - const { error: updateErr } = await service - .from("module_analyses") - .update({ - publish_status: target, - status: legacyStatusFromPublish(target), - published_at: nowIso, - published_by: userId, - published_threshold: r.snapshots_total, - data_conclusao: target === "consolidada" ? nowIso.split("T")[0] : null, - }) - .eq("module_id", modulo.id) - .eq("numero", 1); - - if (updateErr) { - console.error("[publicar] update error:", updateErr); - return NextResponse.json( - { success: false, error: `Erro ao gravar publicação: ${updateErr.message}` }, - { status: 500 } - ); - } - - // 5. Cascata Grupo A - const cascade = await cascataGrupoA(modulo.id, target); - - // 6. Invalida cache do dossier - await invalidarCacheDossie(projectId); - - const result: PublishResult = { - success: true, - publish_status: target, - published_at: nowIso, - published_threshold: r.snapshots_total, - cascade, - }; - - return NextResponse.json(result); - } catch (err) { - console.error("[publicar/POST] exceção:", err); - const message = err instanceof Error ? err.message : "Erro interno"; - return NextResponse.json({ success: false, error: message }, { status: 500 }); - } -} diff --git a/src/app/projetos/[id]/plano-voo/GenerateButton.tsx b/src/app/projetos/[id]/plano-voo/GenerateButton.tsx index d39f1cc2..9d9a21da 100644 --- a/src/app/projetos/[id]/plano-voo/GenerateButton.tsx +++ b/src/app/projetos/[id]/plano-voo/GenerateButton.tsx @@ -6,11 +6,13 @@ import { Loader2, Sparkles } from "lucide-react"; import { Button } from "@/components/ui"; import { generatePlanoVooV2Action } from "./actions"; +// D10: "Coletando dados das 30 análises" saiu — o modelo de 30 análises está +// aposentado, e este texto aparece para a DONA enquanto o Plano é gerado. const STEPS = [ - "Coletando dados das 30 análises", - "Calculando cenários financeiros", - "Redigindo narrativa de consultor", - "Salvando snapshot", + "Lendo os seus 12 meses fechados", + "Calculando a meta e a curva do ano", + "Escrevendo o diagnóstico que a sustenta", + "Salvando a versão", ]; export function GenerateButton({ diff --git a/src/app/projetos/[id]/plano-voo/TravarMetaCard.tsx b/src/app/projetos/[id]/plano-voo/TravarMetaCard.tsx new file mode 100644 index 00000000..3efb16b7 --- /dev/null +++ b/src/app/projetos/[id]/plano-voo/TravarMetaCard.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { useMemo, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2, Lock, Pencil, RotateCcw, Check } from "lucide-react"; +import { Button } from "@/components/ui"; +import { ajustarReceitaDaMeta } from "@/lib/plano-voo/ajuste-meta"; +import type { Meta12m } from "@/types/plano-voo-v2"; +import { travarMetaAction } from "./actions"; + +/** + * O ato do dono sobre a própria meta — E6-A + D11 (Lucas, 2026-08-20). + * + * ANTES: o único lugar que chamava `approvePlanoVooV2Action` era o + * `WorkflowControls`, montado numa página que redirecionava quem não fosse + * `PRIVILEGED_ROLES`. O motor de aprovação já era client-first (vínculo, não + * papel, + gate de qualidade no caminho de ESCRITA) — faltava a PORTA. Esta é a + * porta, e ela mora no overview: `/plano-voo/documento` continua sendo o console + * editorial da ATR (10 narrativas + parecer), que não é assunto da dona. + * + * `escolher → ajustar → travar` é UMA decisão, num round-trip só. A + * pré-visualização roda no client com a MESMA função pura que o server usa ao + * gravar (`ajustarReceitaDaMeta`) — o número que ela vê enquanto digita é o + * número que vai ser travado, não uma aproximação de tela. + */ + +const fmtBRL = (v: number) => + v.toLocaleString("pt-BR", { style: "currency", currency: "BRL", maximumFractionDigits: 0 }); + +const fmtPct = (v: number) => `${(v * 100).toFixed(1).replace(".", ",")}%`; + +/** Só dígitos → número. Campo de dinheiro que aceita o que a pessoa digitar. */ +function parseMoeda(txt: string): number | null { + const digitos = txt.replace(/\D/g, ""); + if (!digitos) return null; + return Number(digitos); +} + +interface Props { + projectId: string; + snapshotId: string; + meta: Meta12m; + /** `aprovado` = já travada. */ + status: string; +} + +export function TravarMetaCard({ projectId, snapshotId, meta, status }: Props) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const [editando, setEditando] = useState(false); + const [texto, setTexto] = useState(String(Math.round(meta.receita.objetivo))); + const [erro, setErro] = useState(null); + + const receitaEscolhida = parseMoeda(texto); + + // Mesma função pura do server: o preview não pode divergir do que grava. + const previsto = useMemo( + () => ajustarReceitaDaMeta(meta, receitaEscolhida ?? meta.receita.objetivo), + [meta, receitaEscolhida], + ); + + const mudou = + receitaEscolhida !== null && receitaEscolhida !== Math.round(meta.receita.objetivo); + + if (status === "aprovado") { + return ( +
+ +
+

Meta travada

+

+ {fmtBRL(meta.receita.objetivo)} em 12 meses. O Cockpit passa a medir o seu + ritmo contra ela, e as Iniciativas saem daqui. +

+
+
+ ); + } + + const travar = () => { + setErro(null); + startTransition(async () => { + const r = await travarMetaAction( + projectId, + snapshotId, + mudou ? receitaEscolhida : null, + ); + if (!r.ok) { + setErro(r.error ?? "Não foi possível travar a meta."); + return; + } + router.refresh(); + }); + }; + + return ( +
+

Esta meta ainda não está travada

+

+ O número abaixo o sistema calculou da sua própria história. Ele é uma proposta — a + meta é sua. Ajuste se quiser e trave: é o que liga o Cockpit e as Iniciativas. +

+ + {/* ── A receita: o campo ─────────────────────────────────────── */} +
+
+ + Faturamento em 12 meses + + {editando ? ( + setTexto(e.target.value)} + onBlur={() => setEditando(false)} + aria-label="Faturamento alvo para os próximos 12 meses" + className="w-52 px-3 py-1.5 rounded-md bg-surface border border-gold-primary/60 text-text-main font-display text-xl font-bold outline-none" + /> + ) : ( + + )} + + {fmtPct(previsto.crescimentoImplicito)} sobre os{" "} + {fmtBRL(meta.receita.baseline)} dos 12 meses anteriores + +
+ + {/* ── O que acompanha ────────────────────────────────────── */} +
+ + Resultado em 12 meses + + + {fmtBRL(previsto.meta.resultado.objetivo)} + + + {previsto.resultadoAncorado + ? meta.fase === "recuperacao" + ? "não acompanha: em recuperação o alvo é sair do vermelho" + : "não acompanha: fora do ICP o modelo não força meta" + : "acompanha a receita que você definir"} + +
+
+ + {mudou && ( +

+ O sistema propunha {fmtBRL(previsto.receitaObjetivoOriginal)}.{" "} + +

+ )} + + {/* Margem e endividamento são metas de NÍVEL (modelo-meta-pacing §4): + vêm do melhor histórico da empresa e não derivam da receita. Dizer + isso é melhor do que deixar a dona achar que mexeu nelas. */} +

+ Margem e endividamento não mudam com este campo — são alvos de patamar, medidos + contra o melhor momento da própria empresa. +

+ +
+ + {receitaEscolhida === null && ( + + Informe o faturamento alvo para travar. + + )} + {erro && ( + + {erro.length > 80 ? erro.slice(0, 80) + "…" : erro} + + )} +
+
+ ); +} diff --git a/src/app/projetos/[id]/plano-voo/actions.ts b/src/app/projetos/[id]/plano-voo/actions.ts index eb07afe8..a95d6a03 100644 --- a/src/app/projetos/[id]/plano-voo/actions.ts +++ b/src/app/projetos/[id]/plano-voo/actions.ts @@ -2,6 +2,7 @@ import { createClient } from "@/lib/supabase/server"; import { canOperateProject } from "@/lib/auth/project-access"; +import { planoVooDestravado } from "@/lib/plano-voo-gate"; import { resolveRole } from "@/lib/auth/landing"; import { PRIVILEGED_ROLES } from "@/lib/constants/user-roles"; import { generatePlanoVooV2 } from "@/lib/plano-voo-v2-service"; @@ -12,6 +13,7 @@ import { type PlanoVooDocumentV2, type Meta12m, } from "@/types/plano-voo-v2"; +import { ajustarReceitaDaMeta } from "@/lib/plano-voo/ajuste-meta"; import { revalidatePath } from "next/cache"; export interface GenerateActionResult { @@ -26,7 +28,17 @@ export async function generatePlanoVooV2Action(projectId: string): Promise 0) { - // Remove antigas do mesmo snapshot (em caso de re-aprovação após edição) - await supabase.from("cockpit_actions").delete().eq("snapshot_id", snapshotId); + // Sem limpeza aqui, de propósito. O `.delete().eq("snapshot_id", snapshotId)` + // que existia mirava o snapshot que estamos aprovando AGORA — que nunca + // tem linha (re-aprovar o mesmo snapshot é barrado lá em cima), e ainda + // era inerte porque `cockpit_actions` não tem policy de DELETE. As ações + // do plano anterior FICAM na tabela: o Cockpit passou a ler pelo snapshot + // vigente (`plano-voo/snapshot-vigente.ts`), então elas somem da tela sem + // que ninguém perca o progresso já marcado nelas. const rows = weeklyActions.map(a => ({ project_id: projectId, @@ -596,3 +623,130 @@ function applyEdits(doc: PlanoVooDocumentV2, edits: EditableNarrativas): PlanoVo return out; } + +/** + * TRAVAR A META — o ato do dono (E6-A + D11, Lucas 2026-08-20). + * + * A jornada: *"o cliente vai analisar e definir a meta com base no que o sistema + * indica, mas pode mexer nessa meta caso queira; e vai travar a meta, que vai + * popular as iniciativas e o cockpit."* Este é esse ato, num só round-trip: + * ajustar (se ela quis) e travar são UMA decisão, não duas. + * + * POR QUE NÃO É SÓ O `approvePlanoVooV2Action`: aquele aprova o que está lá. + * Aqui, quando ela move a receita, o número dela precisa virar uma VERSÃO NOVA + * antes de ser travado — assim o que o motor propôs continua registrado ao lado + * do que ela escolheu. Meta é o que trava Cockpit e Iniciativas: sobrescrever a + * proposta original apagaria a origem do número. + * + * O clone é da LINHA INTEIRA (menos as colunas de identidade/aprovação), não + * campo a campo: os documentos v2 e v3 têm formas diferentes, e montar o insert + * a partir da forma do documento é como se erra numa das duas. + * + * Gate: `canOperateProject` (vínculo, não papel — a dona opera o próprio + * projeto) + `planoVooDestravado` (qualidade do dado). Nenhum gate de papel. + */ +export async function travarMetaAction( + projectId: string, + snapshotId: string, + receitaObjetivo?: number | null, +): Promise { + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return { ok: false, error: "Não autenticado" }; + + if (!(await canOperateProject(supabase, user.id, projectId))) { + return { ok: false, error: "Você não tem acesso a este projeto." }; + } + + const readiness = await planoVooDestravado(projectId); + if (!readiness.pronto) { + return { ok: false, error: readiness.motivo ?? "Dado financeiro insuficiente para a meta de 12 meses." }; + } + + const { data: source } = await supabase + .from("plano_voo_snapshots") + .select("*") + .eq("id", snapshotId) + .eq("project_id", projectId) + .maybeSingle(); + if (!source) return { ok: false, error: "Snapshot não encontrado" }; + if (source.status === "aprovado") return { ok: false, error: "Esta meta já está travada" }; + if (source.document_version !== "v3") { + return { ok: false, error: "Esta versão do Plano não tem meta de 12 meses. Gere uma nova versão." }; + } + + const parsed = planoVooDocumentV3Schema.safeParse(source.html_payload); + if (!parsed.success) return { ok: false, error: "Payload do Plano inválido" }; + + const metaAtual = parsed.data.meta_12m; + const querAjustar = + typeof receitaObjetivo === "number" && + Number.isFinite(receitaObjetivo) && + receitaObjetivo >= 0 && + metaAtual != null && + receitaObjetivo !== metaAtual.receita.objetivo; + + // ── Caminho 1: ela aceitou a meta do motor ────────────────────────────── + if (!querAjustar) { + return approvePlanoVooV2Action(projectId, snapshotId); + } + + // ── Caminho 2: ela moveu a receita ⇒ nova versão com o número dela ────── + const { meta: metaAjustada } = ajustarReceitaDaMeta(metaAtual!, receitaObjetivo!); + const documento = { ...parsed.data, meta_12m: metaAjustada }; + + const validado = planoVooDocumentV3Schema.safeParse(documento); + if (!validado.success) { + return { + ok: false, + error: `A meta ajustada não passou na validação: ${validado.error.issues.slice(0, 3).map(i => i.path.join(".")).join(", ")}`, + }; + } + + const { data: ultima } = await supabase + .from("plano_voo_snapshots") + .select("version") + .eq("project_id", projectId) + .order("version", { ascending: false }) + .limit(1) + .maybeSingle(); + const proximaVersao = ((ultima?.version as number | undefined) ?? 0) + 1; + + // Clona a linha: tudo que não é identidade, carimbo de geração nem estado de + // aprovação viaja igual. `generated_by: "manual"` marca a mão humana. + // Remoção explícita (e não destructuring com `_`): o eslint do projeto não + // configura `varsIgnorePattern`, então variável descartada vira warning. + const clone: Record = { ...(source as Record) }; + for (const coluna of ["id", "generated_at", "approved_at", "approved_by"]) { + delete clone[coluna]; + } + + const { data: novo, error: insErr } = await supabase + .from("plano_voo_snapshots") + .insert({ + ...clone, + version: proximaVersao, + status: "aprovado", + generated_by: "manual", + html_payload: validado.data, + approved_at: new Date().toISOString(), + approved_by: user.id, + }) + .select("id") + .single(); + + if (insErr || !novo) { + return { ok: false, error: insErr?.message || "Falha ao gravar a meta ajustada" }; + } + + // Arquiva a meta travada anterior (histórico de metas — modelo §9). + await supabase + .from("plano_voo_snapshots") + .update({ status: "arquivado" }) + .eq("project_id", projectId) + .eq("status", "aprovado") + .neq("id", novo.id as string); + + revalidatePath(`/projetos/${projectId}/plano-voo`); + return { ok: true, snapshot_id: novo.id as string }; +} diff --git a/src/app/projetos/[id]/plano-voo/page.tsx b/src/app/projetos/[id]/plano-voo/page.tsx index 931d1764..bf6dda08 100644 --- a/src/app/projetos/[id]/plano-voo/page.tsx +++ b/src/app/projetos/[id]/plano-voo/page.tsx @@ -41,6 +41,7 @@ import type { PlanoVooDocumentV2, Meta12m, PerfilEndividamento } from "@/types/p import { getLatestPlanoVooV2, type LatestSnapshotMeta } from "./actions"; import { GenerateButton } from "./GenerateButton"; import { DeletePlanoButton } from "./DeletePlanoButton"; +import { TravarMetaCard } from "./TravarMetaCard"; import { createClient } from "@/lib/supabase/server"; import { resolveRole } from "@/lib/auth/landing"; import { PRIVILEGED_ROLES } from "@/lib/constants/user-roles"; @@ -443,6 +444,23 @@ function OverviewComDocumento({ + {/* ── O ATO DA DONA (E6-A + D11) ────────────────────────────── + Vem ANTES do bloco da meta: a decisão é o que ela veio fazer; o + detalhe (curva, racional, saúde) sustenta a decisão, não o + contrário. Sem gate de papel — `travarMetaAction` já checa + vínculo (`canOperateProject`) e qualidade (`planoVooDestravado`) + no caminho de escrita. */} + {meta && ( + + + + )} + {/* A META — o coração */} {meta ? ( diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 13909559..1e86c7f3 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -142,7 +142,8 @@ export function Sidebar({ currentPath, collapsed = false }: SidebarProps) { // PE3.7 — Badges discretos (iguais pra todos): // - Cockpit (Financeiro): pulse dot enquanto não há OFX importado // - Plano de Voo: 🔒 enquanto o dado financeiro não amadurece - // (gate D-3: ≥3 meses fechados + folha; NÃO mais "30 análises") + // (gate: os 12 meses da JANELA fechados. Folha NÃO é gate — a D-3 + // foi revogada em 2026-06-05; e não é mais o contador "30 análises") // - Iniciativas (HTR): N quando há hipóteses status='proposta' const financeiroBadge = sidebarBadges.hasFinancialDocs ? undefined diff --git a/src/components/central-dados/CategorizacaoCasaTab.tsx b/src/components/central-dados/CategorizacaoCasaTab.tsx index c2040296..8e0272b8 100644 --- a/src/components/central-dados/CategorizacaoCasaTab.tsx +++ b/src/components/central-dados/CategorizacaoCasaTab.tsx @@ -13,11 +13,14 @@ * "Gerar DRE" leva ao resultado da empresa (até o Painel nativo entrar na Fase 3). */ +import { useState } from "react"; import { useRouter } from "next/navigation"; import { Skeleton, SkeletonGroup } from "@/components/ui"; import { useDelayedLoading } from "@/hooks/useDelayedLoading"; import { useDreController } from "@/features/dre/hooks/useDreController"; import { CategorizationTab } from "@/features/dre/components/forense/CategorizationTab"; +import { RegraPorTokenModal } from "@/features/dre/components/forense/RegraPorTokenModal"; +import type { Direction } from "@/features/dre/types"; export function CategorizacaoCasaTab({ projectId, @@ -30,6 +33,8 @@ export function CategorizacaoCasaTab({ }) { const router = useRouter(); const c = useDreController(projectId, "categorizacao"); + /** Regra por token: qual descrição abriu o modal, e em que sentido. */ + const [regraToken, setRegraToken] = useState<{ descricao: string; direction: Direction } | null>(null); // Um gate só, do primeiro frame até a tabela existir — inclui o vão entre // "exercícios chegaram" e "o effect escolheu o ano/mês" (ver o controller). const carregando = c.isCarregandoCategorizacao; @@ -146,6 +151,7 @@ export function CategorizacaoCasaTab({ regrasCategorizacao={c.regrasCategorizacao} monthlyStatus={c.monthlyStatus} snapshotsPorMes={c.snapshotsPorMes} + janela12m={c.janela12m} fecharMes={c.fecharMes} isFechandoMes={c.isFechandoMes} aplicarRegras={c.aplicarRegras} @@ -153,7 +159,20 @@ export function CategorizacaoCasaTab({ propagacoesRelatorio={c.propagacoesRelatorio} onLinhaChange={handleLinhaChange} onLinhaReverter={handleLinhaReverter} + onAbrirRegraToken={(descricao, direction) => setRegraToken({ descricao, direction })} /> + + {regraToken && ( + setRegraToken(null)} + descricaoBase={regraToken.descricao} + direction={regraToken.direction} + preview={c.previewContains} + aplicar={c.aplicarContains} + aplicando={c.isAplicandoContains} + /> + )} ); } diff --git a/src/components/central-dados/ResultadoDreView.tsx b/src/components/central-dados/ResultadoDreView.tsx index d3b07383..8869b002 100644 --- a/src/components/central-dados/ResultadoDreView.tsx +++ b/src/components/central-dados/ResultadoDreView.tsx @@ -68,9 +68,6 @@ export function ResultadoDreView({ onPeriodChange={c.setSelectedPeriod} availableYears={anosExercicios} closedYears={c.closedYears} - dreReadiness={c.dreReadiness} - onPublishDRE={c.publishDRE} - isPublishing={c.isPublishing} mode={mode} onModeChange={(m) => c.setActiveTab(m)} comparativo={c.comparativo || null} diff --git a/src/components/conta/ConfigTab.tsx b/src/components/conta/ConfigTab.tsx index 5546fce1..fa523735 100644 --- a/src/components/conta/ConfigTab.tsx +++ b/src/components/conta/ConfigTab.tsx @@ -2,7 +2,7 @@ /** * Configurações — aba da Minha Conta (F4 Entrega 3), o "avançado/conta". - * 4 blocos: conta & plano (read-only), suporte/consultor, trocar e-mail de login, + * 4 blocos: conta & plano (read-only), suporte, trocar e-mail de login, * sair de todos os dispositivos. Leitura via getConfigDataAction (gateada); e-mail * e signout-global são client-side (supabase do browser + sessão do usuário). */ @@ -134,11 +134,11 @@ export function ConfigTab({ projectId }: { projectId: string }) { {/* 2. SUPORTE */} - + {data?.consultor && (data.consultor.nome || data.consultor.email) ? (
-

{data.consultor.nome || "Consultor ATR"}

+

{data.consultor.nome || "Suporte ATR"}

{[data.consultor.email, data.consultor.telefone].filter(Boolean).join(" · ") || "—"}

@@ -151,7 +151,7 @@ export function ConfigTab({ projectId }: { projectId: string }) {
) : ( -

Sem consultor definido. Fale com a ATR.

+

Sem contato definido ainda. Fale com a ATR.

)}
diff --git a/src/components/pdf/PlanoVooPDF.tsx b/src/components/pdf/PlanoVooPDF.tsx index 9a8a1ffb..09f6e6d1 100644 --- a/src/components/pdf/PlanoVooPDF.tsx +++ b/src/components/pdf/PlanoVooPDF.tsx @@ -373,7 +373,7 @@ function RelatorioPages({ data, meta, briefing }: { data: PlanoVooDocumentV2; me ))} - Consultor responsável + Gerado por {data.conclusao.contato_consultor.nome} {data.conclusao.contato_consultor.email} {m.document_id} diff --git a/src/components/plano-voo/atos/AtoIAbertura.tsx b/src/components/plano-voo/atos/AtoIAbertura.tsx index f9dbce83..2a3b6db9 100644 --- a/src/components/plano-voo/atos/AtoIAbertura.tsx +++ b/src/components/plano-voo/atos/AtoIAbertura.tsx @@ -73,7 +73,7 @@ export function AtoIAbertura({ data }: { data: PlanoVooDocumentV2 }) { secondary={`até ${fmtDate(m.ciclo_fim)}`} /> diff --git a/src/components/plano-voo/atos/AtoVCaminho.tsx b/src/components/plano-voo/atos/AtoVCaminho.tsx index fbe01c6a..65b1111b 100644 --- a/src/components/plano-voo/atos/AtoVCaminho.tsx +++ b/src/components/plano-voo/atos/AtoVCaminho.tsx @@ -118,7 +118,7 @@ function Pagina16Meta({ meta }: { meta: Meta12m }) { const PACING_ZONAS = [ { cor: "var(--success-green)", print: "print:bg-green-500", titulo: "Na rota", txt: "Acumulado ≥ o esperado-sazonal. Segue o jogo." }, { cor: "var(--alert-amber)", print: "print:bg-amber-500", titulo: "Atrasado, recuperável", txt: "Atrás do esperado, mas o ritmo pra virar cabe no que você já fez antes." }, - { cor: "var(--alert-red)", print: "print:bg-red-500", titulo: "Atraso crítico", txt: "Recuperar exige mais do que você já provou — recalibrar com o consultor." }, + { cor: "var(--alert-red)", print: "print:bg-red-500", titulo: "Atraso crítico", txt: "Recuperar exige mais do que você já provou — a virada tem que vir de alavanca nova, não de mais esforço." }, ]; function PacingZonas() { @@ -164,7 +164,8 @@ function Pagina17Curva({ data, meta }: { data: PlanoVooDocumentV2; meta: Meta12m A meta é distribuída ao longo de 12 meses respeitando a sazonalidade de cada métrica — não uma média plana. O Plano de Voo escreve e trava a meta; o caminho - do dia a dia para alcançá-la é conduzido junto ao seu consultor. + do dia a dia acontece no Cockpit (a cada mês fechado, se você está na rota) e nas + Iniciativas (o que fazer agora).

Horizonte: {meta.meses[0]} → {meta.meses[meta.meses.length - 1]} @@ -476,7 +477,7 @@ function Pagina18Conclusao({ data, briefing }: { data: PlanoVooDocumentV2; brief

- Consultor responsável + Gerado por
{c.contato_consultor.nome} diff --git a/src/components/plano-voo/relatorio/PlanoVooRelatorio.tsx b/src/components/plano-voo/relatorio/PlanoVooRelatorio.tsx index 4f189199..f446c073 100644 --- a/src/components/plano-voo/relatorio/PlanoVooRelatorio.tsx +++ b/src/components/plano-voo/relatorio/PlanoVooRelatorio.tsx @@ -506,7 +506,7 @@ function Sec05Meta({ meta, data }: { meta: Meta12m; data: PlanoVooDocumentV2 })
A meta · 12 meses · {mesLabel(meta.meses[0])} → {mesLabel(meta.meses[11])} Para onde vamos - O número que vamos perseguir — calculado pelo motor a partir da sua própria história, calibrado pelo consultor. + O número que vamos perseguir — calculado pelo motor a partir da sua própria história, não de um benchmark de fora.
{metricas.map((m) => ( @@ -571,7 +571,7 @@ function Sec06Pacing() { const zonas = [ { cor: "var(--success-green)", t: "Na rota", d: "Acumulado ≥ o esperado-sazonal até aqui. Segue o jogo." }, { cor: "var(--alert-amber)", t: "Atrasado, recuperável", d: "Atrás do esperado, mas o ritmo pra virar cabe no que você já fez antes." }, - { cor: "var(--alert-red)", t: "Atraso crítico", d: "Recuperar exige mais do que você já provou — recalibrar com o consultor." }, + { cor: "var(--alert-red)", t: "Atraso crítico", d: "Recuperar exige mais do que você já provou — a virada tem que vir de alavanca nova, não de mais esforço." }, ]; return (
@@ -629,7 +629,7 @@ function Sec07Fecho({ data, briefing }: { data: PlanoVooDocumentV2; briefing?: s
-

Consultor responsável

+

Gerado por

{c.contato_consultor.nome}
{c.contato_consultor.email} diff --git a/src/features/dre/components/ProjectStatusBadge.tsx b/src/features/dre/components/ProjectStatusBadge.tsx deleted file mode 100644 index f6a37e59..00000000 --- a/src/features/dre/components/ProjectStatusBadge.tsx +++ /dev/null @@ -1,154 +0,0 @@ -"use client"; - -import { Activity, AlertCircle, CheckCircle2, Clock, Sparkles } from "lucide-react"; -import type { PublishStatus } from "@/lib/dre-publishing-service"; - -interface ProjectStatusBadgeProps { - /** Total de transações pendentes (sem categoria ou sem subcategoria) somado em todos os anos. */ - pendentesGlobais: number; - /** Total de transações no projeto. */ - totalGlobal: number; - /** Maior `periodo_fim` entre os documentos importados (ISO). null se nada foi importado. */ - ultimaAtividade: string | null; - /** Quantidade de anos com ao menos uma pendência (proxy de "meses afetados" no V1). */ - anosComPendencia: number; - /** Estado de publicação da DRE (rascunho / preliminar / consolidada). */ - publishStatus?: PublishStatus; - /** Quantos meses fechados — exibido junto do publishStatus. */ - snapshotsTotal?: number; -} - -/** - * Resumo do estado do projeto financeiro mostrado na PageHeader da DRE. - * - * Computa idade da última atividade (= maior periodo_fim) e classifica em - * verde/amarelo/vermelho. Pendências globais aparecem ao lado pra atalho mental - * "tenho trabalho parado em algum lugar". - */ -export function ProjectStatusBadge({ - pendentesGlobais, - totalGlobal, - ultimaAtividade, - anosComPendencia, - publishStatus, - snapshotsTotal = 0, -}: ProjectStatusBadgeProps) { - if (totalGlobal === 0) { - return null; - } - - const diasDesdeUltima = ultimaAtividade - ? Math.floor((Date.now() - new Date(ultimaAtividade).getTime()) / (1000 * 60 * 60 * 24)) - : null; - - // Classificação de idade — ver §4 do plano - let ageState: "fresh" | "warning" | "stale" = "fresh"; - if (diasDesdeUltima !== null) { - if (diasDesdeUltima > 65) ageState = "stale"; - else if (diasDesdeUltima > 35) ageState = "warning"; - } - - const ageColor = - ageState === "stale" - ? "text-status-error" - : ageState === "warning" - ? "text-status-warning" - : "text-status-success"; - - const ageBg = - ageState === "stale" - ? "bg-status-error/10 border-status-error/30" - : ageState === "warning" - ? "bg-status-warning/10 border-status-warning/30" - : "bg-status-success/10 border-status-success/30"; - - const formatAtividade = () => { - if (diasDesdeUltima === null) return "sem importações"; - if (diasDesdeUltima === 0) return "última atividade hoje"; - if (diasDesdeUltima === 1) return "última atividade ontem"; - if (diasDesdeUltima < 30) return `última atividade há ${diasDesdeUltima}d`; - const meses = Math.floor(diasDesdeUltima / 30); - return `última atividade há ${meses}m`; - }; - - const tudoCategorizado = pendentesGlobais === 0 && totalGlobal > 0; - - const publishChipClass: Record = { - rascunho: "bg-text-muted/10 border-text-muted/30 text-text-muted", - preliminar: "bg-status-warning/10 border-status-warning/30 text-status-warning", - consolidada: "bg-status-success/10 border-status-success/30 text-status-success", - }; - const publishLabel: Record = { - rascunho: "DRE rascunho", - preliminar: "DRE preliminar", - consolidada: "DRE consolidada", - }; - const publishTooltip: Record = { - rascunho: "DRE não publicada — feche pelo menos 3 meses para liberar análises e Plano de Voo", - preliminar: "DRE publicada como preliminar — Plano de Voo libera com aviso, IA gera parecer", - consolidada: "DRE publicada como consolidada — todas as análises e Plano de Voo full-strength", - }; - - return ( -
- {/* Idade da última atividade */} -
- - {formatAtividade()} - {ageState === "stale" && ( - - )} -
- - {/* Status de publicação da DRE */} - {publishStatus && ( -
- - {publishLabel[publishStatus]} - {snapshotsTotal > 0 && ( - · {snapshotsTotal}m - )} -
- )} - - {/* Pendências globais */} -
- {tudoCategorizado ? ( - <> - - tudo categorizado - - ) : ( - <> - - {pendentesGlobais} pendentes - {anosComPendencia > 1 && ( - · {anosComPendencia} anos - )} - - )} -
-
- ); -} diff --git a/src/features/dre/components/forense/CategorizationTab.tsx b/src/features/dre/components/forense/CategorizationTab.tsx index 89afe51a..4471e90a 100644 --- a/src/features/dre/components/forense/CategorizationTab.tsx +++ b/src/features/dre/components/forense/CategorizationTab.tsx @@ -21,6 +21,7 @@ import type { ExercicioFiscal, StatusMensal, VarreduraResult } from "@/lib/finan import type { MonthlySnapshot } from "@/lib/dre-snapshot-service"; import type { CategorizationRule, Direction } from "@/features/dre/types"; import { MonthChip } from "@/features/dre/components/forense/MonthChip"; +import { CobrancaJanela12m } from "@/features/dre/components/forense/CobrancaJanela12m"; import { ConsolidatedFinishModal } from "@/features/dre/components/forense/ConsolidatedFinishModal"; import { LinhasDoGrupo } from "@/features/dre/components/forense/LinhasDoGrupo"; import type { PropagacoesSessao } from "@/features/dre/components/forense/VarreduraReportBody"; @@ -229,6 +230,26 @@ interface CategorizationTabProps { subcategoriaNome?: string | null; }, ) => Promise; + /** + * Abre a regra por TOKEN — categorizar de uma vez tudo que contém uma + * palavra. Opcional: sem ela a tela funciona igual a antes. + * + * O atalho aparece nos grupos de UMA ocorrência sem categoria, que é onde a + * dor mora: medido em produção, eles são 75% das decisões e só 17% das + * linhas. + */ + onAbrirRegraToken?: (lancamentoTratado: string, direction: Direction) => void; + /** + * A janela de 12 meses que destrava o Plano de Voo (passo 5 da jornada). + * `null` = projeto sem data de entrada; a faixa não aparece. + */ + janela12m?: { + faltantes: string[]; + fechados: number; + completa: boolean; + primeiro: string; + ultimo: string; + } | null; } export function CategorizationTab({ @@ -256,6 +277,8 @@ export function CategorizationTab({ propagacoesRelatorio = null, onLinhaChange, onLinhaReverter, + onAbrirRegraToken, + janela12m = null, }: CategorizationTabProps) { const [paginaAtual, setPaginaAtual] = useState(1); const itensPorPagina = 100; @@ -484,6 +507,21 @@ export function CategorizationTab({
+ {/* ============================================================ + BLOCO 1.5: A cobrança dos 12 meses (passo 5 da jornada) — e, + quando a janela fecha, o sinal de que o Plano destravou (passo 9). + Vem ANTES dos chips de propósito: os chips são de um ano só, e a + janela cruza dois. + ============================================================ */} + { + if (ano !== anoSelecionado) onAnoChange(ano); + onMesChange(mes); + }} + /> + {/* ============================================================ BLOCO 2: Faixa horizontal de 12 chips ============================================================ */} @@ -703,6 +741,20 @@ export function CategorizationTab({ )}
+ {/* Atalho da regra por token — só onde a dor está: grupo de + UMA ocorrência ainda sem categoria. Num grupo grande a + decisão já se paga sozinha e o chip seria ruído. */} + {!l.categoria_id && l.ocorrencias === 1 && onAbrirRegraToken && ( + + )} {!l.categoria_id && sugestoesMap.has(l.lancamento_tratado) && (() => { const regra = sugestoesMap.get(l.lancamento_tratado)!; return ( diff --git a/src/features/dre/components/forense/CobrancaJanela12m.tsx b/src/features/dre/components/forense/CobrancaJanela12m.tsx new file mode 100644 index 00000000..f369e17b --- /dev/null +++ b/src/features/dre/components/forense/CobrancaJanela12m.tsx @@ -0,0 +1,119 @@ +"use client"; + +import Link from "next/link"; +import { CalendarClock, CheckCircle2, ArrowRight } from "lucide-react"; + +/** + * A cobrança dos 12 meses — passo 5 e passo 9 da jornada do cliente. + * + * POR QUE ELA EXISTE: antes, o sistema só sabia TRANCAR. O `consultorPronto` + * recebia a contagem (`snapshotsAtivos: number`) e devolvia "faltam 5 meses" + * num tooltip de cadeado, na sidebar — passivo, e num lugar onde a dona não + * tem motivo para ir. A lista dos meses existia em `janela.meses` e era + * descartada na chamada, então a tela era **estruturalmente incapaz** de dizer + * QUAIS meses faltam. + * + * Agora ela cobra pelo nome, e no lugar onde a dona já está trabalhando: a + * Apuração, logo acima dos chips do ano. + * + * O MESMO COMPONENTE FAZ OS DOIS LADOS. Ao chegar em 12/12 ele vira o sinal de + * destravamento ("seu Plano de Voo está liberado"), porque é a mesma pergunta + * antes e depois do limiar — e porque um sinal que aparece só quando falta algo + * deixa a dona sem saber que chegou lá. + * + * DETALHE DE DESENHO: os chips do ano são de UM ano; a janela cruza dois + * (entrada em ago/2026 ⇒ jul/2026 … ago/2025). Por isso cada mês aqui carrega o + * ano e navega sozinho — sem isso, metade da cobrança apontaria para meses que + * não estão na tela. + */ + +const MES_CURTO = [ + "jan", "fev", "mar", "abr", "mai", "jun", + "jul", "ago", "set", "out", "nov", "dez", +]; + +/** `2026-03` → `mar/26`. Slice de string, nunca `new Date` (fuso vira o mês). */ +function rotuloMes(mesRef: string): string { + const [ano, mes] = mesRef.split("-"); + return `${MES_CURTO[Number(mes) - 1]}/${ano.slice(-2)}`; +} + +interface Props { + /** Estado da janela. `null` = sem data de entrada; a faixa não aparece. */ + janela: { + faltantes: string[]; + fechados: number; + completa: boolean; + primeiro: string; + ultimo: string; + } | null; + projectId: string; + /** Leva a dona até o mês — troca ano e mês de uma vez. */ + onIrParaMes: (ano: number, mes: number) => void; +} + +export function CobrancaJanela12m({ janela, projectId, onIrParaMes }: Props) { + if (!janela) return null; + + if (janela.completa) { + return ( +
+
+ +
+

+ Os 12 meses estão fechados +

+

+ De {rotuloMes(janela.primeiro)} a {rotuloMes(janela.ultimo)}. Seu Plano de + Voo está liberado — dá para definir a meta dos próximos 12 meses. +

+
+
+ + Ir ao Plano de Voo + + +
+ ); + } + + const n = janela.faltantes.length; + + return ( +
+
+ +
+

+ {janela.fechados} de 12 meses fechados —{" "} + {n === 1 ? "falta 1 para" : `faltam ${n} para`} destravar seu Plano de Voo +

+

+ A meta é calculada sobre os 12 meses de {rotuloMes(janela.primeiro)} a{" "} + {rotuloMes(janela.ultimo)}. Clique num mês para ir apurá-lo. +

+ +
+ {janela.faltantes.map((mesRef) => { + const [ano, mes] = mesRef.split("-").map(Number); + return ( + + ); + })} +
+
+
+
+ ); +} diff --git a/src/features/dre/components/forense/GateFormBody.tsx b/src/features/dre/components/forense/GateFormBody.tsx index b64a21fa..711c2d98 100644 --- a/src/features/dre/components/forense/GateFormBody.tsx +++ b/src/features/dre/components/forense/GateFormBody.tsx @@ -15,7 +15,7 @@ import type { LancamentoPendencia, SocioNatureza, DecomposicaoEmprestimo, } from "@/lib/dre-detalhamento/types"; -import type { GateFormState, AntecipacaoLinhaEntrada, DecisaoCredito } from "@/features/dre/hooks/useGateForm"; +import type { GateFormState, AntecipacaoLinhaEntrada, DecisaoCredito, OrigemFaceAntecipacao, TaxaAntecipacaoForm } from "@/features/dre/hooks/useGateForm"; import { RECON_INADIMPLENTE, RECON_DESCARTAR } from "@/features/dre/hooks/useGateForm"; import type { LinhaTriagem, ParcelaComCandidatos } from "@/lib/dre-detalhamento/triagem-creditos"; import type { LoanContract } from "@/lib/loan-contracts-service"; @@ -86,6 +86,8 @@ export function GateFormBody({ form, projectId, hideSuccessState = false }: Prop orfas, decisoesOrfa, setDecisaoOrfa, qtdConciliacoesAuto, conciliacoesRevisadas, marcarConciliacoesRevisadas, faceExtratoAntecipacao, movimentacoes: movs, + origemFace, setOrigemFace, taxaForm, setTaxaForm, + previewTaxaAntecipacao, taxaPeriodoAntecipacao, } = form; // Carrega contratos ativos quando o card de empréstimo existe — usado @@ -328,7 +330,10 @@ export function GateFormBody({ form, projectId, hideSuccessState = false }: Prop extraAcao={ // Com ≤1 operação, "média geral vs detalhar por linha" não // decide nada — esconde o toggle e mostra só o formulário único. - (cardAnt.lancamentos?.length || 0) >= 2 ? ( + // E3-B: no modo taxa cada linha deriva a própria face pelo + // próprio crédito (invariante 2) — agregado × detalhado não + // muda um centavo, então o toggle sai da tela. + origemFace !== "taxa" && (cardAnt.lancamentos?.length || 0) >= 2 ? ( - {modoAntecipacao === "agregado" ? ( + + + {origemFace === "taxa" ? ( + a + (l.credito || 0), 0)} + qtdOperacoes={cardAnt.lancamentos?.length || 0} + /> + ) : modoAntecipacao === "agregado" ? ( <>
)} - {modoAntecipacao === "detalhado" && previewAntecipacao && (cardAnt.lancamentos?.length || 0) > 0 && ( + {origemFace !== "taxa" && modoAntecipacao === "detalhado" && previewAntecipacao && (cardAnt.lancamentos?.length || 0) > 0 && ( )} @@ -2249,3 +2265,118 @@ function BarraStatusAntecipacao({ preview }: { preview: ResolucaoAntecipacao })
); } + +/** + * E3-B — o switch da antecipação. + * + * O extrato credita o LÍQUIDO; a face está no borderô, que a maior parte do ICP + * não tem em mãos na hora de fechar o mês. Antes disto, esse campo trancava a + * apuração inteira. Agora o dono escolhe por onde consegue responder. + */ +function SwitchOrigemFace({ + origem, onChange, +}: { origem: OrigemFaceAntecipacao; onChange: (v: OrigemFaceAntecipacao) => void }) { + const opcoes: [OrigemFaceAntecipacao, string, string][] = [ + ["bordero", "Tenho o borderô", "Sei o valor de face de cada operação"], + ["taxa", "Sei a taxa que pago", "O sistema calcula a face a partir do que entrou"], + ]; + return ( +
+ {opcoes.map(([valor, titulo, ajuda]) => ( + + ))} +
+ ); +} + +/** + * E3-B — o formulário do modo "sei a taxa que pago". + * + * Pede **% ao mês**, que é como o banco cota e como o dono lembra. O prazo faz a + * conversão para o % do período, e a tela mostra os dois — sem isso o número + * derivado seria mágica. + * + * Tudo aqui é ESTIMATIVA (invariante 3 do E3-B) e a tela é obrigada a dizer. + */ +function TaxaDeclaradaView({ + taxaForm, setTaxaForm, preview, taxaPeriodo, liquidoMes, qtdOperacoes, +}: { + taxaForm: TaxaAntecipacaoForm; + setTaxaForm: React.Dispatch>; + preview: ResolucaoAntecipacao | null; + taxaPeriodo: number | null; + liquidoMes: number; + qtdOperacoes: number; +}) { + const invalida = preview !== null && preview.indefinidas > 0; + return ( + <> +
+ setTaxaForm(f => ({ ...f, taxa_ao_mes: v }))} + /> + setTaxaForm(f => ({ ...f, prazo_medio_dias: v }))} + /> + setTaxaForm(f => ({ ...f, tipo_antecipacao: v }))} + options={[["desconto_duplicata", "Desconto de duplicata"], ["antecipacao_cartao", "Antecipação de cartão"], ["outro", "Outro"]]} + /> +
+ +
+

+ Entrou na conta{qtdOperacoes > 1 ? ` em ${qtdOperacoes} operações` : ""} + {" "}{formatBRL(liquidoMes)} + {taxaPeriodo !== null && ( + <> — a {taxaForm.taxa_ao_mes}% ao mês por {taxaForm.prazo_medio_dias} dias, o banco cobrou{" "} + {taxaPeriodo}% do período. + )} +

+ + {preview && !invalida && ( +
+ + + +
+ )} + + {invalida && ( +

+ Taxa e prazo não fecham — a esse ritmo o custo comeria o título inteiro. + Confira o % ao mês (não o do período) e o prazo em dias. +

+ )} + + {preview && !invalida && ( +

+ A face é estimada a partir da taxa que você informou — o + líquido é o que o extrato mostra, ao centavo. Com o borderô em mãos, troque + para “Tenho o borderô” e o número vira medido. +

+ )} +
+ + ); +} diff --git a/src/features/dre/components/forense/PublishDREModal.tsx b/src/features/dre/components/forense/PublishDREModal.tsx deleted file mode 100644 index 03b49bda..00000000 --- a/src/features/dre/components/forense/PublishDREModal.tsx +++ /dev/null @@ -1,173 +0,0 @@ -"use client"; - -import { X, Loader2, CheckCircle2, Sparkles, FileBarChart2, Brain, Plane } from "lucide-react"; -import { Button } from "@/components/ui"; -import { - publishStatusLabel, - type DreReadiness, - type ThresholdTarget, -} from "@/lib/dre-publishing-service"; - -interface Props { - isOpen: boolean; - onClose: () => void; - readiness: DreReadiness; - target: ThresholdTarget; - isPublishing: boolean; - onConfirm: (target: ThresholdTarget) => Promise; -} - -/** - * Cada nível libera um conjunto de capacidades. UI reflete ADR-010 + - * decisões do plano (ver pelos-meus-testes-ta-imperative-feather.md). - */ -const UNLOCKS: Record> = { - preliminar: [ - { - icon: Brain, - label: "Consultor IA gera parecer", - detail: "IA passa a citar números reais (com disclaimer de período curto).", - }, - { - icon: Plane, - label: "Plano de Voo desbloqueado", - detail: "Geração liberada com aviso 'baseado em N meses'.", - }, - { - icon: FileBarChart2, - label: "4 análises derivadas auto-concluídas", - detail: "Auditoria de Custos, Ponto de Equilíbrio, Remuneração de Sócios, CAC vs LTV.", - }, - ], - consolidada: [ - { - icon: Plane, - label: "Plano de Voo full-strength", - detail: "Sem disclaimer. Recomendações com peso máximo.", - }, - { - icon: FileBarChart2, - label: "Análises Grupo B liberadas", - detail: "Endividamento, Projeção de Caixa, Orçamento — exigem 12+ meses.", - }, - { - icon: CheckCircle2, - label: "Comparativo entre anos disponível", - detail: "Anos com 12/12 fechados podem ser comparados lado a lado.", - }, - ], -}; - -export function PublishDREModal({ isOpen, onClose, readiness, target, isPublishing, onConfirm }: Props) { - if (!isOpen) return null; - - const unlocks = UNLOCKS[target]; - - const handleConfirm = async () => { - try { - await onConfirm(target); - onClose(); - } catch { - // erro tratado no controller via setErrorMessage - } - }; - - return ( -
-
- {/* Header */} -
-
-
- -
-
-

- Publicar DRE como {publishStatusLabel(target)} -

-

- {readiness.snapshots_total} {readiness.snapshots_total === 1 ? "mês fechado" : "meses fechados"} - {readiness.earliest_closed && readiness.latest_closed && ( - <> · período {readiness.earliest_closed} → {readiness.latest_closed} - )} -

-
-
- -
- - {/* Body */} -
-

- Publicar a DRE sinaliza ao restante do sistema que os dados financeiros estão prontos para consumo. - Esta ação é reversível somente subindo de nível (preliminar → consolidada). -

- - {/* O que vai desbloquear */} -
-

- O que será liberado -

-
- {unlocks.map((u, idx) => { - const Icon = u.icon; - return ( -
-
- -
-
-

{u.label}

-

{u.detail}

-
-
- ); - })} -
-
- - {/* Aviso quando preliminar */} - {target === "preliminar" && ( -
- Recomenda-se republicar como consolidada assim que atingir 12 meses fechados (atual: {readiness.snapshots_total}). Análises e Plano de Voo ganham peso máximo nesse nível. -
- )} - - {target === "consolidada" && ( -
- Sua DRE atinge o nível mais alto — todas as análises do módulo financeiro e o Plano de Voo passam a operar com peso máximo. -
- )} -
- - {/* Footer */} -
- - -
-
-
- ); -} diff --git a/src/features/dre/components/forense/RegraPorTokenModal.tsx b/src/features/dre/components/forense/RegraPorTokenModal.tsx new file mode 100644 index 00000000..9d9bebc6 --- /dev/null +++ b/src/features/dre/components/forense/RegraPorTokenModal.tsx @@ -0,0 +1,270 @@ +"use client"; + +/** + * Regra por TOKEN CONTIDO — uma decisão para muitas descrições. + * + * ── O problema que esta tela resolve ──────────────────────────────────────── + * Medido em produção (2026-08-17): PRUDENTE & SILVA tem 1.358 grupos de UMA + * ocorrência — 75% de todas as decisões de categorização — e 1.157 deles (85%) + * terminam com o mesmo token: `MAQUININHA`. São 1.157 clientes diferentes + * pagando pelo MESMO canal. Todas são a mesma coisa, e eram 1.157 cliques. + * + * ── Por que o preview é obrigatório ───────────────────────────────────────── + * Regra ampla sem preview é o jeito mais rápido de estragar uma DRE. O dono vê + * ANTES: quantas linhas, quanto dinheiro, que sentido, e **quantas ficam de + * fora por terem contraparte detectada**. Mesmo espírito do + * `varredura/tempo-economizado.ts`, que exibe a fórmula e não só o resultado — + * conferir é o que constrói confiança na tela. + * + * A linha "protegidas" não é detalhe técnico: é a prova visível de que aporte de + * sócio não vai virar receita. + */ + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Filter, ShieldCheck, TriangleAlert } from "lucide-react"; +import { Modal, Button } from "@/components/ui"; +import { CATEGORIAS, opcoesDoDropdown } from "@/lib/categorization-rules"; +import type { Direction } from "@/features/dre/types"; +import type { PreviaRegraContains } from "@/lib/financial-document-service"; + +interface Props { + isOpen: boolean; + onClose: () => void; + /** Descrição do grupo que originou a ação — base para sugerir o token. */ + descricaoBase: string; + direction: Direction; + preview: (token: string, direction: Direction) => Promise; + aplicar: (dados: { + token: string; + categoriaId: string; + categoriaNome: string; + subcategoriaId?: string | null; + subcategoriaNome?: string | null; + direction: Direction; + totalValor: number; + }) => Promise; + aplicando: boolean; +} + +const brl = (n: number) => + n.toLocaleString("pt-BR", { style: "currency", currency: "BRL", maximumFractionDigits: 0 }); + +/** + * Palavras que não discriminam nada — sugerir uma delas como token seria + * oferecer ao dono uma regra que pega o extrato inteiro. + */ +const RUIDO = new Set(["DE", "DA", "DO", "DOS", "DAS", "E", "PIX", "TED", "DOC"]); + +/** Candidatos a token: palavras ≥4 letras, sem ruído, da mais ao fim para o começo. */ +function sugerirTokens(descricao: string): string[] { + const palavras = (descricao ?? "").split(/\s+/).filter(Boolean); + return palavras + .filter((p) => p.length >= 4 && !RUIDO.has(p)) + .reverse() + .slice(0, 6); +} + +export function RegraPorTokenModal({ + isOpen, + onClose, + descricaoBase, + direction, + preview, + aplicar, + aplicando, +}: Props) { + const sugestoes = useMemo(() => sugerirTokens(descricaoBase), [descricaoBase]); + const [token, setToken] = useState(""); + const [previa, setPrevia] = useState(null); + const [carregando, setCarregando] = useState(false); + const [categoriaId, setCategoriaId] = useState(""); + const [subcategoriaId, setSubcategoriaId] = useState(""); + + // Abrir com o token mais provável já escolhido — o do FIM da descrição, que + // é onde o canal costuma estar ("… PIX MAQUININHA"). + useEffect(() => { + if (!isOpen) return; + setToken(sugestoes[0] ?? ""); + setPrevia(null); + setCategoriaId(""); + setSubcategoriaId(""); + }, [isOpen, sugestoes]); + + const medir = useCallback(async () => { + const t = token.trim(); + if (!t) return; + setCarregando(true); + try { + setPrevia(await preview(t, direction)); + } finally { + setCarregando(false); + } + }, [token, direction, preview]); + + const opcoesSub = useMemo(() => (categoriaId ? opcoesDoDropdown(categoriaId) : []), [categoriaId]); + const categoria = CATEGORIAS.find((c) => c.id === categoriaId); + const subcategoria = opcoesSub.find((o) => o.id === subcategoriaId); + + const podeAplicar = !!previa && previa.linhas > 0 && !!categoriaId && !!subcategoriaId && !aplicando; + + const handleAplicar = async () => { + if (!previa || !categoria || !subcategoria) return; + await aplicar({ + token: token.trim(), + categoriaId: categoria.id, + categoriaNome: categoria.nome, + subcategoriaId: subcategoria.id, + subcategoriaNome: subcategoria.nome, + direction, + totalValor: previa.valor, + }); + onClose(); + }; + + return ( + +
+ {/* 1 · o token */} +
+ +
+ { + setToken(e.target.value.toUpperCase()); + setPrevia(null); + }} + placeholder="Ex.: MAQUININHA" + className="flex-1 px-3 py-2 rounded-lg bg-deep border border-border-subtle text-text-main text-sm font-mono focus:outline-none focus:border-gold-primary/50" + /> + +
+ + {sugestoes.length > 0 && ( +
+ {sugestoes.map((s) => ( + + ))} +
+ )} +

+ Baseado em: {descricaoBase} +

+
+ + {/* 2 · o alcance medido */} + {previa && ( +
+ {previa.linhas === 0 ? ( +

+ + Nenhum lançamento sem categoria contém {token} neste sentido. +

+ ) : ( + <> +

+ Vai categorizar{" "} + {previa.linhas.toLocaleString("pt-BR")} lançamentos,{" "} + {brl(previa.valor)}, todos de{" "} + {direction === "credit" ? "entrada" : "saída"}. +

+

+ São {previa.grupos.toLocaleString("pt-BR")}{" "} + descrições diferentes que viram 1 decisão. +

+ + {previa.protegidasPorContraparte > 0 && ( +

+ + + + {previa.protegidasPorContraparte.toLocaleString("pt-BR")} + {" "} + lançamentos contêm essa palavra mas não serão tocados — são movimentação + de sócio, de empresa do grupo ou entre contas suas. Eles seguem no gate, para + você decidir um a um. + +

+ )} + + {previa.exemplos.length > 0 && ( +
+ Exemplos + {previa.exemplos.slice(0, 4).map((e) => ( + {e} + ))} +
+ )} + + )} +
+ )} + + {/* 3 · o destino */} + {previa && previa.linhas > 0 && ( +
+
+ + +
+
+ + +
+
+ )} + +
+ + +
+
+
+ ); +} diff --git a/src/features/dre/components/forense/ResultsTab.tsx b/src/features/dre/components/forense/ResultsTab.tsx index 0316cacb..b0a9f98d 100644 --- a/src/features/dre/components/forense/ResultsTab.tsx +++ b/src/features/dre/components/forense/ResultsTab.tsx @@ -14,7 +14,6 @@ import { PeriodPicker } from "@/features/dre/components/forense/PeriodPicker"; import { DREMultiMensal, SubcategoriaDRE } from "@/lib/extrato-processor"; import type { MonthlySnapshot } from "@/lib/dre-snapshot-service"; import type { StatusMensal } from "@/lib/financial-document-service"; -import type { DreReadiness, ThresholdTarget } from "@/lib/dre-publishing-service"; import { PeriodSelection, formatPeriodoLabel, @@ -60,11 +59,11 @@ interface ResultsTabProps { onPeriodChange: (sel: PeriodSelection) => void; availableYears: number[]; closedYears: number[]; - /** Estado de publicação da DRE (rascunho/preliminar/consolidada). */ - dreReadiness?: DreReadiness | null; - /** Mutation pra publicar — recebida via page.tsx. */ - onPublishDRE?: (target: ThresholdTarget) => Promise; - isPublishing?: boolean; + // D3 (Lucas, 2026-08-20): a escada de publicação foi aposentada. Os props + // `dreReadiness` / `onPublishDRE` / `isPublishing` eram DECLARADOS e nunca + // renderizados — o `onPublishDRE` era o achado E3-F, e o `dreReadiness` + // fazia a tela disparar um RPC por load para jogar o resultado fora. + // O mês fecha pelo gate (`apurar → gate → preview → concluir`), e é isso. /** * A leitura do recorte ainda está em curso. Sem isto a tela caía no estado * vazio "Sem dados no recorte" durante o carregamento — dizendo que o mês não diff --git a/src/features/dre/hooks/useDreController.ts b/src/features/dre/hooks/useDreController.ts index d4ee08b5..d0d7c46f 100644 --- a/src/features/dre/hooks/useDreController.ts +++ b/src/features/dre/hooks/useDreController.ts @@ -16,6 +16,8 @@ import { aplicarRegrasNoProjeto, buscarLancamentosUnicos, buscarRegrasCategorizacao, + previewRegraContains, + aplicarRegraContains, type ImportarExtratoParams, type LancamentoManualInput, type StatusMensal, @@ -34,12 +36,6 @@ import { type MonthlySnapshot, } from "@/lib/dre-snapshot-service"; import type { DreDetalhamento } from "@/lib/dre-detalhamento/types"; -import { - getDreReadiness, - publishDRE, - type DreReadiness, - type ThresholdTarget, -} from "@/lib/dre-publishing-service"; import type { TransacaoCategoriazada } from "@/lib/extrato-processor"; import { parseDataBanco } from "@/lib/parsers/utils"; import { remapearProlabore } from "@/lib/dre-canonico"; @@ -50,6 +46,11 @@ import { } from "@/features/dre/utils/periodSelection"; import { montarDrePorPeriodo } from "@/features/dre/utils/montarDrePorPeriodo"; import { getProjectService } from "@/lib/project-service"; +import { + janelaPorDataDeEntrada, + mesesFaltantes, + MESES_JANELA, +} from "@/lib/plano-voo/janela-12m"; // "fechamento" (fin-04 / FASE 4.6): ritual guiado = ENTRADA do mês. // As demais são o drill-down forense (D-04.1). @@ -216,7 +217,7 @@ export function useDreController(projectId: string, initialTab?: Tab) { return { total, categorizados, pendentes, porcentagem }; }, [rawLancamentos]); - // Stats globais do projeto (todos os anos) — alimentam o ProjectStatusBadge + // Stats globais do projeto (todos os anos). // e qualquer view que precise de visão consolidada além do ano selecionado. const projectStatus = useMemo(() => { const totalGlobal = exercicios.reduce((acc, ex) => acc + ex.total_transacoes, 0); @@ -440,6 +441,63 @@ export function useDreController(projectId: string, initialTab?: Tab) { }, }); + // ── Regra por TOKEN CONTIDO ───────────────────────────────────────────── + // Uma decisão para muitas descrições diferentes que compartilham o mesmo + // token (ex.: 1.157 recebimentos de clientes distintos, todos "… PIX + // MAQUININHA"). O preview vem antes SEMPRE — o dono precisa ver o alcance, + // incluindo quantas linhas ficam protegidas por terem contraparte. + const previewContains = useCallback( + (token: string, direction: Direction) => previewRegraContains(projectId, token, direction), + [projectId], + ); + + const aplicarContainsMutation = useMutation({ + mutationFn: (data: { + token: string, + categoriaId: string, + categoriaNome: string, + subcategoriaId?: string | null, + subcategoriaNome?: string | null, + direction: Direction, + totalValor: number, + }) => aplicarRegraContains( + projectId, + data.token, + data.categoriaId, + data.categoriaNome, + data.subcategoriaId, + data.subcategoriaNome, + data.direction, + data.totalValor, + ), + onSuccess: (result) => { + // Mesmo tracker do lote por descrição: a propagação é do mesmo tipo, + // só que muito maior — e o relatório da sessão tem de contá-la. + if (result.count > 0) { + setPropagacoesSessao(prev => { + const novosMeses = new Set(prev.meses); + for (const d of result.datas) { + const mesRef = (d || "").slice(0, 7); + if (mesRef.length === 7) novosMeses.add(mesRef); + } + const novosLancamentos = new Set(prev.lancamentos); + novosLancamentos.add(result.token); + return { + transacoes: prev.transacoes + result.count, + meses: novosMeses, + lancamentos: novosLancamentos, + }; + }); + } + queryClient.invalidateQueries({ queryKey: ["dre", "lancamentos", projectId, anoSelecionado] }); + queryClient.invalidateQueries({ queryKey: ["dre", "regras", projectId] }); + queryClient.invalidateQueries({ queryKey: ["dre", "monthlyStatus", projectId] }); + queryClient.invalidateQueries({ queryKey: ["dre", "transacoesPeriodo", projectId] }); + queryClient.invalidateQueries({ queryKey: ["dre", "readiness", projectId] }); + setSuccessMessage(`${result.count} lançamentos categorizados de uma vez.`); + }, + }); + // Varredura retroativa em duas fases: aplica regras nos meses pendentes E // resolve pendentes-fantasma (cat preenchida + sub vazia) propagando pares // (cat, sub) unânimes. Disparada quando o cliente conclui a categorização @@ -498,37 +556,36 @@ export function useDreController(projectId: string, initialTab?: Tab) { const snapshotsPorMes = useMemo(() => indexarPorMes(snapshots), [snapshots]); - // Estado de publicação da DRE (rascunho/preliminar/consolidada). - // Invalidado quando: snapshots mudam, transações mudam (categorização afeta bloqueios), - // ou após publicação manual. - const { data: dreReadiness } = useQuery({ - queryKey: ["dre", "readiness", projectId], - queryFn: () => getDreReadiness(projectId), - }); - - // Mutation pra publicar DRE no nível alvo. Backend valida + dispara cascata. - const publishMutation = useMutation({ - mutationFn: (target: ThresholdTarget) => publishDRE(projectId, target), - onSuccess: (result) => { - if (!result.success) { - setErrorMessage(result.error || "Erro ao publicar DRE"); - return; - } - queryClient.invalidateQueries({ queryKey: ["dre", "readiness", projectId] }); - queryClient.invalidateQueries({ queryKey: ["dre", "snapshots", projectId] }); - // Cascata pode ter concluído outras análises do módulo financeiro - queryClient.invalidateQueries({ queryKey: ["modules", projectId] }); - const cascadeMsg = (result.cascade?.concluidas?.length ?? 0) > 0 - ? ` ${result.cascade!.concluidas.length} análise(s) derivada(s) auto-concluída(s).` - : ""; - setSuccessMessage( - `DRE publicada como "${result.publish_status}" (${result.published_threshold} ${result.published_threshold === 1 ? "mês" : "meses"} fechados).${cascadeMsg}` - ); - }, - onError: (err: unknown) => { - setErrorMessage(err instanceof Error ? err.message : "Erro ao publicar DRE"); - }, - }); + // ── A janela de 12 meses que destrava o Plano de Voo ───────────────────── + // Espelha `plano-voo-gate.ts` (server) e `useProjectSidebarBadges`: a janela + // é ancorada na ENTRADA do cliente, não no mês corrente. Aqui é derivação + // pura do que a tela JÁ carregou (`project` + `snapshots`) — nenhuma leitura + // nova. É o que permite a Apuração COBRAR os meses pelo nome, em vez de só + // trancar o Plano com uma contagem. + const janela12m = useMemo(() => { + const p = project as { data_inicio?: string | null; created_at?: string | null } | null; + const janela = janelaPorDataDeEntrada(p?.data_inicio ?? p?.created_at); + if (!janela) return null; + const fechados = snapshots.map((s) => s.mes_referencia); + const faltantes = mesesFaltantes(janela, fechados); + return { + /** `YYYY-MM` dos meses da janela ainda em aberto, do mais antigo ao mais recente. */ + faltantes, + /** Quantos dos 12 já fecharam. */ + fechados: MESES_JANELA - faltantes.length, + /** Janela completa ⇒ Plano de Voo liberado. */ + completa: faltantes.length === 0, + primeiro: janela.primeiro, + ultimo: janela.ultimo, + }; + }, [project, snapshots]); + + // D3 (Lucas, 2026-08-20): a escada de publicação foi APOSENTADA. + // Saíram daqui a query de readiness (que disparava o RPC por load da + // tela para alimentar um prop nunca renderizado) e a mutation de + // publicar (cujo botão nunca teve ponto de montagem — achado E3-F). + // O mês fecha por `apurar → gate → preview → concluir`, que grava o + // snapshot em `POST /dre/fechar-mes`. Não há segundo caminho. // Anos com 12/12 meses fechados — habilita preset COMPARE_YEARS no PeriodPicker. const closedYears = useMemo(() => { @@ -706,6 +763,7 @@ export function useDreController(projectId: string, initialTab?: Tab) { // Data project, + janela12m, documentos, exercicios, lancamentosUnicos, @@ -723,6 +781,10 @@ export function useDreController(projectId: string, initialTab?: Tab) { regrasCategorizacao, aplicarRegras: aplicarRegrasMutation.mutateAsync, isAplicandoRegras: aplicarRegrasMutation.isPending, + // Regra por token contido + previewContains, + aplicarContains: aplicarContainsMutation.mutateAsync, + isAplicandoContains: aplicarContainsMutation.isPending, varreduraProgress, propagacoesRelatorio, @@ -781,10 +843,6 @@ export function useDreController(projectId: string, initialTab?: Tab) { snapshots, snapshotsPorMes, - // Readiness / publicação - dreReadiness, - publishDRE: publishMutation.mutateAsync, - isPublishing: publishMutation.isPending, criarTransacaoManual: manualEntryMutation.mutateAsync, isCreatingManual: manualEntryMutation.isPending, atualizarCategoria: updateCategoryMutation.mutateAsync, diff --git a/src/features/dre/hooks/useGateForm.ts b/src/features/dre/hooks/useGateForm.ts index 2ccdcc85..90e82feb 100644 --- a/src/features/dre/hooks/useGateForm.ts +++ b/src/features/dre/hooks/useGateForm.ts @@ -32,6 +32,8 @@ import type { import { resolverAntecipacaoMultinivel, derivarAntecipacaoDoLiquido, + derivarFaceDaTaxa, + resolverAntecipacaoPorTaxaDeclarada, type AntecipacaoLinha, type ResolucaoAntecipacao, } from "@/lib/dre-detalhamento/antecipacao-multinivel"; @@ -109,6 +111,16 @@ function faceDoExtrato(card?: CardPendencia): number | null { return somarFaces((card?.lancamentos ?? []).map(l => l.descricao_original)); } +/** E3-B — de onde vem a face da antecipação neste mês. */ +export type OrigemFaceAntecipacao = "bordero" | "taxa"; + +/** E3-B — o que o dono declara no modo taxa. `taxa_ao_mes` é % AO MÊS, como o banco cota. */ +export interface TaxaAntecipacaoForm { + taxa_ao_mes: string; + prazo_medio_dias: string; + tipo_antecipacao: string; +} + function antecipacaoFormInicial(card?: CardPendencia): AntecipacaoForm { const d = card?.detalhamento?.dados as AntecipacaoAgregado | undefined; // Pré-preenche com a face que o extrato JÁ declara — mas nunca sobrescreve o @@ -209,6 +221,15 @@ export interface GateFormState { /** Face total que o MEMO do extrato declara (#6). `null` = extrato não diz. */ faceExtratoAntecipacao: number | null; /** Preview client-side da decomposição multinível — alimenta a barra de status. */ + /** E3-B — de onde vem a face neste mês: borderô do dono ou taxa declarada. */ + origemFace: OrigemFaceAntecipacao; + setOrigemFace: (v: OrigemFaceAntecipacao) => void; + taxaForm: TaxaAntecipacaoForm; + setTaxaForm: React.Dispatch>; + /** Resolução do mês no modo taxa. Null enquanto taxa/prazo não estiverem preenchidos. */ + previewTaxaAntecipacao: ResolucaoAntecipacao | null; + /** % do PERÍODO que a taxa a.m. declarada implica (taxa × prazo/30). */ + taxaPeriodoAntecipacao: number | null; previewAntecipacao: ResolucaoAntecipacao | null; /** Derivação ao vivo do AGREGADO de antecipação (Fatia 3c): líquido do extrato, * deságio ("deixou na mesa"), taxa implícita e custo anual. Null sem card. */ @@ -246,6 +267,20 @@ export function useGateForm(projectId: string, mesRef: string, enabled: boolean const [modoEmprestimo, setModoEmprestimo] = useState("por_contrato"); const [alocacoes, setAlocacoes] = useState>({}); const [modoAntecipacao, setModoAntecipacao] = useState("agregado"); + /** + * E3-B — o switch da antecipação, do MÊS (não da linha). + * + * "bordero" = o dono tem a face na mão (caminho de sempre). + * "taxa" = o dono só sabe o % que paga; a face é DERIVADA do líquido. + * + * É por mês porque quem tem borderô tem para todas as operações, e quem paga + * taxa a paga em todas. Por linha viraria uma matriz com o toggle + * agregado × detalhado, sem caso real que a justifique. + */ + const [origemFace, setOrigemFace] = useState("bordero"); + const [taxaForm, setTaxaForm] = useState({ + taxa_ao_mes: "", prazo_medio_dias: "", tipo_antecipacao: "desconto_duplicata", + }); const [antLinhas, setAntLinhas] = useState>({}); const [recebivelForm, setRecebivelForm] = useState>({}); const [triagem, setTriagem] = useState(null); @@ -290,6 +325,23 @@ export function useGateForm(projectId: string, mesRef: string, enabled: boolean // renderização e submissão ficarem consistentes. const qtdAnt = ant?.lancamentos?.length ?? 0; const modoAntSalvo = ant?.detalhamento?.modo; + + // E3-B: mês salvo no modo taxa volta com o switch e os campos que o + // dono declarou. Sem isto ele reabriria o gate em "borderô" e veria + // a face estimada num campo que diz ter vindo dele. + if (modoAntSalvo === "por_taxa") { + const td = (ant?.detalhamento?.dados as { taxaDeclarada?: { + taxa_ao_mes?: number; prazo_medio_dias?: number; tipo_antecipacao?: string; + } | null } | undefined)?.taxaDeclarada; + setOrigemFace("taxa"); + setTaxaForm({ + taxa_ao_mes: td?.taxa_ao_mes != null ? String(td.taxa_ao_mes) : "", + prazo_medio_dias: td?.prazo_medio_dias != null ? String(td.prazo_medio_dias) : "", + tipo_antecipacao: td?.tipo_antecipacao || "desconto_duplicata", + }); + } else { + setOrigemFace("bordero"); + } setModoAntecipacao( qtdAnt <= 1 ? "agregado" @@ -488,6 +540,36 @@ export function useGateForm(projectId: string, mesRef: string, enabled: boolean return resolverAntecipacaoMultinivel(linhasInput, null); }, [cardAnt, antLinhas]); + /** + * E3-B — preview do modo taxa. Roda o MESMO motor que a rota grava + * (`resolverAntecipacaoPorTaxaDeclarada`), nunca uma segunda conta: preview + * que diverge do snapshot é como o dono perde a confiança no número. + */ + const previewTaxaAntecipacao = useMemo(() => { + const lancs = cardAnt?.lancamentos || []; + if (lancs.length === 0) return null; + const taxaAoMes = Number(taxaForm.taxa_ao_mes) || 0; + const prazo = Number(taxaForm.prazo_medio_dias) || 0; + if (taxaAoMes <= 0 || prazo <= 0) return null; + + return resolverAntecipacaoPorTaxaDeclarada( + lancs.map(l => ({ transaction_id: l.id, valor_bruto_real: l.credito || 0 })), + { + taxa_ao_mes: taxaAoMes, + prazo_medio_dias: prazo, + tipo_antecipacao: taxaForm.tipo_antecipacao || "desconto_duplicata", + }, + ); + }, [cardAnt, taxaForm.taxa_ao_mes, taxaForm.prazo_medio_dias, taxaForm.tipo_antecipacao]); + + /** O % do PERÍODO que a taxa declarada implica — o que a tela mostra na vírgula. */ + const taxaPeriodoAntecipacao = useMemo(() => { + const taxaAoMes = Number(taxaForm.taxa_ao_mes) || 0; + const prazo = Number(taxaForm.prazo_medio_dias) || 0; + if (taxaAoMes <= 0 || prazo <= 0) return null; + return derivarFaceDaTaxa(1000, taxaAoMes, prazo).taxaPeriodoPercent; + }, [taxaForm.taxa_ao_mes, taxaForm.prazo_medio_dias]); + // Derivação do AGREGADO (Fatia 3c): líquido = soma dos créditos do extrato; // bruto = campo do dono; taxa e "deixou na mesa" (deságio) derivados ao vivo. // `completo` = bruto e prazo preenchidos e bruto ≥ líquido (deságio ≥ 0). @@ -522,10 +604,16 @@ export function useGateForm(projectId: string, mesRef: string, enabled: boolean // (toda linha precisa ter bruto + prazo pra derivar a taxa, ou herdar a média). // No modo agregado, exige bruto + prazo preenchidos (a taxa é derivada do // líquido do extrato) — `completo` cobre isso (Fatia 3c). + // E3-B: no modo taxa a face deixa de ser exigida — o que o gate cobra é a taxa + // e o prazo, e que o motor tenha conseguido derivar TODAS as linhas. Era este + // `antValido` que trancava o mês inteiro (e com ele as 7 perguntas do produto) + // para todo cliente que desconta duplicata sem borderô em mãos. const antValido = !cardAnt || ( - modoAntecipacao === "detalhado" - ? (previewAntecipacao !== null && previewAntecipacao.indefinidas === 0) - : (derivAntecipacaoAgregado?.completo ?? false) + origemFace === "taxa" + ? (previewTaxaAntecipacao !== null && previewTaxaAntecipacao.indefinidas === 0) + : modoAntecipacao === "detalhado" + ? (previewAntecipacao !== null && previewAntecipacao.indefinidas === 0) + : (derivAntecipacaoAgregado?.completo ?? false) ); // Movimentações de sócio são OPCIONAIS (Fatia 3 — jornada de apuração): não // entram no gate de envio. A natureza declarada ainda é salva (handleFinalizar @@ -576,7 +664,10 @@ export function useGateForm(projectId: string, mesRef: string, enabled: boolean // - Antecipação modo "detalhado": POST /antecipacao-linhas (por_linha/hibrido) // - Resto (emprestimo agregado, antecipação agregada, sócios): POST agregado clássico const usaMulti = !!cardEmp && modoEmprestimo === "por_contrato"; - const usaAntLinhas = !!cardAnt && modoAntecipacao === "detalhado"; + // E3-B: o modo taxa vai pela MESMA rota do detalhado (a que roda o motor + // multinível), mas com `taxaDeclarada` no lugar das linhas. + const usaAntTaxa = !!cardAnt && origemFace === "taxa"; + const usaAntLinhas = !!cardAnt && !usaAntTaxa && modoAntecipacao === "detalhado"; if (usaMulti) { const multiRes = await salvarMultiContrato(projectId, { @@ -589,6 +680,22 @@ export function useGateForm(projectId: string, mesRef: string, enabled: boolean } } + if (usaAntTaxa) { + const antRes = await salvarAntecipacaoLinhas(projectId, { + mes_referencia: mesRef, + linhas: [], + taxaDeclarada: { + taxa_ao_mes: Number(taxaForm.taxa_ao_mes), + prazo_medio_dias: Number(taxaForm.prazo_medio_dias), + tipo_antecipacao: taxaForm.tipo_antecipacao || "desconto_duplicata", + }, + }); + if ("error" in antRes) { + setError(antRes.error); + return { ok: false, error: antRes.error }; + } + } + if (usaAntLinhas) { const linhasPayload = (cardAnt?.lancamentos || []).map(l => { const e = antLinhas[l.id]; @@ -626,7 +733,7 @@ export function useGateForm(projectId: string, mesRef: string, enabled: boolean indexador: empForm.indexador, }; } - if (cardAnt && !usaAntLinhas) { + if (cardAnt && !usaAntLinhas && !usaAntTaxa) { // Fatia 3c: taxa derivada do líquido real (soma dos créditos do mês), // não mais um input. O motor decomporAntecipacao reproduz deságio = // bruto − líquido ⟹ preview == snapshot intacto. @@ -752,7 +859,7 @@ export function useGateForm(projectId: string, mesRef: string, enabled: boolean } finally { setSaving(false); } - }, [podeEnviar, mesRef, projectId, cardEmp, cardAnt, cardSoc, cardRec, empForm, antForm, socioNat, portas, recebivelForm, decisoesCredito, decisoesOrfa, queryClient, modoEmprestimo, alocacoes, modoAntecipacao, antLinhas]); + }, [podeEnviar, mesRef, projectId, cardEmp, cardAnt, cardSoc, cardRec, empForm, antForm, socioNat, portas, recebivelForm, decisoesCredito, decisoesOrfa, queryClient, modoEmprestimo, alocacoes, modoAntecipacao, antLinhas, origemFace, taxaForm]); return { pendencias, @@ -804,6 +911,13 @@ export function useGateForm(projectId: string, mesRef: string, enabled: boolean marcarConciliacoesRevisadas, hasCarteira, faceExtratoAntecipacao, + // E3-B — o switch da antecipação + origemFace, + setOrigemFace, + taxaForm, + setTaxaForm, + previewTaxaAntecipacao, + taxaPeriodoAntecipacao, previewAntecipacao, derivAntecipacaoAgregado, }; diff --git a/src/features/dre/types/index.ts b/src/features/dre/types/index.ts index f8266d46..850939d9 100644 --- a/src/features/dre/types/index.ts +++ b/src/features/dre/types/index.ts @@ -36,7 +36,21 @@ export interface DRESubcategory { // ============================================================ /** Tipo de correspondência para regras de categorização */ -export type MatchType = 'exact' | 'contains' | 'starts_with' | 'regex'; +/** + * Como a regra do cliente casa com `transactions.lancamento_tratado`. + * + * Espelha o CHECK de `categorization_rules.match_type` + * (migration `20260818000000`) — manter os dois em sincronia. + * + * Este tipo já existia declarado com `'starts_with'` e `'regex'`, mas a coluna + * nunca existiu e nada consumia o campo. Os dois saíram na implementação: + * - `starts_with` foi MEDIDO e cobre só 4% da cauda (o nome da pessoa vem + * primeiro no descritor, então o prefixo não discrimina); + * - `regex` vindo do usuário é armadilha (ReDoS) e `contains` resolve 85% dos + * casos medidos com risco zero. + * Voltar a incluí-los é migration + um ramo em `filtroDeRegra`. + */ +export type MatchType = 'exact' | 'contains'; /** Direção do dinheiro (crédito ou débito) */ export type Direction = 'credit' | 'debit'; diff --git a/src/hooks/useProjectSidebarBadges.ts b/src/hooks/useProjectSidebarBadges.ts index ee1a4587..3000a1fb 100644 --- a/src/hooks/useProjectSidebarBadges.ts +++ b/src/hooks/useProjectSidebarBadges.ts @@ -1,12 +1,13 @@ /** - * useProjectSidebarBadges — PE3.7 (gate D-3 desde 2026-05-30) + * useProjectSidebarBadges — PE3.7 (gate: os 12 meses da janela fechados) * * Retorna contadores leves usados para badges discretos na sidebar SaaS: * * - `hasFinancialDocs`: pulse no item Financeiro quando o projeto ainda * não tem nenhum `financial_documents` (cliente precisa subir 1º OFX). * - `planoVooReady` / `planoVooMotivo`: o Plano de Voo destrava pelo dado - * financeiro vivo (`consultorPronto`: ≥3 meses fechados; folha NÃO é gate), + * financeiro vivo (`consultorPronto`: os 12 meses da janela fechados; + * folha NÃO é gate), * NÃO mais pelo contador "30 análises". `motivo` é o tooltip do cadeado. * - `hypothesesProposta`: contagem de `atr_hypotheses.status='proposta'` * usado no Ciclo HTR ("N propostas"). @@ -20,7 +21,7 @@ import { useEffect, useState } from "react"; import { createClient } from "@/lib/supabase/client"; import { consultorPronto } from "@/lib/consultor-readiness"; -import { cobertura, janelaPorDataDeEntrada } from "@/lib/plano-voo/janela-12m"; +import { mesesFaltantes, janelaPorDataDeEntrada } from "@/lib/plano-voo/janela-12m"; export interface ProjectSidebarBadges { hasFinancialDocs: boolean; @@ -100,24 +101,23 @@ async function fetchBadges(projectId: string): Promise { // Espelha `plano-voo-gate.ts` (server): cobertura da janela de 12 meses // ancorada na entrada, não contagem solta de snapshots. const janela = janelaPorDataDeEntrada(proj?.data_inicio ?? proj?.created_at); - const mesesNaJanela = janela - ? cobertura( - janela, - ((snapsRes.data ?? []) as { mes_referencia: string }[]).map((r) => r.mes_referencia), - ) - : 0; + const fechadosNaJanela = ((snapsRes.data ?? []) as { mes_referencia: string }[]).map( + (r) => r.mes_referencia, + ); const readiness = isConsolidado ? { pronto: false, motivo: "O Plano de Voo é por empresa — abra uma empresa do grupo.", + mesesFaltantes: [] as string[], } : !janela ? { pronto: false, motivo: "Sem data de início no projeto não dá para apurar os 12 meses.", + mesesFaltantes: [] as string[], } - : consultorPronto({ snapshotsAtivos: mesesNaJanela }); + : consultorPronto({ mesesFaltantes: mesesFaltantes(janela, fechadosNaJanela) }); const iniciativasReady = !isConsolidado && (planoAprovadoRes.count ?? 0) > 0; diff --git a/src/lib/agents/consultor-ia-prompt.ts b/src/lib/agents/consultor-ia-prompt.ts index 8bf88ea6..7d8d3aa0 100644 --- a/src/lib/agents/consultor-ia-prompt.ts +++ b/src/lib/agents/consultor-ia-prompt.ts @@ -33,17 +33,18 @@ interface BuildSystemPromptOpts { }>; phaseFocus: string; phaseGuardrails: string; - analysesCompleted: number; phase: string; semana?: number; + /** D10: modo Consultor = 12 meses da janela fechados (`isModoConsultor`). */ toolsEnabled: boolean; } /** * Monta o system prompt do Consultor IA (chat amplo). * - * Modo Assistente (<30 análises): tom orientativo, sem análise estratégica. - * Modo Consultor (≥30): tom de consultor sênior, com tool-use ativo. + * Modo Assistente (janela de 12 meses ainda aberta): tom orientativo, guia a + * apuração, sem tool-use e sem leitura que exija a série completa. + * Modo Consultor (12 meses fechados): tom de consultor sênior, tool-use ativo. * * Diferença vs HTR Agent: aqui o objetivo é CONVERSAR sobre qualquer aspecto * do projeto; lá é PROPOR HIPÓTESES TESTÁVEIS. @@ -54,7 +55,6 @@ export function buildConsultorIaSystemPrompt(opts: BuildSystemPromptOpts): strin memories, phaseFocus, phaseGuardrails, - analysesCompleted, phase, semana, toolsEnabled, @@ -72,22 +72,23 @@ export function buildConsultorIaSystemPrompt(opts: BuildSystemPromptOpts): strin .join("\n")}\n` : ""; - // ── Modo Assistente: cliente novo, base de dados em formação ── - if (analysesCompleted < 30) { + // ── Modo Assistente: a apuração ainda não fechou os 12 meses da janela ── + if (!toolsEnabled) { return `Você é o Consultor IA do ATR-OS — assistente sênior conversando com o dono da empresa. -SEU PAPEL AGORA (cliente em fase inicial): +SEU PAPEL AGORA (a apuração ainda está em curso): - Conversar como um consultor de verdade — direto, prático, sem jargão de processo. -- Ajudar o cliente a colocar os dados pra dentro (extratos bancários no Financeiro é o primeiro passo). +- Guiar a apuração: subir os extratos, organizar as contas por empresa, categorizar e fechar o mês. - Explicar terminologia empresarial quando perguntado (EBITDA, CMV, margem, etc.). - Mostrar em termos concretos (tempo, dinheiro) o que a empresa ganha quando os números estiverem completos. - Conhecer o negócio: faça perguntas pra entender o contexto antes de generalizar. +- Ler o que JÁ está apurado — mês fechado é número real, pode falar dele. REGRAS ABSOLUTAS (o sistema nunca expõe processo interno): - NUNCA mencione "X/30 análises", "diagnóstico", "fase do ciclo", "ciclo HTR", "flight plan", percentuais ou qualquer artefato interno. O cliente fala de negócio; processo é invisível. -- NÃO faça análise estratégica nem sugira ações ainda — a base de dados é frágil. Qualquer leitura agora seria precipitada. -- Se o cliente perguntar algo estratégico, responda em linguagem de negócio: - "Ainda estou conhecendo os números da sua empresa. Quando houver mais dados — o melhor começo é subir os extratos bancários no Financeiro — eu consigo entrar de verdade. Quer que eu te mostre por onde começar?" +- NÃO projete tendência, não compare períodos nem recomende decisão estrutural (preço, crédito, corte, investimento) enquanto os 12 meses não fecharem — a série ainda é curta e a leitura sairia torta. +- Se o cliente perguntar algo que exige a série completa, responda em linguagem de negócio: + "Ainda não tenho os 12 meses fechados da sua empresa — sem eles, uma leitura de tendência sairia torta. Vamos fechar os meses que faltam? Aí eu entro de verdade." ${factsBlock} @@ -105,7 +106,7 @@ FERRAMENTAS DISPONÍVEIS (use-as ativamente — são a sua fonte de verdade): - compareToBenchmark({ mes? }): compara KPIs do mês com benchmark do segmento (crítico/atenção/saudável/excelente por indicador). - getActiveHypothesesAndTests({ module? }): hipóteses aprovadas/em-teste e testes em execução — pra dar panorama do HTR. - getHypothesisHistory({ module?, limit? }): hipóteses validadas/descartadas com testes e resultados — aprender com o passado. -- getAnalysisDiagnosis({ module? }): diagnóstico consolidado das análises concluídas. +- getDreReadiness(): quanto da história já está apurado (meses fechados, consecutivos, anos completos) e \`bloqueios\` — o que trava o gate do próximo fechamento. Use para "minha DRE está pronta?" e "o que falta pra fechar o mês?". - getPlanoVoo(): snapshot mais recente do Plano de Voo (gaps, alavancas, plano 12 semanas, resumo executivo). - searchMemories({ query?, module?, memory_type?, tags?, limit? }): busca em memórias (aprendizados, insights, decisões, marcos). Use antes de sugerir algo novo. @@ -115,7 +116,7 @@ REGRAS DE USO DAS FERRAMENTAS: 3. Pra andamento do ciclo HTR (mas SEM propor novas hipóteses — isso é trabalho do HTR Agent), use getActiveHypothesesAndTests. 4. Pra aprender com o passado, getHypothesisHistory. 5. ANTES de propor uma orientação nova, chame searchMemories pra ver se já foi discutida. -6. Se a tool retornar \`error\` ou lista vazia, diga explicitamente: "Não tenho esse dado no diagnóstico ainda." Nunca invente. +6. Se a tool retornar \`error\` ou lista vazia, diga explicitamente: "Não tenho esse dado apurado ainda." Nunca invente. 7. Cite a origem do número ao reportá-lo (ex.: "segundo o snapshot mais recente..."). ESCOPO DO CONSULTOR IA (vs HTR Agent): diff --git a/src/lib/ai-categorizer.ts b/src/lib/ai-categorizer.ts deleted file mode 100644 index 183dbfbf..00000000 --- a/src/lib/ai-categorizer.ts +++ /dev/null @@ -1,210 +0,0 @@ -/** - * ATR OS - AI Categorizer - * Categorização inteligente de lançamentos via `callAIWithMeta` - * (provider-agnóstico + telemetria em `ai_calls`). - */ - -import { callAIWithMeta } from "@/lib/ai-client"; -import { extractJson } from "@/lib/ai/json-extract"; - -export interface CategorySuggestion { - description: string; - category: string; - subcategory: string; - confidence: number; -} - -// Categorias e subcategorias da ATR -import { CATEGORY_MAP } from "@/lib/constants/financial-categories"; - -// Categorias e subcategorias da ATR -export const ATR_CATEGORIES = CATEGORY_MAP; - -/** - * Gera o prompt para o Gemini categorizar os lançamentos - */ -function buildCategorizationPrompt(descriptions: string[]): string { - const categoriesText = Object.entries(ATR_CATEGORIES) - .map( - ([cat, subs]) => - `${cat}:\n${subs.map((s) => ` - ${s}`).join("\n")}` - ) - .join("\n\n"); - - return `Você é um especialista em contabilidade e DRE Gerencial para pequenas e médias empresas brasileiras. - -Sua tarefa é categorizar lançamentos bancários de acordo com a metodologia ATR de DRE Gerencial. - -## CATEGORIAS DISPONÍVEIS: - -${categoriesText} - -## LANÇAMENTOS PARA CATEGORIZAR: - -${descriptions.map((d, i) => `${i + 1}. ${d}`).join("\n")} - -## INSTRUÇÕES: - -1. Analise cada lançamento e determine a categoria e subcategoria mais adequada -2. Use EXATAMENTE os nomes de categoria/subcategoria listados acima -3. Se não tiver certeza, use a categoria mais provável e indique confiança baixa -4. Para lançamentos ambíguos, considere o contexto de uma PME brasileira - -## FORMATO DE RESPOSTA: - -Responda APENAS com um JSON válido no seguinte formato (sem markdown, sem comentários): - -[ - { - "index": 1, - "description": "texto do lançamento", - "category": "Nome da Categoria", - "subcategory": "Nome da Subcategoria", - "confidence": 0.85 - } -] - -O campo confidence deve ser um número entre 0 e 1 indicando sua confiança na categorização. -`; -} - -/** - * Categoriza lançamentos via `callAIWithMeta`. Provider é definido pelas - * env vars AI_PROVIDER/AI_FALLBACK_PROVIDER — a categorização não amarra - * em Gemini. - */ -export async function categorizeWithAI( - descriptions: string[], - opts: { projectId?: string } = {} -): Promise { - // Limita a 50 lançamentos por chamada para não exceder limites - const batches: string[][] = []; - for (let i = 0; i < descriptions.length; i += 50) { - batches.push(descriptions.slice(i, i + 50)); - } - - const allResults: CategorySuggestion[] = []; - - for (const batch of batches) { - const prompt = buildCategorizationPrompt(batch); - - const { text } = await callAIWithMeta(prompt, { - maxTokens: 2000, - caller: "ai_categorizer", - projectId: opts.projectId, - }); - - if (!text) { - console.error("[AI-Categorizer] Sem resposta do provider"); - continue; - } - - const parsed = extractJson>(text); - - if (!parsed || !Array.isArray(parsed)) { - console.error("[AI-Categorizer] Resposta sem JSON válido"); - continue; - } - - for (const item of parsed) { - allResults.push({ - description: item.description, - category: item.category, - subcategory: item.subcategory, - confidence: item.confidence, - }); - } - } - - return allResults; -} - -/** - * Busca regra aprendida no banco para uma descrição - */ -export async function findLearnedRule( - pattern: string, - supabase: any -): Promise<{ category_id: string; subcategory_id: string } | null> { - const { data } = await supabase - .from("learned_rules") - .select("category_id, subcategory_id") - .eq("pattern", pattern) - .single(); - - return data; -} - -/** - * Salva ou atualiza uma regra aprendida - */ -export async function saveLearnedRule( - pattern: string, - categoryId: string, - subcategoryId: string, - supabase: any -): Promise { - // Verifica se a regra já existe - const { data: existing } = await supabase - .from("learned_rules") - .select("id, hit_count") - .eq("pattern", pattern) - .single(); - - if (existing) { - // Atualiza incrementando o hit_count manualmente - await supabase - .from("learned_rules") - .update({ - hit_count: existing.hit_count + 1, - category_id: categoryId, - subcategory_id: subcategoryId, - }) - .eq("id", existing.id); - } else { - // Insere nova regra - await supabase.from("learned_rules").insert({ - pattern, - category_id: categoryId, - subcategory_id: subcategoryId, - hit_count: 1, - }); - } -} - -/** - * Valida se a categoria e subcategoria existem - */ -export function validateCategory( - category: string, - subcategory: string -): boolean { - const validSubs = ATR_CATEGORIES[category as keyof typeof ATR_CATEGORIES] as unknown as string[] | undefined; - if (!validSubs) return false; - return validSubs.includes(subcategory); -} - -/** - * Obtém a lista flat de todas as subcategorias - */ -export function getAllSubcategories(): string[] { - return Object.values(ATR_CATEGORIES).flat() as string[]; -} - -/** - * Obtém a categoria pai de uma subcategoria - */ -export function getCategoryForSubcategory(subcategory: string): string | null { - for (const [category, subs] of Object.entries(ATR_CATEGORIES)) { - if ((subs as string[]).includes(subcategory)) { - return category; - } - } - return null; -} diff --git a/src/lib/ai-prompts.ts b/src/lib/ai-prompts.ts index ceda797d..9f99a25e 100644 --- a/src/lib/ai-prompts.ts +++ b/src/lib/ai-prompts.ts @@ -21,11 +21,11 @@ NUNCA: invente dados, seja genérico, use clichês sem dados, ignore os números export function getConsultantPromptByPhase(phase: string, semana?: number): string { switch (phase) { case "onboarding": - return "FOCO ATUAL: Explicar o sistema ATR OS de forma simples e orientar o cliente sobre a importância de preencher os dados iniciais."; + return "FOCO ATUAL: O cliente ainda não fechou nenhum mês. Guiar a entrada dos dados — baixar os extratos OFX no banco, subir no sistema, organizar quais contas são de cada empresa — e explicar, em linguagem de negócio, o que isso destrava."; case "diagnostico": - return "FOCO ATUAL: Incentivar a conclusão das 30 análises de diagnóstico para destravar o Consultor Pleno e mostrar os primeiros insights baseados nos dados já preenchidos."; + return "FOCO ATUAL: O cliente já tem mês fechado, mas a janela de 12 meses ainda não completou. Falar do que já está apurado e incentivar o fechamento dos meses que faltam — é o que destrava o Plano de Voo."; case "plano": - return "FOCO ATUAL: Revisar os gaps identificados, estruturar uma sequência lógica de correção e incentivar a aprovação oficial do Plano de Voo para iniciar os 90 dias."; + return "FOCO ATUAL: Os 12 meses estão fechados e o Plano de Voo está destravado. Ajudar o cliente a ler a meta que o sistema indicou, ajustá-la se ele quiser, e travá-la — é o que popula as Iniciativas e o Cockpit."; case "execucao": if (semana && semana >= 1 && semana <= 4) { return "FOCO ATUAL: Primeiras 4 semanas do HTR. Focar urgentemente nos gaps financeiros críticos, saúde de caixa, CMV e adequação das despesas fixas."; @@ -49,14 +49,21 @@ export function getConsultantPromptByPhase(phase: string, semana?: number): stri export function getPhaseGuardrails(phase: string): string { switch (phase) { case "onboarding": + return `GUARDRAIL: Nenhum mês foi apurado ainda — não existe número para ler. +NÃO invente leitura financeira. Foque em guiar a entrada dos extratos e a organização das contas.`; case "diagnostico": - return `GUARDRAIL: Você está em modo AUXILIAR. -NÃO faça análises estratégicas. NÃO interprete dados financeiros. -NÃO sugira ações de negócio. Foque em guiar o preenchimento.`; + // D10: o cliente aqui JÁ tem mês fechado. O guardrail antigo proibia + // "interpretar dados financeiros" — e, com a fase presa em onboarding, + // isso amordaçava a IA de quem tinha 12 meses apurados. O limite certo + // não é falar do número: é não projetar sobre série curta. + return `GUARDRAIL: Há meses fechados, mas a janela de 12 meses ainda não completou. +PODE ler e explicar os meses já apurados — são número real, do extrato do cliente. +NÃO projete tendência, não compare períodos longos nem recomende decisão estrutural +(preço, crédito, corte, investimento): a série ainda é curta e a leitura sairia torta.`; case "plano": - return `GUARDRAIL: O diagnóstico está completo mas o Plano de Voo ainda não foi aprovado. -Foque em apresentar os gaps identificados e recomendar aprovação do plano. -NÃO inicie ciclos HTR antes da aprovação.`; + return `GUARDRAIL: Os 12 meses estão fechados e a meta ainda não foi travada. +Foque em ajudar o cliente a ler a meta indicada, ajustá-la se quiser, e travá-la. +NÃO trate as ações semanais como vigentes antes de a meta ser travada.`; case "execucao": return `GUARDRAIL: Empresa em execução ativa. Use APENAS dados do dossiê. Cite números reais. diff --git a/src/lib/ai-tools/get-analysis-diagnosis.ts b/src/lib/ai-tools/get-analysis-diagnosis.ts deleted file mode 100644 index a1eb7ace..00000000 --- a/src/lib/ai-tools/get-analysis-diagnosis.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { tool, jsonSchema } from "ai"; -import type { SupabaseClient } from "@supabase/supabase-js"; - -/** - * Tool `getAnalysisDiagnosis` — retorna o diagnóstico inicial consolidado - * a partir das análises já concluídas (`module_analyses.status = 'concluido'`), - * opcionalmente filtrado por módulo. - * - * Usar quando o cliente pedir um resumo do diagnóstico, perguntar "o que - * vocês encontraram na minha empresa?", ou quando o Consultor precisa - * citar as análises fechadas para justificar uma recomendação. - */ - -type Input = { module?: "financeiro" | "comercial" | "gestao" }; - -const parameters = jsonSchema({ - type: "object", - properties: { - module: { - type: "string", - enum: ["financeiro", "comercial", "gestao"], - description: - "Filtra por módulo. Omitir retorna o diagnóstico de todos os módulos.", - }, - }, - additionalProperties: false, -}); - -export function makeGetAnalysisDiagnosis(ctx: { - projectId: string; - supabase: SupabaseClient; -}) { - return tool({ - description: - "Retorna o diagnóstico das análises concluídas do projeto (parecer + resultado JSON rico + as_of) por módulo. Use para resumir o que foi descoberto na empresa, justificar recomendações com base nas análises fechadas, ou responder ao cliente sobre o estado do diagnóstico. IMPORTANTE: `as_of` é a data do último recompute daquela análise — cite os números como 'na apuração de ', não como tempo real.", - parameters, - execute: async ({ module }: Input) => { - const { projectId, supabase } = ctx; - - const { data: projectModules, error: pmError } = await supabase - .from("project_modules") - .select("id, tipo, ordem") - .eq("project_id", projectId); - - if (pmError) return { error: pmError.message, analyses: [] }; - const modules = (projectModules ?? []).filter( - (m) => !module || m.tipo === module - ); - if (modules.length === 0) return { error: null, analyses: [] }; - - const moduleIds = modules.map((m) => m.id); - - const { data: analyses, error: aError } = await supabase - .from("module_analyses") - .select( - "id, module_id, numero, nome, status, parecer, resultado, data_conclusao" - ) - .in("module_id", moduleIds) - .eq("status", "concluido") - .order("numero", { ascending: true }); - - if (aError) return { error: aError.message, analyses: [] }; - - const byModuleId = new Map(modules.map((m) => [m.id, m.tipo])); - - return { - error: null, - analyses: (analyses ?? []).map((a) => { - // Bloco 1 (ponte): `resultado` é o envelope AnaliseRica - // materializado da dedicada. `as_of` (selo de frescor) sobe - // ao topo p/ o modelo SEMPRE ver a data ao lado do detalhe — - // a convergência é eventual (via recompute), não tempo real. - const env = a.resultado as { as_of?: string | null } | null; - return { - modulo: byModuleId.get(a.module_id) ?? "desconhecido", - numero: a.numero, - nome: a.nome, - parecer: a.parecer, - as_of: env?.as_of ?? a.data_conclusao ?? null, - resultado: a.resultado, - data_conclusao: a.data_conclusao, - }; - }), - }; - }, - }); -} diff --git a/src/lib/ai-tools/get-dre-readiness.ts b/src/lib/ai-tools/get-dre-readiness.ts index 691944d9..fdcc69c5 100644 --- a/src/lib/ai-tools/get-dre-readiness.ts +++ b/src/lib/ai-tools/get-dre-readiness.ts @@ -4,10 +4,16 @@ import type { SupabaseClient } from "@supabase/supabase-js"; /** * Tool: getDreReadiness * - * Retorna o estado de publicação da DRE do projeto: rascunho/preliminar/consolidada, - * snapshots fechados, anos completos, bloqueios pra publicação. Útil pra IA - * dar resposta precisa do tipo "Você fechou 9 meses, faltam 3 para uma DRE - * consolidada" sem inventar. + * Quanto da história já está apurado, e o que impede fechar o próximo mês. + * + * D3/D10 (Lucas, 2026-08-20): a escada de publicação foi aposentada. O + * `publish_status` do RPC deixou de ser lido de `module_analyses` (onde era + * sempre `'rascunho'`, porque nada publicava) e passou a ser DERIVADO da + * contagem de meses fechados. As quatro colunas de publicação que sobraram no + * RPC — `pode_publicar`, `target_threshold`, `last_published_at`, + * `published_by` — são vestigiais (assinatura preservada para não perder os + * GRANTs) e NÃO sobem para o modelo: um `pode_publicar: false` convidaria a IA + * a falar de publicar, que não existe mais. */ type Input = Record; @@ -24,7 +30,7 @@ export function makeGetDreReadiness(ctx: { }) { return tool({ description: - "Estado de publicação da DRE do projeto: rascunho (0-2 meses fechados, sistema bloqueado), preliminar (3-11, libera análises com aviso), consolidada (12+, peso máximo). Retorna também quantos meses fechados, anos completos disponíveis e o que falta pra publicar. Use sempre que precisar contextualizar 'a DRE está pronta?' ou 'falta quanto pra X'.", + "Quanto da história da empresa já está apurado e o que impede fechar o próximo mês. Retorna: `maturidade_do_historico` (inicial 0-2 meses fechados / parcial 3-11 / completa 12+), quantos meses fechados e quantos consecutivos, o primeiro e o último mês fechado, anos com 12/12, há quantos dias foi o último fechamento, e `bloqueios` — a lista do que trava o gate (lançamentos sem categoria e os impeditivos de empréstimo, antecipação e sócio). Use para responder 'a minha DRE está pronta?', 'o que falta para eu fechar o mês?' ou para saber se pode confiar numa leitura de tendência. IMPORTANTE: para 'faltam quantos meses para destravar o Plano de Voo', esta tool NÃO é a fonte — ela conta todos os meses fechados, e o Plano usa a janela de 12 meses ancorada na entrada do cliente.", parameters, execute: async () => { const { projectId, supabase } = ctx; @@ -46,9 +52,32 @@ export function makeGetDreReadiness(ctx: { }; } + const r = data as { + publish_status: string; + snapshots_total: number; + snapshots_consecutivos: number; + earliest_closed: string | null; + latest_closed: string | null; + anos_completos: number[] | null; + cobertura_atual_dias: number | null; + bloqueios: string[] | null; + }; + + // Só o que é vivo. As 4 colunas de publicação ficam de fora (ver + // cabeçalho): são vestígio de assinatura, não informação. return { found: true, - ...(data as Record), + maturidade_do_historico: + r.publish_status === "consolidada" ? "completa" + : r.publish_status === "preliminar" ? "parcial" + : "inicial", + meses_fechados: r.snapshots_total, + meses_consecutivos: r.snapshots_consecutivos, + primeiro_mes_fechado: r.earliest_closed, + ultimo_mes_fechado: r.latest_closed, + anos_completos: r.anos_completos ?? [], + dias_desde_o_ultimo_fechamento: r.cobertura_atual_dias, + bloqueios: r.bloqueios ?? [], }; }, }); diff --git a/src/lib/ai-tools/index.ts b/src/lib/ai-tools/index.ts index 95b7154d..10189779 100644 --- a/src/lib/ai-tools/index.ts +++ b/src/lib/ai-tools/index.ts @@ -13,7 +13,7 @@ * - Tier 1 (essenciais): getFinancialSnapshot, getFinancialTimeline, * getActiveHypothesesAndTests. * - Tier 2 (contexto rico): compareToBenchmark, getHypothesisHistory, - * getAnalysisDiagnosis, getPlanoVoo. + * getPlanoVoo. * - Tier 3 (memória): searchMemories (lexical; pgvector pendente). */ @@ -24,7 +24,6 @@ import { makeGetDreReadiness } from "./get-dre-readiness"; import { makeGetActiveHypothesesAndTests } from "./get-active-hypotheses-and-tests"; import { makeCompareToBenchmark } from "./compare-to-benchmark"; import { makeGetHypothesisHistory } from "./get-hypothesis-history"; -import { makeGetAnalysisDiagnosis } from "./get-analysis-diagnosis"; import { makeGetPlanoVoo } from "./get-plano-voo"; import { makeSearchMemories } from "./search-memories"; @@ -45,7 +44,6 @@ export function buildConsultantTools(ctx: ConsultantToolContext) { getActiveHypothesesAndTests: makeGetActiveHypothesesAndTests(ctx), compareToBenchmark: makeCompareToBenchmark(ctx), getHypothesisHistory: makeGetHypothesisHistory(ctx), - getAnalysisDiagnosis: makeGetAnalysisDiagnosis(ctx), getPlanoVoo: makeGetPlanoVoo(ctx), searchMemories: makeSearchMemories(ctx), }; diff --git a/src/lib/ai-tools/manifesto-paridade.test.ts b/src/lib/ai-tools/manifesto-paridade.test.ts new file mode 100644 index 00000000..800487b3 --- /dev/null +++ b/src/lib/ai-tools/manifesto-paridade.test.ts @@ -0,0 +1,57 @@ +/** + * Regressão — toda tool REGISTRADA precisa estar ANUNCIADA nos dois manifestos. + * + * De onde vem: na fatia 2 do plano de correção do pente fino (D3, 2026-08-20), + * a tool `getAnalysisDiagnosis` foi removida do `buildConsultantTools` — e os + * manifestos dentro dos prompts continuaram anunciando-a ao modelo. Uma tool + * anunciada e inexistente vira erro em runtime no meio da conversa da cliente; + * uma tool registrada e não anunciada (era o caso do `getDreReadiness`) é uma + * ferramenta que o modelo tem e não sabe que tem. + * + * Os dois lados vivem em arquivos diferentes e ninguém os casava. Este teste + * casa — lendo o FONTE, porque o manifesto é string dentro do prompt e não há + * como observá-lo em runtime sem chamar a IA. + */ + +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const REGISTRO = "src/lib/ai-tools/index.ts"; +const MANIFESTOS = [ + "src/app/api/ai/htr/chat/route.ts", + "src/lib/agents/consultor-ia-prompt.ts", +]; + +const ler = (p: string) => readFileSync(resolve(process.cwd(), p), "utf-8"); + +/** Nomes das tools no objeto devolvido por `buildConsultantTools`. */ +function toolsRegistradas(): string[] { + const fonte = ler(REGISTRO); + return [...fonte.matchAll(/^\s+(\w+): make\w+\(ctx\),/gm)].map((m) => m[1]); +} + +describe("manifesto de tools × registro", () => { + it("o registro não está vazio (senão o teste passaria por omissão)", () => { + expect(toolsRegistradas().length).toBeGreaterThan(0); + }); + + for (const manifesto of MANIFESTOS) { + it(`${manifesto}: anuncia exatamente as tools registradas`, () => { + const fonte = ler(manifesto); + const registradas = toolsRegistradas(); + + // Anunciada = aparece como item de lista do manifesto: "- nomeDaTool(". + const anunciadas = registradas.filter((t) => fonte.includes(`- ${t}(`)); + + const faltando = registradas.filter((t) => !anunciadas.includes(t)); + expect(faltando, "tool registrada que o modelo não sabe que tem").toEqual([]); + + // O outro lado: nada de anunciar tool que não existe mais. Varre os + // itens do manifesto e confere que cada um está no registro. + const itens = [...fonte.matchAll(/^- (\w+)\(/gm)].map((m) => m[1]); + const fantasmas = itens.filter((t) => !registradas.includes(t)); + expect(fantasmas, "tool anunciada que não está registrada").toEqual([]); + }); + } +}); diff --git a/src/lib/auth/project-access.ts b/src/lib/auth/project-access.ts index efe85c10..702301dd 100644 --- a/src/lib/auth/project-access.ts +++ b/src/lib/auth/project-access.ts @@ -1,5 +1,5 @@ /** - * Pode este usuário operar ESTE projeto? + * Pode este usuário ACESSAR (ler) ESTE projeto? * * Owner/admin (PRIVILEGED_ROLES) acessam qualquer projeto — têm rota `['*']` e * não necessariamente têm linha em `project_members`. Os demais (consultor, @@ -11,7 +11,7 @@ */ import type { SupabaseClient } from "@supabase/supabase-js"; -import { PRIVILEGED_ROLES, PROJECT_OPERATOR_ROLES } from "@/lib/constants/user-roles"; +import { PRIVILEGED_ROLES } from "@/lib/constants/user-roles"; import { resolveRole } from "@/lib/auth/landing"; export async function canAccessProject( @@ -33,31 +33,28 @@ export async function canAccessProject( } /** - * Pode este usuário OPERAR (escrever/aprovar) ESTE projeto como operador interno? + * Pode este usuário ESCREVER neste projeto? * - * Diferente de `canAccessProject` (que é só LEITURA — cliente-membro passa): - * aqui o **cliente nunca opera**. Só `PROJECT_OPERATOR_ROLES` - * (owner/admin/consultor). Owner/admin operam qualquer projeto; consultor - * precisa ser membro. Use em escritas sensíveis (gerar/editar/aprovar o Plano - * de Voo, travar a meta) onde a checagem de membership crua deixaria um - * cliente-membro editar a própria meta. + * **SaaS sem consultor** (decisão 18/ago/2026, `docs/atros-v3/saas-sem-consultor.md`): + * a autoridade vem de ser MEMBRO do projeto, não do degrau na hierarquia global. + * O cliente é o dono e opera o próprio projeto; o consultor entra por convite do + * cliente e tem exatamente o mesmo poder — nem mais, nem menos. Owner/admin + * operam qualquer projeto (plano de plataforma). + * + * Isto INVERTE o modelo anterior, em que `PROJECT_OPERATOR_ROLES` barrava o + * cliente ("cliente/sdr nunca operam") e o consultor era etapa obrigatória do + * fluxo. Sob SaaS não existe esse degrau: quem trava a própria meta é o dono. + * + * Por isso ler e escrever passaram a ter a MESMA regra de autorização, e esta + * função delega. O nome se mantém nos pontos de escrita porque o que separa + * escrita de leitura hoje **não é papel, é prontidão do dado**: o gate de + * qualidade (12 meses da janela fechados) mora em `plano-voo-gate.ts` e é + * aplicado no ponto de escrita — não aqui. */ export async function canOperateProject( supabase: SupabaseClient, userId: string, projectId: string, ): Promise { - const role = await resolveRole(supabase, userId); - if (!role || !PROJECT_OPERATOR_ROLES.includes(role)) return false; // cliente/sdr nunca operam - if (PRIVILEGED_ROLES.includes(role)) return true; // owner/admin: qualquer projeto - - // consultor: precisa ser membro do projeto (a RLS reforça no banco) - const { data: membership } = await supabase - .from("project_members") - .select("user_id") - .eq("project_id", projectId) - .eq("user_id", userId) - .maybeSingle(); - - return !!membership; + return canAccessProject(supabase, userId, projectId); } diff --git a/src/lib/auto-conclusion-service.test.ts b/src/lib/auto-conclusion-service.test.ts deleted file mode 100644 index 7e0ad867..00000000 --- a/src/lib/auto-conclusion-service.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { readFileSync } from "node:fs"; -import { describe, it, expect } from "vitest"; - -/** - * Regressão B3 (D13-Q7) — auto-conclusion-service:323 grava - * `status: "calculado"` em `breakeven_analyses`, mas o CHECK constraint - * só aceita 'deficit'|'equilibrio'|'lucro'. Resultado: upsert do breakeven - * QUEBRA em todo projeto novo silenciosamente. - * - * O fix correto requer decidir QUAL status atribuir conforme o cálculo do - * breakeven (não é uma troca cega). Este teste verifica apenas que o - * valor inválido literal "calculado" foi removido do source. - * - * Ver `src/app/api/cron/health-score-snapshot/route.test.ts` pra contexto - * do padrão source-based. - */ - -const SOURCE_PATH = "src/lib/auto-conclusion-service.ts"; -const source = readFileSync(SOURCE_PATH, "utf-8"); - -describe("B3 — auto-conclusion-service", () => { - it("não grava status literal 'calculado' (valor inválido do CHECK)", () => { - // Match em `status: "calculado"` ou `status: 'calculado'` (com aspas). - // Comentários que mencionem o bug não fazem match porque a regex pede - // dois-pontos antes (sintaxe de propriedade de objeto). - expect(source).not.toMatch(/\bstatus\s*:\s*["']calculado["']/); - }); -}); diff --git a/src/lib/auto-conclusion-service.ts b/src/lib/auto-conclusion-service.ts deleted file mode 100644 index 6f36ec4c..00000000 --- a/src/lib/auto-conclusion-service.ts +++ /dev/null @@ -1,350 +0,0 @@ -/** - * Auto-Conclusion Service — Cascata de Conclusão do Módulo Financeiro - * - * Sprint 3: Após a DRE Gerencial ser concluída, este serviço: - * 1. Calcula e persiste os resultados das análises do Grupo A - * (Auditoria de Custos #2, Ponto de Equilíbrio #5, Política de Sócios #8, Fornecedores #10) - * 2. Marca essas análises como 'concluido' em module_analyses - * 3. Os triggers existentes (trg_analise_progresso) recalculam o progresso automaticamente - * 4. Invalida o cache do dossiê de IA (company_ai_dossiers) - * - * IMPORTANTE: Este serviço é event-driven (chamado no momento da conclusão da DRE), - * NÃO usa cron jobs. Compatível com Vercel Hobby. - * - * Fluxo: - * POST /api/projetos/[id]/financeiro/concluir { analysisKey: 'dre' } - * → concluirDREForense() valida critérios - * → autoConcluirGrupoA() dispara cascata - * → invalidarCacheDossie() limpa cache IA - */ - -import { createClient } from "@/lib/supabase/server"; - -// ============================================================ -// TYPES -// ============================================================ - -interface AutoConclusionResult { - success: boolean; - concluidas: string[]; // Nomes das análises concluídas - erros: string[]; // Erros não-bloqueantes -} - -interface ValidacaoDRE { - valida: boolean; - motivo?: string; - detalhes: { - temExtratos: boolean; - categorizacao100: boolean; - snapshotsMinimos: boolean; // ≥ 3 snapshots ativos (threshold preliminar) - snapshotsTotal: number; - }; -} - -// ============================================================ -// 1. VALIDAÇÃO DA DRE Gerencial -// ============================================================ - -/** - * Valida se a DRE Gerencial atende os critérios pra conclusão **automática** via - * endpoint legado `/concluir`. A partir do v3 a validação canônica vive em - * `dre-publishing-service` (snapshots mensais imutáveis). - * - * Critérios v3 (snapshot-based): - * - Existem transações importadas - * - 100% das transações estão categorizadas (categoria_id E subcategoria_id) - * - Mínimo de 3 snapshots mensais ativos (threshold preliminar) - * - * Os campos legacy `dreSalva` e `temParecer` continuam expostos em `detalhes` - * pra compat com o checklist da aba Parecer, mas não bloqueiam mais a conclusão. - */ -export async function validarCriteriosDRE(projectId: string): Promise { - const supabase = await createClient(); - - // Verifica transações, categorização (cat AND sub) e snapshots em paralelo - const [txTotal, txPendentes, snapshotsRows] = await Promise.all([ - supabase - .from("transactions") - .select("*", { count: "exact", head: true }) - .eq("project_id", projectId), - supabase - .from("transactions") - .select("*", { count: "exact", head: true }) - .eq("project_id", projectId) - .or("categoria_id.is.null,subcategoria_id.is.null"), - supabase - .from("dre_monthly_snapshots") - .select("mes_referencia") - .eq("project_id", projectId), - ]); - - const totalTx = txTotal.count || 0; - const pendentes = txPendentes.count || 0; - const temExtratos = totalTx > 0; - const categorizacao100 = temExtratos && pendentes === 0; - - // Conta meses únicos com snapshot — ignora versões múltiplas - const mesesSet = new Set( - (snapshotsRows.data || []).map(r => (r as { mes_referencia: string }).mes_referencia) - ); - const snapshotsTotal = mesesSet.size; - const snapshotsMinimos = snapshotsTotal >= 3; - - const detalhes = { - temExtratos, - categorizacao100, - snapshotsMinimos, - snapshotsTotal, - }; - - if (!temExtratos) { - return { valida: false, motivo: "Nenhum extrato importado. Importe extratos na DRE Gerencial.", detalhes }; - } - if (!categorizacao100) { - return { valida: false, motivo: `${pendentes} transações ainda sem categoria ou subcategoria. Categorize todas antes de concluir.`, detalhes }; - } - if (!snapshotsMinimos) { - return { - valida: false, - motivo: `Apenas ${snapshotsTotal} ${snapshotsTotal === 1 ? "mês fechado" : "meses fechados"} — feche pelo menos 3 meses para concluir a DRE.`, - detalhes, - }; - } - - return { valida: true, detalhes }; -} - -// ============================================================ -// 2. CONCLUSÃO DA DRE Gerencial -// ============================================================ - -/** - * Conclui a DRE Gerencial e dispara o cascateamento do Grupo A. - * Endpoint legado mantido pra compat com aba Parecer e callers antigos. - * - * v3: também grava `publish_status` (preliminar se 3-11 snapshots, consolidada - * se 12+) pra alinhar com o novo contrato de publicação. Idempotente: chamar - * múltiplas vezes não causa downgrade nem erro. - */ -export async function concluirDREForense( - projectId: string, - moduleId: string -): Promise<{ success: boolean; error?: string; cascade?: AutoConclusionResult }> { - const supabase = await createClient(); - - // 1. Valida critérios (snapshots-based) - const validacao = await validarCriteriosDRE(projectId); - if (!validacao.valida) { - return { success: false, error: validacao.motivo }; - } - - // 2. Determina nível de publicação a partir do número de snapshots - const snapshotsTotal = validacao.detalhes.snapshotsTotal; - const publishStatus = snapshotsTotal >= 12 ? "consolidada" : "preliminar"; - const legacyStatus = publishStatus === "consolidada" ? "concluido" : "em_analise"; - - // 3. Lê estado atual pra preservar campos quando já publicado em nível superior - const { data: current } = await supabase - .from("module_analyses") - .select("publish_status, published_at, published_by, published_threshold") - .eq("module_id", moduleId) - .eq("numero", 1) - .maybeSingle(); - - // Não rebaixar: se já é consolidada, não voltar pra preliminar - if (current?.publish_status === "consolidada" && publishStatus === "preliminar") { - // Já está num nível superior — apenas dispara cascata por garantia (idempotente) - const cascade = await autoConcluirGrupoA(projectId, moduleId); - return { success: true, cascade }; - } - - const nowIso = new Date().toISOString(); - const { error: updateError } = await supabase - .from("module_analyses") - .update({ - publish_status: publishStatus, - status: legacyStatus, - data_conclusao: publishStatus === "consolidada" ? nowIso.split("T")[0] : null, - published_at: nowIso, - published_by: current?.published_by ?? null, // preserva se já existia - published_threshold: snapshotsTotal, - }) - .eq("module_id", moduleId) - .eq("numero", 1); - - if (updateError) { - console.error("[AutoConclusion] Erro ao concluir DRE:", updateError); - return { success: false, error: updateError.message }; - } - - // 4. Dispara cascata do Grupo A (event-driven, sem cron) - const cascade = await autoConcluirGrupoA(projectId, moduleId); - - // 5. Invalida cache do dossiê de IA - await invalidarCacheDossie(projectId); - - return { success: true, cascade }; -} - -// ============================================================ -// 3. AUTO-CONCLUSÃO DO GRUPO A -// ============================================================ - -/** - * Grupo A: Análises que podem ser concluídas automaticamente - * após a DRE Gerencial, pois seus dados são derivados dela. - * - * #2 Auditoria de Custos → basta ter DRE com despesas categorizadas - * #4 Ciclo de Caixa → PMR/PMP/ciclo financeiro das transações (Bloco 1). - * PME (estoque) ainda ausente → resultado/parecer marcam ciclo PARCIAL. - * #5 Ponto de Equilíbrio → calculado pela RPC existente - * #8 Política de Sócios → detectado via padrões de transações - * #10 Fornecedores → calculado pelo HHI de transações - */ -async function autoConcluirGrupoA( - projectId: string, - moduleId: string -): Promise { - const supabase = await createClient(); - const concluidas: string[] = []; - const erros: string[] = []; - - // Verifica quais análises do Grupo A já não estão concluídas - const { data: analyses } = await supabase - .from("module_analyses") - .select("numero, nome, status") - .eq("module_id", moduleId) - .in("numero", [2, 4, 5, 8, 10]); - - const pendentes = (analyses || []).filter(a => a.status !== "concluido"); - if (pendentes.length === 0) { - return { success: true, concluidas: [], erros: [] }; - } - - // Conclui cada análise pendente do Grupo A - for (const analise of pendentes) { - try { - const { error } = await supabase - .from("module_analyses") - .update({ - status: "concluido", - data_conclusao: new Date().toISOString().split("T")[0], - parecer: gerarParecerAutomatico(analise.numero), - }) - .eq("module_id", moduleId) - .eq("numero", analise.numero); - - if (error) { - erros.push(`${analise.nome}: ${error.message}`); - } else { - concluidas.push(analise.nome); - } - } catch (err) { - erros.push(`${analise.nome}: ${err instanceof Error ? err.message : "Erro desconhecido"}`); - } - } - - // Persiste os resultados nas ghost tables (se os serviços existirem) - // Isso é fire-and-forget — não bloqueia a conclusão - try { - await persistirResultadosGrupoA(projectId); - } catch (err) { - erros.push(`Persistência em ghost tables: ${err instanceof Error ? err.message : "Erro"}`); - } - - console.log(`[AutoConclusion] Grupo A: ${concluidas.length} concluídas, ${erros.length} erros.`); - - return { success: true, concluidas, erros }; -} - -/** - * Gera um parecer padrão para as análises auto-concluídas. - * O parecer real será enriquecido pelo Consultor ATR quando solicitado. - */ -function gerarParecerAutomatico(numero: number): string { - const pareceres: Record = { - 2: "Auditoria de Custos concluída automaticamente com base na DRE Gerencial. Os percentuais de despesas fixas, variáveis e financeiras sobre a receita foram extraídos e comparados com benchmarks setoriais.", - 4: "Diagnóstico de Ciclo de Caixa concluído automaticamente. PMR, PMP e ciclo financeiro derivados das transações. ATENÇÃO: ciclo PARCIAL — o PME (prazo médio de estoque) ainda não é calculado (depende do Estoque Tier 1); o ciclo operacional pode estar subestimado.", - 5: "Ponto de Equilíbrio calculado automaticamente. Meta diária e prazos definidos com base na margem de contribuição e custos fixos identificados na DRE Gerencial.", - 8: "Análise de Remuneração de Sócios e Distribuição produzida automaticamente. Retiradas, pró-labore e possíveis commingling detectados nos extratos. Recomenda-se revisão pelo consultor.", - 10: "Análise de Fornecedores concluída automaticamente. Concentração de compras (HHI) e dependência do fornecedor principal calculados com base nas transações classificadas." - }; - return pareceres[numero] || "Análise concluída automaticamente após DRE Gerencial."; -} - -/** - * Persiste os resultados calculados nas ghost tables. - * Chama as RPCs do banco que já existem, ou calcula inline. - * Fire-and-forget — não bloqueia o fluxo principal. - */ -async function persistirResultadosGrupoA(projectId: string): Promise { - const supabase = await createClient(); - - // Usa a vw_financial_snapshot para buscar dados calculados - const { data: snap } = await supabase - .from("vw_financial_snapshot") - .select("*") - .eq("project_id", projectId) - .maybeSingle(); - - if (!snap) return; - - // Breakeven (#5) — persiste resultado se ainda não existir - if (snap.breakeven_dias === null || snap.breakeven_dias === undefined) { - const receita = Number(snap.receita_operacional) || 0; - const cmv = Math.abs(Number(snap.cmv_fornecedores) || 0); - const despVar = Math.abs(Number(snap.despesas_variaveis) || 0); - const despFixas = Math.abs(Number(snap.despesas_fixas) || 0); - const mc = receita - cmv - despVar; - const mcPct = receita > 0 ? mc / receita : 0; - const pe = mcPct > 0 ? despFixas / mcPct : 0; - const diasPE = receita > 0 ? Math.ceil((pe / receita) * 30) : 30; - - // B3 fix (D13-Q7) — status precisa ser um dos valores do CHECK - // (deficit | equilibrio | lucro). Antes era literal "calculado" que - // violava a constraint e quebrava o upsert silenciosamente em todo - // projeto novo. Faixa de tolerância 5% pra equilíbrio. - const ratio = pe > 0 ? receita / pe : 1; - const status: "deficit" | "equilibrio" | "lucro" = - ratio < 0.95 ? "deficit" - : ratio > 1.05 ? "lucro" - : "equilibrio"; - - await supabase.from("breakeven_analyses").upsert({ - project_id: projectId, - ponto_equilibrio: pe, - ponto_equilibrio_dias: diasPE, - margem_contribuicao: mc, - margem_contribuicao_percent: mcPct * 100, - meta_diaria: pe / 22, - status, - updated_at: new Date().toISOString(), - }, { onConflict: "project_id" }); - } - - // Os demais (Fornecedores, Sócios) são persistidos por seus services dedicados - // (supplier-analysis-service.ts, partner-policy-service.ts) - // e já possuem dados se a DRE foi concluída. -} - -// ============================================================ -// 4. INVALIDAÇÃO DE CACHE -// ============================================================ - -/** - * Invalida o cache do dossiê de IA para forçar recompilação na próxima consulta. - * Sem cron — é chamado no momento da conclusão da DRE. - */ -export async function invalidarCacheDossie(projectId: string): Promise { - const supabase = await createClient(); - - const { error } = await supabase - .from("company_ai_dossiers") - .delete() - .eq("project_id", projectId); - - if (error) { - console.warn("[AutoConclusion] Erro ao invalidar cache:", error.message); - // Não bloqueia — na pior hipótese, o dossiê será recompilado pelo TTL - } -} diff --git a/src/lib/company-context-service.ts b/src/lib/company-context-service.ts index 32c8429a..ddab74af 100644 --- a/src/lib/company-context-service.ts +++ b/src/lib/company-context-service.ts @@ -26,20 +26,13 @@ export interface CompanyContext { funcionarios_quantidade: number | null; regime_tributario: string | null; area_atuacao: string | null; - modules_status: ModuleStatus[] | null; + // D10: `modules_status`, `analyses_completed` e `analyses_total` saíram — + // a view ainda os expõe (`30 AS analyses_total` cravado em SQL), mas nada + // aqui os lê desde que o bloco PROGRESSO saiu do prompt. completed_analyses: CompletedAnalysis[] | null; - analyses_completed: number; - analyses_total: number; htr_history: HtrHistoryEntry[] | null; } -export interface ModuleStatus { - tipo: string; - status: string; - progresso: number; - ordem: number; -} - export interface CompletedAnalysis { module: string; numero: number; @@ -163,12 +156,12 @@ export function formatContextForPrompt(ctx: AiPromptContext): string { output += `\n- Margem Líquida: ${financial.margem_liquida}%`; } - output += `\n\nPROGRESSO (${company.analyses_completed}/${company.analyses_total} análises):`; - if (company.modules_status) { - for (const m of company.modules_status) { - output += `\n- ${m.tipo}: ${m.progresso}% (${m.status})`; - } - } + // D10 (Lucas, 2026-08-20): o bloco `PROGRESSO (X/30 análises)` saiu do + // prompt. Ele injetava o placar de um modelo morto — `analyses_total` é `30` + // cravado na view `vw_company_context`, e `analyses_completed` é 0 em todo + // projeto real. A IA lia "0/30" e concluía que a empresa não tinha dado, + // com 12 meses fechados logo acima no mesmo prompt. O que o sistema sabe da + // maturidade da empresa vem do SNAPSHOT FINANCEIRO e da fase do dossiê. if (gaps.length > 0) { output += `\n\nGAPS IDENTIFICADOS (${gaps.length}):`; diff --git a/src/lib/company-service.ts b/src/lib/company-service.ts index 2e5253e7..5011846f 100644 --- a/src/lib/company-service.ts +++ b/src/lib/company-service.ts @@ -1,5 +1,6 @@ import { createClient } from "@/lib/supabase/client"; import type { SupabaseClient } from "@supabase/supabase-js"; +import { sincronizarSociosDoCliente } from "@/lib/partes-relacionadas/sincronizar-socios"; // ========================================== // Frente C — Multi-CNPJ @@ -69,6 +70,37 @@ export class CompanyService { this.supabase = supabaseClient || createClient(); } + /** + * Deriva os sócios do QSA recém-gravado para `client_socios` — a tabela que + * o detector de partes relacionadas lê. + * + * ── Por que AQUI, e não na tela ───────────────────────────────────────── + * Existem QUATRO telas que enriquecem uma empresa pela BrasilAPI + * (`ClienteSheet`, `CadastrarEmpresaModal`, `CompanyForm`, + * `ConvertToClientModal`). Ligar o sync em cada uma seria garantir que a + * quinta, criada daqui a três meses, nasça sem ele. `criar` e `atualizar` + * são o choke point por onde TODAS passam. + * + * ── E por que não no caminho de leitura ───────────────────────────────── + * A tentação era sincronizar em `carregarCadastroDoProjeto` (o leitor + * único). Não dá: a policy `client_socios_insert` exige role + * `owner|admin|consultor`, e o leitor roda também na ingestão feita pelo + * CLIENTE — o insert falharia calado justamente em quem mais precisa. + * Aqui quem escreve é sempre consultor+, que é quem tem a permissão. + * + * ── Não-bloqueante de propósito ───────────────────────────────────────── + * Cadastrar empresa não pode falhar porque o QSA veio torto. Mesmo padrão + * da âncora de saldo em `confirmarImportacao`: loga e segue. + */ + private async sincronizarSocios(clientId: string | null | undefined): Promise { + if (!clientId) return; + try { + await sincronizarSociosDoCliente(this.supabase, clientId); + } catch (err) { + console.warn("[CompanyService] sync de sócios não-bloqueante falhou:", err); + } + } + async listarPorCliente(clientId: string): Promise<{ data: Company[] | null; error: Error | null }> { const { data, error } = await this.supabase .from("companies") @@ -114,6 +146,12 @@ export class CompanyService { .select() .single(); + // Só quando o QSA veio junto — cadastro manual sem enriquecimento não + // tem o que derivar, e chamar à toa seria uma ida ao banco por nada. + if (!error && input.dados_receita) { + await this.sincronizarSocios((data as Company | null)?.client_id ?? input.client_id); + } + return { data: data as Company | null, error: error ? new Error(error.message) : null }; } @@ -125,6 +163,13 @@ export class CompanyService { .select() .single(); + // Condicionado a `dados_receita` no payload: `setPrincipal`, `inativar` e + // `reativar` também passam por aqui e não mexem no QSA — sincronizar + // neles seria trabalho invisível a cada clique. + if (!error && updates.dados_receita) { + await this.sincronizarSocios((data as Company | null)?.client_id); + } + return { data: data as Company | null, error: error ? new Error(error.message) : null }; } diff --git a/src/lib/constants/financial-categories.ts b/src/lib/constants/financial-categories.ts index 5a6d53e3..783c1449 100644 --- a/src/lib/constants/financial-categories.ts +++ b/src/lib/constants/financial-categories.ts @@ -16,107 +16,9 @@ export const FINANCIAL_CATEGORIES = { DEDUCOES_RECEITA: "Deduções da Receita", } as const; -export type FinancialCategory = typeof FINANCIAL_CATEGORIES[keyof typeof FINANCIAL_CATEGORIES]; - -export const FINANCIAL_SUBCATEGORIES = { - // Receita Operacional - VENDAS_DINHEIRO: "Vendas em Dinheiro", - VENDAS_DEBITO: "Vendas Cartão (Débito)", - VENDAS_CREDITO: "Vendas Cartão (Crédito)", - VENDAS_OUTROS: "Vendas Cartão (Outros)", - VENDAS_PIX: "Vendas Pix (QR Code/Chave)", - VENDAS_CHEQUE: "Vendas em Cheque", - - // CMV - COMPRA_MERCADORIAS: "Compra de Mercadorias", - - // Despesas Financeiras - TARIFAS_BANCARIAS: "Tarifas Bancárias", - JUROS_AMORTIZACAO: "Juros/Amortização Empréstimos", - IOF_IMPOSTOS: "IOF e Impostos Financeiros", - ANTECIPACAO: "Antecipação de Recebíveis", - ALUGUEL_MAQUINAS: "Aluguel/Taxas de Máquinas Cartão", - - // Movimentação Não Operacional - APORTE_SOCIOS: "Aporte dos Sócios", - RETIRADA_SOCIOS_PESSOAL: "Retirada de Sócios (Pessoal)", - RETIRADA_SOCIOS_ADIANTAMENTOS: "Retirada de Sócios / Adiantamentos", - INVESTIMENTOS: "Investimentos/Consórcios", - PAGAMENTO_FATURA: "Pagamento Fatura Cartão", - - // Receita Financeira - RENDIMENTOS: "Rendimentos de Aplicação", - - // Despesas Fixas - SISTEMAS_SOFTWARE: "Sistemas e Software", - TELEFONIA_INTERNET: "Telefonia e Internet", - SALARIOS: "Salários e Ordenados", - SEGUROS: "Seguros", - UTILIDADES: "Utilidades (Água/Luz)", - CONTABILIDADE: "Serviços Contábeis", - TAXAS_LICENCAS: "Taxas e Licenças", - SERVICOS_TERCEIROS: "Serviços de Terceiros", - ALUGUEL_IMOVEIS: "Aluguel de Imóveis", - JURIDICO: "Serviços Notariais e Jurídicos", - - // Despesas Variáveis - PEQUENO_PORTE: "Despesas de Pequeno Porte / Viagens", - VEICULOS: "Combustível e Manutenção Veículos", - MARKETING: "Marketing e Publicidade", - - // Deduções - IMPOSTOS_VENDA: "Impostos sobre Venda/Nota", - DEVOLUCOES: "Devoluções e Estornos", -} as const; - -export const CATEGORY_MAP = { - [FINANCIAL_CATEGORIES.RECEITA_OPERACIONAL]: [ - FINANCIAL_SUBCATEGORIES.VENDAS_DINHEIRO, - FINANCIAL_SUBCATEGORIES.VENDAS_DEBITO, - FINANCIAL_SUBCATEGORIES.VENDAS_CREDITO, - FINANCIAL_SUBCATEGORIES.VENDAS_OUTROS, - FINANCIAL_SUBCATEGORIES.VENDAS_PIX, - FINANCIAL_SUBCATEGORIES.VENDAS_CHEQUE, - ], - [FINANCIAL_CATEGORIES.CMV_FORNECEDORES]: [ - FINANCIAL_SUBCATEGORIES.COMPRA_MERCADORIAS, - ], - [FINANCIAL_CATEGORIES.DESPESAS_FINANCEIRAS]: [ - FINANCIAL_SUBCATEGORIES.TARIFAS_BANCARIAS, - FINANCIAL_SUBCATEGORIES.JUROS_AMORTIZACAO, - FINANCIAL_SUBCATEGORIES.IOF_IMPOSTOS, - FINANCIAL_SUBCATEGORIES.ANTECIPACAO, - FINANCIAL_SUBCATEGORIES.ALUGUEL_MAQUINAS, - ], - [FINANCIAL_CATEGORIES.MOVIMENTACAO_NAO_OPERACIONAL]: [ - FINANCIAL_SUBCATEGORIES.APORTE_SOCIOS, - FINANCIAL_SUBCATEGORIES.RETIRADA_SOCIOS_PESSOAL, - FINANCIAL_SUBCATEGORIES.RETIRADA_SOCIOS_ADIANTAMENTOS, - FINANCIAL_SUBCATEGORIES.INVESTIMENTOS, - FINANCIAL_SUBCATEGORIES.PAGAMENTO_FATURA, - ], - [FINANCIAL_CATEGORIES.RECEITA_FINANCEIRA]: [ - FINANCIAL_SUBCATEGORIES.RENDIMENTOS, - ], - [FINANCIAL_CATEGORIES.DESPESAS_FIXAS]: [ - FINANCIAL_SUBCATEGORIES.SISTEMAS_SOFTWARE, - FINANCIAL_SUBCATEGORIES.TELEFONIA_INTERNET, - FINANCIAL_SUBCATEGORIES.SALARIOS, - FINANCIAL_SUBCATEGORIES.SEGUROS, - FINANCIAL_SUBCATEGORIES.UTILIDADES, - FINANCIAL_SUBCATEGORIES.CONTABILIDADE, - FINANCIAL_SUBCATEGORIES.TAXAS_LICENCAS, - FINANCIAL_SUBCATEGORIES.SERVICOS_TERCEIROS, - FINANCIAL_SUBCATEGORIES.ALUGUEL_IMOVEIS, - FINANCIAL_SUBCATEGORIES.JURIDICO, - ], - [FINANCIAL_CATEGORIES.DESPESAS_VARIAVEIS]: [ - FINANCIAL_SUBCATEGORIES.PEQUENO_PORTE, - FINANCIAL_SUBCATEGORIES.VEICULOS, - FINANCIAL_SUBCATEGORIES.MARKETING, - ], - [FINANCIAL_CATEGORIES.DEDUCOES_RECEITA]: [ - FINANCIAL_SUBCATEGORIES.IMPOSTOS_VENDA, - FINANCIAL_SUBCATEGORIES.DEVOLUCOES, - ], -}; +// FINANCIAL_SUBCATEGORIES, CATEGORY_MAP e o tipo FinancialCategory foram +// removidos em 2026-08-18 (G-08). Existiam só para alimentar o prompt do +// `ai-categorizer.ts` — a categorização por inferência de IA, aposentada por +// decisão do Lucas em 17/ago: a categorização do ATR OS é determinística. +// O catálogo vivo é o v3 (`dre_categories` / `dre_subcategories` no banco), +// não esta lista. Histórico no git. diff --git a/src/lib/consultor-readiness.test.ts b/src/lib/consultor-readiness.test.ts index ab8a528e..dbb0fe15 100644 --- a/src/lib/consultor-readiness.test.ts +++ b/src/lib/consultor-readiness.test.ts @@ -1,51 +1,67 @@ /** * Testes do gatilho "Modo Consultor" (fin-06 A-06.2 / opção A maturidade). * - * Critério atual: só maturidade de DRE fechada (≥MIN_SNAPSHOTS). Folha NÃO é - * gate (decisão 2026-06-05, `modelo-meta-pacing.md` §7 — revoga D-3). A - * degradação explicável (motivo qualitativo, plural correto) é o que justifica - * a escolha sobre B "sempre Consultor". + * Critério atual: só maturidade de DRE fechada (janela de 12 meses coberta). + * Folha NÃO é gate (decisão 2026-06-05, `modelo-meta-pacing.md` §7 — revoga + * D-3). A degradação explicável (motivo qualitativo, plural correto) é o que + * justifica a escolha sobre B "sempre Consultor". + * + * Desde 2026-08-20 a entrada é a LISTA dos meses que faltam, não a contagem — + * o sistema precisa poder COBRAR ("faltam mar/2026"), não só trancar. */ import { describe, it, expect } from "vitest"; import { consultorPronto, MIN_SNAPSHOTS } from "@/lib/consultor-readiness"; +/** N chaves `YYYY-MM` plausíveis, para simular meses em aberto. */ +const faltando = (n: number): string[] => + Array.from({ length: n }, (_, i) => `2026-${String(i + 1).padStart(2, "0")}`); + describe("consultorPronto", () => { - it("pronto = true quando ≥MIN_SNAPSHOTS (folha não importa)", () => { - const r = consultorPronto({ snapshotsAtivos: MIN_SNAPSHOTS }); + it("pronto = true quando nada falta (folha não importa)", () => { + const r = consultorPronto({ mesesFaltantes: [] }); expect(r.pronto).toBe(true); expect(r.motivo).toBeNull(); + expect(r.mesesFaltantes).toEqual([]); }); - it("acima do mínimo também conta", () => { - expect(consultorPronto({ snapshotsAtivos: MIN_SNAPSHOTS + 5 }).pronto).toBe(true); - }); - - it("folha NÃO bloqueia: ≥MIN_SNAPSHOTS sem folha continua pronto", () => { + it("folha NÃO bloqueia: janela coberta sem folha continua pronto", () => { // Antes exigia folha; agora não. (Vertímetal: 22 meses, 0 funcionários.) - const r = consultorPronto({ snapshotsAtivos: MIN_SNAPSHOTS }); - expect(r.pronto).toBe(true); + expect(consultorPronto({ mesesFaltantes: [] }).pronto).toBe(true); }); - it("faltam snapshots — plural correto", () => { - const r2 = consultorPronto({ snapshotsAtivos: MIN_SNAPSHOTS - 2 }); + it("faltam meses — plural correto", () => { + const r2 = consultorPronto({ mesesFaltantes: faltando(2) }); expect(r2.pronto).toBe(false); expect(r2.motivo).toMatch(/Faltam 2 meses fechados/); - const r1 = consultorPronto({ snapshotsAtivos: MIN_SNAPSHOTS - 1 }); + const r1 = consultorPronto({ mesesFaltantes: faltando(1) }); expect(r1.pronto).toBe(false); expect(r1.motivo).toMatch(/Falta 1 mês fechado/); }); - it("zero snapshots → bloqueado, mas sem mencionar folha", () => { - const r = consultorPronto({ snapshotsAtivos: 0 }); + it("a janela inteira em aberto → bloqueado, sem mencionar folha", () => { + const r = consultorPronto({ mesesFaltantes: faltando(MIN_SNAPSHOTS) }); expect(r.pronto).toBe(false); expect(r.motivo).toMatch(/meses fechados/); expect(r.motivo).not.toMatch(/folha/i); }); + it("devolve QUAIS meses faltam, em ordem — é o que a tela cobra", () => { + const abertos = ["2025-09", "2025-12", "2026-03"]; + const r = consultorPronto({ mesesFaltantes: abertos }); + expect(r.mesesFaltantes).toEqual(abertos); + }); + + it("a lista devolvida é cópia — mutar a saída não corrompe a entrada", () => { + const abertos = ["2025-09", "2025-12"]; + const r = consultorPronto({ mesesFaltantes: abertos }); + r.mesesFaltantes.push("2030-01"); + expect(abertos).toEqual(["2025-09", "2025-12"]); + }); + it("anti-falsa-precisão: motivo é qualitativo, sem R$/datas inventadas", () => { - const r = consultorPronto({ snapshotsAtivos: 0 }); + const r = consultorPronto({ mesesFaltantes: faltando(MIN_SNAPSHOTS) }); expect(r.motivo).not.toMatch(/R\$/); expect(r.motivo).not.toMatch(/\d{4}-\d{2}-\d{2}/); }); diff --git a/src/lib/consultor-readiness.ts b/src/lib/consultor-readiness.ts index a571b0fe..209a549a 100644 --- a/src/lib/consultor-readiness.ts +++ b/src/lib/consultor-readiness.ts @@ -37,14 +37,20 @@ * Re-exporta `MESES_JANELA` para manter UM número só no sistema. */ export { MESES_JANELA as MIN_SNAPSHOTS } from "@/lib/plano-voo/janela-12m"; -import { MESES_JANELA } from "@/lib/plano-voo/janela-12m"; export interface ReadinessInputs { /** - * Quantos meses DA JANELA (12 meses antes da entrada) têm snapshot ativo. - * NÃO é o total de snapshots do projeto — ver `cobertura()` em `janela-12m`. + * QUAIS meses DA JANELA (12 meses antes da entrada) ainda não fecharam, + * do mais antigo para o mais recente — a saída de `mesesFaltantes()` em + * `janela-12m`. Vazio = janela completa. + * + * Recebe a LISTA e não a contagem de propósito: com a contagem, o sistema + * só sabia trancar ("faltam 2 meses"); com a lista ele consegue cobrar + * ("faltam mar/2026 e dez/2025"), que é o passo 5 da jornada. E é campo + * obrigatório, não opcional — opcional faria um caller esquecido apagar a + * cobrança em silêncio. */ - snapshotsAtivos: number; + mesesFaltantes: readonly string[]; } export interface Readiness { @@ -52,6 +58,16 @@ export interface Readiness { pronto: boolean; /** Quando `!pronto`: o que falta, em linguagem de cliente. `null` quando pronto. */ motivo: string | null; + /** + * Os meses que faltam fechar (`YYYY-MM`, do mais antigo ao mais recente). + * Vazio quando `pronto`. O `motivo` é o texto curto do cadeado; esta lista + * é o que a tela usa para NOMEAR os meses e levar a cliente até cada um. + * + * ATENÇÃO: `!pronto` com lista VAZIA não é contradição — é bloqueio que não + * é sobre apuração (consolidado do grupo, projeto sem data de início). A + * tela decide por `pronto` primeiro; só então usa a lista. + */ + mesesFaltantes: string[]; } /** @@ -59,12 +75,13 @@ export interface Readiness { * `motivo` sempre não-nulo quando `pronto=false` (UX de degradação). */ export function consultorPronto(i: ReadinessInputs): Readiness { - if (i.snapshotsAtivos < MESES_JANELA) { - const faltam = MESES_JANELA - i.snapshotsAtivos; + const faltam = i.mesesFaltantes.length; + if (faltam > 0) { return { pronto: false, motivo: `Falta${faltam === 1 ? "" : "m"} ${faltam} ${faltam === 1 ? "mês fechado" : "meses fechados"} dos últimos 12 para gerar o Plano de Voo.`, + mesesFaltantes: [...i.mesesFaltantes], }; } - return { pronto: true, motivo: null }; + return { pronto: true, motivo: null, mesesFaltantes: [] }; } diff --git a/src/lib/context-compiler-service.ts b/src/lib/context-compiler-service.ts index f01e1870..adce977e 100644 --- a/src/lib/context-compiler-service.ts +++ b/src/lib/context-compiler-service.ts @@ -3,9 +3,9 @@ import { getBenchmark } from "@/lib/benchmarks"; import { extractFinancialGaps } from "@/lib/financial-gaps-extractor"; import { extractGapsFromAnalyses } from "@/lib/qualitative-gaps-extractor"; import { recalcularAnalises, type MotorSaida } from "@/lib/analysis-engine/engine"; -import { isFeatureEnabled, FeatureFlag, getModulosAtivos, isModuloAtivo } from "@/lib/feature-flags"; +import { isFeatureEnabled, FeatureFlag, isModuloAtivo } from "@/lib/feature-flags"; import { consultorPronto, type Readiness } from "@/lib/consultor-readiness"; -import { cobertura, janelaPorDataDeEntrada } from "@/lib/plano-voo/janela-12m"; +import { cobertura, mesesFaltantes, janelaPorDataDeEntrada } from "@/lib/plano-voo/janela-12m"; import type { CompanyContext, FinancialSnapshot, @@ -145,7 +145,6 @@ export interface HTRSummary { export interface CompanyDossier { project_id: string; phase: "onboarding" | "diagnostico" | "plano" | "execucao" | "revisao"; - analyses_completed: number; client_profile: any; health_score: any | null; financial: FinancialContext | null; @@ -157,11 +156,6 @@ export interface CompanyDossier { business_profile: BusinessProfile | null; products_services: ProductServiceItem[]; htr_summary: HTRSummary | null; - module_progress: { - financeiro: number; - comercial: number; - gestao: number; - }; // ── C2 débito resolvido (F3): campos para alimentar buildAiPromptContext // legado (shape idêntico ao antigo). Todos os 7 callers server-side passam // pelo cache do dossier (company_ai_dossiers) sem refazer queries. @@ -775,33 +769,14 @@ export async function compileDossier(projectId: string): Promise { - const tipo = d.project_modules?.tipo; - if (tipo === 'financeiro') finCount++; - else if (tipo === 'comercial') comCount++; - else if (tipo === 'gestao') gesCount++; - }); - - const module_progress = { - financeiro: Math.min(100, Math.round((finCount / 10) * 100)), - comercial: Math.min(100, Math.round((comCount / 10) * 100)), - gestao: Math.min(100, Math.round((gesCount / 10) * 100)), - }; + // D10 (Lucas, 2026-08-20): a contagem de análises SAIU do dossiê. + // Ela alimentava dois campos — `analyses_completed` e `module_progress` — + // que eram ESCRITOS e nunca lidos por ninguém: uma ida ao banco por compile + // de dossiê para preencher peso morto. A fase agora vem da janela de 12 + // meses (bloco D10 abaixo) e o modo Consultor vem de `consultor_readiness`. + // Os gaps qualitativos de comercial/gestão continuam vindo de + // `company_context_full.completed_analyses` — aqueles NÃO são fantasma: + // são feature atrás da flag MODULOS_EXTRAS, e voltam a valer se ela ligar. // ── Sprint 2: contexto financeiro via RPC (1 call = tudo) ──────────────── // Tenta usar compile_financial_context() do banco. @@ -923,17 +898,9 @@ export async function compileDossier(projectId: string): Promise= totalAnalysesExpected && !plan) phase = "plano"; - else if (plan && plan.semana_atual <= 13) phase = "execucao"; - else phase = "revisao"; + // A FASE não mora mais aqui — ela passou a depender da cobertura da janela + // de 12 meses, calculada no bloco de readiness logo abaixo. Ver o bloco + // "D10" depois do `consultor_readiness`. // ── fin-05 FASE 5.1: motor por evento (plug no recompile event-driven) ──── // O fabric stale→recompile já existe (9 triggers `trg_*_mark_dossier_stale` @@ -971,6 +938,10 @@ export async function compileDossier(projectId: string): Promise r.mes_referencia, + ); + mesesFechadosNaJanela = cobertura(janela, fechados); consultor_readiness = consultorPronto({ - snapshotsAtivos: cobertura( - janela, - ((snaps ?? []) as { mes_referencia: string }[]).map((r) => r.mes_referencia), - ), + mesesFaltantes: mesesFaltantes(janela, fechados), }); } } catch (e) { @@ -1012,8 +986,37 @@ export async function compileDossier(projectId: string): Promise { expect(derivarAntecipacaoDoLiquido(0, 0, 0)).toMatchObject({ desagio: 0, taxaPercent: 0, custoEfetivoAnual: 0 }); }); }); + +// ═══════════════════════════════════════════════════════════════════════════ +// E3-B — o switch da antecipação: "sei a taxa que pago" +// ═══════════════════════════════════════════════════════════════════════════ + +describe("E3-B — derivarFaceDaTaxa", () => { + it("reproduz a conta da spec: 100k, 2% a.m., 45 dias", () => { + const d = derivarFaceDaTaxa(100_000, 2, 45); + // t = 2% × 45/30 = 3% ⇒ face = 100.000 / 0,97 + expect(d.face).toBe(103_092.78); + expect(d.desagio).toBe(3_092.78); + expect(d.taxaPeriodoPercent).toBe(3); + expect(d.custoEfetivoAnual).toBeCloseTo(27.6, 0); + expect(d.valida).toBe(true); + }); + + it("a taxa incide sobre a FACE, não sobre o líquido — o erro que o forward comete", () => { + const d = derivarFaceDaTaxa(100_000, 2, 45); + // O caminho errado (forward sobre o líquido) daria deságio = 100.000 × 3% = + // 3.000,00 e líquido resolvido 97.000 — que NÃO é o que entrou no extrato. + // Falha para menos, e em silêncio: é a armadilha registrada no E3-B. + expect(d.desagio).not.toBe(3_000); + expect(d.desagio).toBeGreaterThan(3_000); + }); + + it("fecha a identidade ao centavo: líquido + deságio === face", () => { + // Valores quebrados de propósito — é onde o arredondamento costuma vazar. + for (const [liq, tx, dias] of [[87_431.19, 3.7, 23], [1_234.56, 1.15, 61], [999_999.99, 0.9, 7]]) { + const d = derivarFaceDaTaxa(liq, tx, dias); + expect(d.valida).toBe(true); + expect(Math.round((liq + d.desagio) * 100) / 100).toBe(d.face); + } + }); + + it("recusa taxa/prazo impossíveis em vez de estimar um número inauditável", () => { + expect(derivarFaceDaTaxa(100_000, 0, 45).valida).toBe(false); // nada declarado + expect(derivarFaceDaTaxa(100_000, 20, 180).valida).toBe(false); // t = 120% + expect(derivarFaceDaTaxa(100_000, 100, 30).valida).toBe(false); // t = 100% + }); +}); + +describe("E3-B — resolverAntecipacaoPorTaxaDeclarada (os 3 invariantes)", () => { + const taxa = { taxa_ao_mes: 2, prazo_medio_dias: 45, tipo_antecipacao: "desconto_duplicata" }; + const linhas = [ + { transaction_id: "t1", valor_bruto_real: 100_000 }, + { transaction_id: "t2", valor_bruto_real: 37_500.55 }, + { transaction_id: "t3", valor_bruto_real: 8_120.10 }, + ]; + + it("invariante 1 — o líquido NUNCA é recalculado: é o crédito do extrato", () => { + const r = resolverAntecipacaoPorTaxaDeclarada(linhas, taxa); + expect(r.linhas_resolvidas.map(l => l.liquido)).toEqual([100_000, 37_500.55, 8_120.10]); + expect(r.totais.liquido).toBe(145_620.65); + }); + + it("invariante 2 — cada linha deriva a PRÓPRIA face pelo PRÓPRIO crédito", () => { + const r = resolverAntecipacaoPorTaxaDeclarada(linhas, taxa); + for (const l of r.linhas_resolvidas) { + const esperada = derivarFaceDaTaxa(l.liquido, 2, 45).face; + expect(l.valor_bruto).toBe(esperada); + } + // Corolário: rodar uma linha sozinha dá o mesmo resultado que dentro do + // lote — é por isso que agregado × detalhado fica irrelevante no modo taxa. + const sozinha = resolverAntecipacaoPorTaxaDeclarada([linhas[1]], taxa); + expect(sozinha.linhas_resolvidas[0]).toEqual(r.linhas_resolvidas[1]); + }); + + it("invariante 3 — tudo que sai daqui é rotulável como estimativa", () => { + const r = resolverAntecipacaoPorTaxaDeclarada(linhas, taxa); + expect(r.linhas_resolvidas.every(l => l.fonte === "estimada_taxa")).toBe(true); + expect(r.detalhadas).toBe(0); + expect(r.indefinidas).toBe(0); + }); + + it("os totais fecham: bruto = líquido + deságio", () => { + const r = resolverAntecipacaoPorTaxaDeclarada(linhas, taxa); + expect(round2(r.totais.liquido + r.totais.desagio)).toBe(r.totais.valor_bruto); + }); + + it("o efeito na DRE é exatamente o que entrou na conta (regime de caixa)", () => { + const r = resolverAntecipacaoPorTaxaDeclarada(linhas, taxa); + // A face bruta sobe como receita (L10) e o deságio como despesa financeira + // (L65). Se a identidade face − deságio = líquido não valesse, o resultado + // do mês passaria a divergir do caixa — inventando ou sumindo com dinheiro + // que o extrato não viu. É o que torna a face ESTIMADA segura de publicar. + const receitaL10 = r.totais.valor_bruto; + const despesaL65 = r.totais.desagio; + expect(round2(receitaL10 - despesaL65)).toBe(r.totais.liquido); + expect(r.totais.liquido).toBe(145_620.65); + }); + + it("a chave do resultado é a que a RPC e a dre-canonico leem", () => { + // `calculate_monthly_dre_v3` lê resultado->>'total_desagio' e 'total_bruto'; + // `dre-canonico` lê `det.ant.resultado`. Os dois são agnósticos ao modo — o + // que o modo taxa precisa garantir é o SHAPE, não uma segunda rota de DRE. + const r = resolverAntecipacaoPorTaxaDeclarada(linhas, taxa); + expect(Object.keys(r.totais).sort()).toEqual(["desagio", "liquido", "valor_bruto"]); + }); + + it("taxa impossível bloqueia o gate (indefinidas), não grava número inventado", () => { + const r = resolverAntecipacaoPorTaxaDeclarada(linhas, { ...taxa, taxa_ao_mes: 0 }); + expect(r.indefinidas).toBe(3); + expect(r.totais.desagio).toBe(0); + // Mesmo recusando, o líquido continua fiel ao extrato. + expect(r.totais.liquido).toBe(145_620.65); + }); +}); + +const round2 = (v: number) => Math.round((v + Number.EPSILON) * 100) / 100; diff --git a/src/lib/dre-detalhamento/antecipacao-multinivel.ts b/src/lib/dre-detalhamento/antecipacao-multinivel.ts index e6b5aacf..fc40f6cb 100644 --- a/src/lib/dre-detalhamento/antecipacao-multinivel.ts +++ b/src/lib/dre-detalhamento/antecipacao-multinivel.ts @@ -43,7 +43,26 @@ export interface NivelGeral { tipo_antecipacao: string; } -export type FonteDado = "detalhada" | "media_ponderada" | "media_geral" | "indefinido"; +/** + * E3-B — o outro lado do switch da antecipação: o dono NÃO tem o borderô, mas + * sabe a taxa que paga. Declara em **% ao mês** (como o banco cota) + o prazo, e + * o sistema deriva a face a partir do líquido que entrou no extrato. + */ +export interface TaxaDeclarada { + /** % AO MÊS (ex: 2 = 2% a.m.). Não é o % do período — a conversão é aqui. */ + taxa_ao_mes: number; + prazo_medio_dias: number; + tipo_antecipacao: string; +} + +export type FonteDado = + | "detalhada" + | "media_ponderada" + | "media_geral" + /** E3-B: face DERIVADA da taxa que o dono declarou — número estimado, não medido. + * A tela é obrigada a rotular como estimativa (invariante 3). */ + | "estimada_taxa" + | "indefinido"; export interface LinhaResolvida { transaction_id: string; @@ -145,6 +164,67 @@ export function derivarAntecipacaoDoLiquido( }; } +/** + * E3-B — DERIVAÇÃO INVERSA a partir da taxa declarada. + * + * O extrato credita o LÍQUIDO; a face está no borderô, que a maior parte do ICP + * não tem em mãos. Quando o dono só sabe a taxa que paga, a face se recupera: + * + * t_período = taxa_a.m. × prazo_dias / 30 (desconto comercial, como o banco cobra) + * face = líquido / (1 − t_período) + * deságio = face − líquido + * + * Exemplo: entrou R$ 100.000, 2% a.m., 45 dias → face R$ 103.092,78, + * deságio R$ 3.092,78, custo efetivo ~27,6% a.a. + * + * ───────────────────────────────────────────────────────────────────────────── + * POR QUE ISTO NÃO É `decomporLinha` COM OUTRO NOME — a armadilha do E3-B + * + * O caminho "cliente informa a taxa" já existia no motor (Nível 1, `nivelGeral`) + * e estava morto. Religá-lo como estava erra **em silêncio e para menos**: sem + * face informada, `valorBrutoDe` cai no `valor_bruto_real`, que é o LÍQUIDO do + * extrato. Aí `decomporLinha` faz `deságio = líquido × taxa` (base errada) e + * `líquido resolvido = líquido − deságio` — que não bate com o extrato. + * + * A taxa incide sobre a FACE, não sobre o líquido. Por isso a divisão por + * `(1 − t)`, e por isso esta função existe separada: o caminho da taxa nunca + * passa por `valorBrutoDe`. + * + * O arredondamento fecha pelo deságio, não pela face — assim + * `líquido + deságio === face` ao centavo e o líquido do extrato fica INTACTO + * (invariante 1). + */ +export function derivarFaceDaTaxa( + liquido: number, + taxaAoMes: number, + prazoDias: number, +): { face: number; desagio: number; taxaPeriodoPercent: number; custoEfetivoAnual: number; valida: boolean } { + const liq = round2(Math.max(0, liquido)); + const dias = Math.max(1, prazoDias); + const tPeriodo = (Math.max(0, taxaAoMes) / 100) * (dias / 30); + + // t ≥ 1 ⇒ o deságio comeria 100% da face: taxa/prazo impossíveis. t ≤ 0 ⇒ o + // dono não declarou nada. Nos dois casos não há número a estimar — devolve + // inválido em vez de um valor plausível que ninguém conseguiria auditar. + if (tPeriodo <= 0 || tPeriodo >= 1) { + return { face: liq, desagio: 0, taxaPeriodoPercent: 0, custoEfetivoAnual: 0, valida: false }; + } + + const desagio = round2(liq / (1 - tPeriodo) - liq); + const face = round2(liq + desagio); + const custoEfetivoAnual = liq > EPS + ? round2((Math.pow(1 + desagio / liq, 360 / dias) - 1) * 100) + : 0; + + return { + face, + desagio, + taxaPeriodoPercent: round2(tPeriodo * 100), + custoEfetivoAnual, + valida: true, + }; +} + function decomporLinha( valorBruto: number, taxa: number, @@ -168,6 +248,93 @@ function decomporLinha( }; } +/** + * E3-B — resolve o mês no modo "sei a taxa que pago". + * + * Irmã de `resolverAntecipacaoMultinivel`, não um modo dela: o caminho da taxa + * declarada **nunca chama `valorBrutoDe`**, que é onde o líquido do extrato era + * confundido com a face. Manter as duas separadas faz a armadilha do E3-B ser + * impossível de reintroduzir por descuido, em vez de só documentada. + * + * Os 3 invariantes que este caminho carrega: + * + * 1. O LÍQUIDO nunca é recalculado — é o crédito do extrato, ao centavo. + * 2. Cada linha deriva a PRÓPRIA face pelo PRÓPRIO crédito (a taxa é do mês, a + * face não é rateada). Por isso agregado × detalhado fica irrelevante aqui. + * 3. Tudo que sai daqui é `fonte: "estimada_taxa"` — número estimado, e a tela + * é obrigada a dizer isso. + * + * @param linhas Transações de antecipação do mês. `valor_bruto_real` é o + * CRÉDITO do extrato (o líquido) — no modo taxa é a entrada, não + * um palpite de face. + * @param taxa O que o dono declarou no switch: % ao mês + prazo + tipo. + */ +export function resolverAntecipacaoPorTaxaDeclarada( + linhas: AntecipacaoLinha[], + taxa: TaxaDeclarada, +): ResolucaoAntecipacao { + const linhasResolvidas: LinhaResolvida[] = []; + let porMedia = 0; + let indefinidas = 0; + + for (const l of linhas) { + const liquido = round2(Math.max(0, l.valor_bruto_real)); + const d = derivarFaceDaTaxa(liquido, taxa.taxa_ao_mes, taxa.prazo_medio_dias); + + if (!d.valida) { + // Taxa/prazo impossíveis: não estima. Bloqueia o gate em vez de gravar + // um número que ninguém consegue auditar depois. + linhasResolvidas.push({ + transaction_id: l.transaction_id, + valor_bruto: liquido, + taxa: 0, + prazo_dias: Math.max(1, taxa.prazo_medio_dias), + tipo: taxa.tipo_antecipacao || "outro", + desagio: 0, + liquido, + custo_efetivo_anual: 0, + fonte: "indefinido", + }); + indefinidas += 1; + continue; + } + + linhasResolvidas.push({ + transaction_id: l.transaction_id, + valor_bruto: d.face, + // `taxa` no resolvido é sempre % da FACE (mesma unidade das outras + // fontes, que o snapshot e a tela já leem assim) — não o % ao mês que + // o dono digitou. A conversão mora em `derivarFaceDaTaxa`. + taxa: round2(d.face > EPS ? (d.desagio / d.face) * 100 : 0), + prazo_dias: Math.max(1, taxa.prazo_medio_dias), + tipo: taxa.tipo_antecipacao || "outro", + desagio: d.desagio, + liquido, // invariante 1: o extrato manda + custo_efetivo_anual: d.custoEfetivoAnual, + fonte: "estimada_taxa", + }); + porMedia += 1; + } + + return { + detalhadas: 0, + por_media: porMedia, + indefinidas, + media_aplicada: { + taxa: round2(taxa.taxa_ao_mes), + prazo: Math.max(1, taxa.prazo_medio_dias), + tipo: taxa.tipo_antecipacao || "outro", + base: 0, + }, + linhas_resolvidas: linhasResolvidas, + totais: { + valor_bruto: round2(linhasResolvidas.reduce((acc, l) => acc + l.valor_bruto, 0)), + desagio: round2(linhasResolvidas.reduce((acc, l) => acc + l.desagio, 0)), + liquido: round2(linhasResolvidas.reduce((acc, l) => acc + l.liquido, 0)), + }, + }; +} + /** * Resolve as antecipações do mês aplicando o algoritmo de 3 níveis. * diff --git a/src/lib/dre-detalhamento/client.ts b/src/lib/dre-detalhamento/client.ts index 9f1dac0b..92956913 100644 --- a/src/lib/dre-detalhamento/client.ts +++ b/src/lib/dre-detalhamento/client.ts @@ -238,11 +238,13 @@ export async function aplicarLinksRetroativos( } /** Salva antecipação no modo "Detalhado por linha" — POST /antecipacao-linhas. - * Persiste linhas + resolve multinível (nível 2 ou nível 3 híbrido). */ + * Persiste linhas + resolve multinível (nível 2 ou nível 3 híbrido). + * E3-B: com `taxaDeclarada`, roda o modo "sei a taxa que pago" e `linhas` vai vazia. */ export async function salvarAntecipacaoLinhas( projectId: string, payload: { mes_referencia: string; + taxaDeclarada?: { taxa_ao_mes: number; prazo_medio_dias: number; tipo_antecipacao: string }; nivelGeral?: { valor_bruto: number; taxa_media: number; prazo_medio_dias: number; tipo_antecipacao: string }; linhas: Array<{ transaction_id: string; diff --git a/src/lib/dre-detalhamento/types.ts b/src/lib/dre-detalhamento/types.ts index 6d9ee038..d3ae1b9a 100644 --- a/src/lib/dre-detalhamento/types.ts +++ b/src/lib/dre-detalhamento/types.ts @@ -71,7 +71,9 @@ export interface DecomposicaoAntecipacao { /** Modo do detalhamento — agregado é o legado; `por_contrato`/`por_linha`/ * `hibrido` foram introduzidos na PR 5.2 (multi-contrato + antecipação multinível). * `detalhado` é resquício do plano antes da decomposição multi e fica vazio. */ -export type ModoDetalhamento = "agregado" | "detalhado" | "por_contrato" | "por_linha" | "hibrido"; +/** `por_taxa` (E3-B) = a face foi DERIVADA da taxa declarada pelo dono, não informada + * por ele. Número estimado — quem lê é obrigado a rotular como tal. */ +export type ModoDetalhamento = "agregado" | "detalhado" | "por_contrato" | "por_linha" | "hibrido" | "por_taxa"; /** Registro de `dre_detalhamento` (linha do banco). */ export interface DreDetalhamento { diff --git a/src/lib/dre-publishing-service.ts b/src/lib/dre-publishing-service.ts deleted file mode 100644 index db169e34..00000000 --- a/src/lib/dre-publishing-service.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * DRE Publishing Service — contrato de publicação da DRE para o resto do sistema. - * - * "Publicar DRE" = sinalizar que os dados financeiros estão prontos pra serem - * consumidos por Plano de Voo, IA Consultor, HTR e análises derivadas. Tem 3 - * níveis: rascunho (default, < 3 meses) → preliminar (3-11) → consolidada (12+). - * - * Decisão arquitetural: snapshot mensal (`dre_monthly_snapshots`) é a moeda - * canônica. `dre_results` legacy continua sendo gravado pela aba Parecer mas - * não é gate. Ver docs/atros-v3/decisions.md ADR-009/010. - * - * Fonte de verdade: RPC `get_dre_readiness(p_project_id)` no banco. - */ - -import { createClient } from "@/lib/supabase/client"; - -// ============================================================ -// TIPOS -// ============================================================ - -export type PublishStatus = "rascunho" | "preliminar" | "consolidada"; -export type ThresholdTarget = "preliminar" | "consolidada"; - -/** - * Estado canônico da DRE de um projeto. Retornado pelo RPC `get_dre_readiness`. - */ -export interface DreReadiness { - project_id: string; - publish_status: PublishStatus; - snapshots_total: number; - snapshots_consecutivos: number; - earliest_closed: string | null; // YYYY-MM - latest_closed: string | null; // YYYY-MM - anos_completos: number[]; - cobertura_atual_dias: number | null; - pode_publicar: boolean; - target_threshold: ThresholdTarget | null; - last_published_at: string | null; - published_by: string | null; - bloqueios: string[]; -} - -/** - * Resultado de uma publicação bem-sucedida. - */ -export interface PublishResult { - success: boolean; - publish_status: PublishStatus; - published_at: string; - published_threshold: number; - cascade?: { - concluidas: string[]; - erros: string[]; - }; - error?: string; -} - -// ============================================================ -// LEITURA (cliente) -// ============================================================ - -const getSupabase = () => createClient(); - -/** - * Lê estado de readiness via RPC. Snapshot do estado atual — não cache. - * - * Pode ser chamado client-side (RPC tem SECURITY DEFINER e respeita RLS via - * project_members na tabela module_analyses). - */ -export async function getDreReadiness(projectId: string): Promise { - // RETURNS TABLE → supabase-js entrega array. Não usamos .single() pra - // evitar PGRST116 quando o cache de schema do PostgREST não atualizou - // (causa típica do erro vazio). - // - // O cache de schema do PostgREST pode ficar stale após migrations que - // alteram tabelas lidas por este RPC (ex.: ALTER em dre_monthly_snapshots). - // O sintoma é um erro com body vazio (`{}`). Fazemos 1 retry curto — o - // PostgREST costuma recarregar o cache sozinho em segundos. Se persistir, - // rode no SQL editor do Supabase: NOTIFY pgrst, 'reload schema'; - const MAX_TENTATIVAS = 2; - - for (let tentativa = 1; tentativa <= MAX_TENTATIVAS; tentativa++) { - const { data, error } = await getSupabase() - .rpc("get_dre_readiness", { p_project_id: projectId }); - - if (!error) { - if (!data || (Array.isArray(data) && data.length === 0)) { - console.warn("[getDreReadiness] sem dados para project:", projectId); - return null; - } - const row = Array.isArray(data) ? data[0] : data; - return row as DreReadiness; - } - - const isUltima = tentativa === MAX_TENTATIVAS; - const detalhe = { - tentativa, - message: error.message, - details: error.details, - hint: error.hint, - code: error.code, - json: JSON.stringify(error), - keys: Object.keys(error), - }; - - if (!isUltima) { - console.warn("[getDreReadiness] falhou, tentando de novo em 800ms…", detalhe); - await new Promise((r) => setTimeout(r, 800)); - continue; - } - - // Última tentativa: warn (não error) pra não estourar o overlay de erro - // do Next em dev. O app degrada — análises ficam bloqueadas até o - // cache do PostgREST recarregar (NOTIFY pgrst, 'reload schema'). - console.warn( - "[getDreReadiness] indisponível após retry — provável cache de schema do PostgREST stale. " + - "Rode `NOTIFY pgrst, 'reload schema';` no Supabase. Detalhe:", - detalhe - ); - return null; - } - - return null; -} - -// ============================================================ -// ESCRITA (via API route — service_role) -// ============================================================ - -/** - * Pede ao backend pra publicar a DRE no nível alvo. Backend valida readiness, - * grava `module_analyses.publish_status`, dispara cascata Grupo A se aplicável, - * invalida cache do dossier. - */ -export async function publishDRE( - projectId: string, - threshold: ThresholdTarget -): Promise { - const res = await fetch( - `/api/projetos/${projectId}/financeiro/publicar`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ threshold_target: threshold }), - } - ); - - if (!res.ok) { - const body = await res.json().catch(() => ({})); - return { - success: false, - publish_status: "rascunho", - published_at: "", - published_threshold: 0, - error: body.error || `Erro ${res.status}`, - }; - } - - return res.json(); -} - -// ============================================================ -// HELPERS -// ============================================================ - -/** - * Mapeia publish_status para o status legacy (`module_analyses.status`). - * Reusado pela API route pra manter triggers de progresso funcionando. - */ -export function legacyStatusFromPublish(publish: PublishStatus): string { - switch (publish) { - case "rascunho": - return "coletando_dados"; - case "preliminar": - return "em_analise"; - case "consolidada": - return "concluido"; - } -} - -/** - * Label humano para UI. - */ -export function publishStatusLabel(status: PublishStatus): string { - switch (status) { - case "rascunho": - return "Rascunho"; - case "preliminar": - return "Preliminar"; - case "consolidada": - return "Consolidada"; - } -} - -/** - * Decide qual threshold alvo o usuário deve disparar dado o estado atual. - * Útil pro botão "Publicar DRE" entender o que ele faz. - */ -export function nextThresholdTarget(readiness: DreReadiness): ThresholdTarget | null { - return readiness.target_threshold; -} diff --git a/src/lib/email-service.ts b/src/lib/email-service.ts index 3ff8f64b..fc51df97 100644 --- a/src/lib/email-service.ts +++ b/src/lib/email-service.ts @@ -265,7 +265,7 @@ function buildWelcomeHtml(params: WelcomeEmailParams): string {

Este email foi enviado automaticamente pelo ATR OS. - Em caso de dúvidas, responda a este email ou entre em contato com seu consultor. + Em caso de dúvidas, é só responder a este email.

diff --git a/src/lib/financial-document-service.ts b/src/lib/financial-document-service.ts index da801690..b49e7f04 100644 --- a/src/lib/financial-document-service.ts +++ b/src/lib/financial-document-service.ts @@ -24,7 +24,7 @@ import { registrarAncorasDoStatement } from "@/lib/balance-anchor-service"; import { getBankAccountService } from "@/lib/bank-account-service"; import { parseCreditCardFile } from "@/lib/parsers/credit-card-parser"; import { parseManualTemplate } from "@/lib/parsers/manual-template-parser"; -import { DRECategory, DRESubcategory, CategorizationRule, Direction } from "@/features/dre/types"; +import { DRECategory, DRESubcategory, CategorizationRule, Direction, MatchType } from "@/features/dre/types"; import { filtrarBatchPorFitid } from "@/lib/fitid-dedup"; import { calcularMesesFaltantes, type MesesFaltantesResult } from "@/lib/missing-months"; import { formatDataBanco } from "@/lib/parsers/utils"; @@ -708,18 +708,20 @@ export async function confirmarImportacao(params: ImportarExtratoParams): Promis try { const regras = await buscarRegrasCategorizacao(projectId); for (const regra of regras) { - let q = getSupabase() - .from("transactions") - .update({ - categoria_id: regra.category_id, - categoria_nome: regra.categoria_nome, - subcategoria_id: regra.subcategory_id || null, - subcategoria_nome: regra.subcategoria_nome || null, - }) - .eq("project_id", projectId) - .eq("document_id", documento.id) - .eq("lancamento_tratado", regra.pattern) - .is("categoria_id", null); + let q = filtroDeRegra( + getSupabase() + .from("transactions") + .update({ + categoria_id: regra.category_id, + categoria_nome: regra.categoria_nome, + subcategoria_id: regra.subcategory_id || null, + subcategoria_nome: regra.subcategoria_nome || null, + }) + .eq("project_id", projectId) + .eq("document_id", documento.id) + .is("categoria_id", null), + regra, + ); if (regra.direction === "credit") { q = q.gt("credito", 0); @@ -1680,6 +1682,9 @@ export async function criarSubcategoriaPersonalizada( /** * Salva ou atualiza uma regra de categorização * CRÍTICO: Inclui direction para não misturar regras de crédito/débito + * + * `matchType` default `'exact'` mantém o comportamento histórico: todo caller + * existente continua gravando regra de igualdade, sem mudar uma linha. */ export async function salvarRegraCategorizacao( projectId: string, @@ -1689,7 +1694,8 @@ export async function salvarRegraCategorizacao( categoriaNome: string, subcategoriaNome: string | undefined, direction: Direction, - totalValor: number = 0 + totalValor: number = 0, + matchType: MatchType = "exact", ): Promise { // Frente C — Multi-CNPJ §7: intercompany/mútuo sempre manual. // Não persistir regra pra esses casos — cada lançamento é único. @@ -1713,6 +1719,7 @@ export async function salvarRegraCategorizacao( categoria_nome: par?.categoriaNome ?? categoriaNome, subcategoria_nome: par?.subcategoriaNome ?? subcategoriaNome ?? null, direction: direction, + match_type: matchType, ocorrencias: 1, total_valor: totalValor, is_active: true, @@ -1748,7 +1755,79 @@ export async function buscarRegrasCategorizacao( return []; } - return (data || []).map(normalizarRegraPeloCatalogo); + // ORDEM IMPORTA: `exact` antes de `contains`. + // + // A regra exata é a mais específica e tem de ganhar. Como todo aplicador só + // toca linha com `categoria_id IS NULL`, basta a exata rodar primeiro para a + // ampla nunca sobrescrever uma decisão específica — a precedência sai de + // graça, sem prioridade explícita. + // + // Ordenado em TS de propósito: depender da ordenação alfabética do Postgres + // ('contains' < 'exact') seria acidente, não desenho. + return (data || []) + .map(normalizarRegraPeloCatalogo) + .sort((a, b) => { + const peso = (r: CategorizationRule) => (r.match_type === "contains" ? 1 : 0); + const d = peso(a) - peso(b); + return d !== 0 ? d : (b.ocorrencias ?? 0) - (a.ocorrencias ?? 0); + }); +} + +// ============================================================ +// CASAMENTO DE REGRA — exact × contains +// ============================================================ + +/** + * Escapa o pattern para uso dentro de `ILIKE '%…%'`. PURA. + * + * `%` e `_` são curingas no LIKE. Um descritor com `_` (comum em memo de PIX) + * viraria "qualquer caractere" e a regra pegaria mais do que o dono viu no + * preview — que é exatamente o tipo de surpresa que quebra a confiança na tela. + * A barra invertida vem primeiro, senão escaparíamos os próprios escapes. + */ +export function escaparParaIlike(pattern: string): string { + return (pattern ?? "") + .replace(/\\/g, "\\\\") + .replace(/%/g, "\\%") + .replace(/_/g, "\\_"); +} + +/** O mínimo que uma query precisa expor para receber o filtro de uma regra. */ +interface QueryFiltravel { + eq(column: string, value: unknown): Self; + ilike(column: string, pattern: string): Self; + is(column: string, value: null): Self; +} + +/** + * Aplica o casamento da regra sobre uma query de `transactions`. FONTE ÚNICA — + * os três pontos que aplicam regra (importação, varredura retroativa e lote da + * tela) passam por aqui, para não existir `if (match_type)` espalhado. + * + * ── A TRAVA DE SEGURANÇA ──────────────────────────────────────────────────── + * Regra `contains` é ampla por natureza, e a cauda dos extratos é cheia de + * pessoa física. Sem guarda, uma regra "contém PIX" varreria **aporte de sócio + * para dentro da receita** — e receita inflada contamina margem, ponto de + * equilíbrio e a meta do Plano de Voo. + * + * A guarda é uma cláusula: `contains` só toca linha SEM contraparte detectada. + * Fica segura por construção, do mesmo jeito que a varredura retroativa é segura + * por só tocar `categoria_id IS NULL`. Medido em produção: 1.029 lançamentos têm + * contraparte (900 deles só por nome) e ficam protegidos por esta linha. + * + * `exact` não precisa da guarda: casar a descrição inteira já é uma decisão + * específica sobre aquele lançamento. + */ +function filtroDeRegra>( + query: Q, + regra: Pick, +): Q { + if (regra.match_type === "contains") { + return query + .ilike("lancamento_tratado", `%${escaparParaIlike(regra.pattern)}%`) + .is("contraparte_tipo", null); + } + return query.eq("lancamento_tratado", regra.pattern); } /** @@ -1940,21 +2019,23 @@ export async function aplicarRegrasNoProjeto( const regra = regras[i]; let linhasAtualizadas = 0; try { - let q = getSupabase() - .from("transactions") - .update({ - categoria_id: regra.category_id, - categoria_nome: regra.categoria_nome, - subcategoria_id: regra.subcategory_id || null, - subcategoria_nome: regra.subcategoria_nome || null, - }) - .eq("project_id", projectId) - .eq("lancamento_tratado", regra.pattern) - .is("categoria_id", null) - // `manually_reviewed` é boolean sem NOT NULL — linhas legadas - // podem ser NULL; `.eq(false)` as excluiria. Só queremos - // proteger as exceções explícitas (true). - .or("manually_reviewed.is.null,manually_reviewed.is.false"); + let q = filtroDeRegra( + getSupabase() + .from("transactions") + .update({ + categoria_id: regra.category_id, + categoria_nome: regra.categoria_nome, + subcategoria_id: regra.subcategory_id || null, + subcategoria_nome: regra.subcategoria_nome || null, + }) + .eq("project_id", projectId) + .is("categoria_id", null) + // `manually_reviewed` é boolean sem NOT NULL — linhas legadas + // podem ser NULL; `.eq(false)` as excluiria. Só queremos + // proteger as exceções explícitas (true). + .or("manually_reviewed.is.null,manually_reviewed.is.false"), + regra, + ); if (regra.direction === "credit") { q = q.gt("credito", 0); @@ -2074,6 +2155,167 @@ export async function atualizarCategoriaEmLoteComRegra( return { ...result, lancamentoTratado }; } +// ============================================================ +// REGRA POR TOKEN CONTIDO +// ============================================================ + +export interface PreviaRegraContains { + token: string; + direction: Direction; + /** Linhas que a regra REALMENTE atingiria. */ + linhas: number; + /** Soma do valor dessas linhas, no sentido da regra. */ + valor: number; + /** Descrições distintas que colapsam — é a economia de decisões. */ + grupos: number; + /** + * Linhas que contêm o token mas NÃO serão tocadas por terem contraparte + * detectada (sócio / empresa do grupo / conta própria). + * + * Este número existe para ser MOSTRADO. Ele é a prova visível de que a + * regra não vai varrer aporte de sócio para dentro da receita. + */ + protegidasPorContraparte: number; + /** Amostra das descrições atingidas, para o dono conferir antes de aplicar. */ + exemplos: string[]; +} + +/** + * Mede o alcance de uma regra `contains` ANTES de aplicar. Nada é gravado. + * + * O preview é obrigatório no fluxo da UI, no mesmo espírito de + * `varredura/tempo-economizado.ts`, que exibe a fórmula e não só o resultado: o + * dono confere o número, e é conferir que constrói confiança na tela. + */ +export async function previewRegraContains( + projectId: string, + token: string, + direction: Direction, +): Promise { + const alvo = `%${escaparParaIlike(token)}%`; + const previa: PreviaRegraContains = { + token, + direction, + linhas: 0, + valor: 0, + grupos: 0, + protegidasPorContraparte: 0, + exemplos: [], + }; + + const grupos = new Set(); + const PAGINA = 1000; + + for (let from = 0; ; from += PAGINA) { + let q = getSupabase() + .from("transactions") + .select("lancamento_tratado, credito, debito, contraparte_tipo") + .eq("project_id", projectId) + .ilike("lancamento_tratado", alvo) + .is("categoria_id", null) + .order("id", { ascending: true }) + .range(from, from + PAGINA - 1); + + if (direction === "credit") q = q.gt("credito", 0); + else if (direction === "debit") q = q.gt("debito", 0); + + const { data, error } = await q; + if (error) throw new Error(`Falha no preview da regra: ${error.message}`); + + const lote = (data ?? []) as { + lancamento_tratado: string | null; + credito: number | null; + debito: number | null; + contraparte_tipo: string | null; + }[]; + + for (const l of lote) { + if (l.contraparte_tipo != null) { + previa.protegidasPorContraparte++; + continue; + } + previa.linhas++; + previa.valor += (direction === "credit" ? l.credito : l.debito) ?? 0; + const desc = l.lancamento_tratado ?? ""; + if (desc) { + grupos.add(desc); + if (previa.exemplos.length < 8 && !previa.exemplos.includes(desc)) { + previa.exemplos.push(desc); + } + } + } + + if (lote.length < PAGINA) break; + } + + previa.grupos = grupos.size; + return previa; +} + +/** + * Aplica uma regra `contains` e a persiste para valer nas importações futuras. + * + * A trava de segurança mora em `filtroDeRegra`, fonte única — aqui não se repete + * a cláusula de contraparte, para não existir duas verdades sobre o mesmo risco. + */ +export async function aplicarRegraContains( + projectId: string, + token: string, + categoriaId: string, + categoriaNome: string, + subcategoriaId: string | null | undefined, + subcategoriaNome: string | null | undefined, + direction: Direction, + totalValor: number = 0, +): Promise { + const regra = { pattern: token, match_type: "contains" as const }; + + let q = filtroDeRegra( + getSupabase() + .from("transactions") + .update({ + categoria_id: categoriaId, + categoria_nome: categoriaNome, + subcategoria_id: subcategoriaId ?? null, + subcategoria_nome: subcategoriaNome ?? null, + }) + .eq("project_id", projectId) + .is("categoria_id", null) + .or("manually_reviewed.is.null,manually_reviewed.is.false"), + regra, + ); + + if (direction === "credit") q = q.gt("credito", 0); + else if (direction === "debit") q = q.gt("debito", 0); + + // Mesma convenção de `atualizarCategoriaEmLote`: erro é logado e degrada + // para zero, sem derrubar o fluxo da tela. + const { data, error } = await q.select("id, data"); + if (error) { + console.error("Erro ao aplicar regra por token:", error); + return { count: 0, datas: [], token }; + } + + const rows = (data ?? []) as Array<{ id: string; data: string }>; + const count = rows.length; + + if (count > 0 && subcategoriaId) { + await salvarRegraCategorizacao( + projectId, + token, + categoriaId, + subcategoriaId, + categoriaNome, + subcategoriaNome ?? undefined, + direction, + totalValor, + "contains", + ); + } + + return { count, datas: rows.map((r) => r.data), token }; +} + // ============================================================ // LANÇAMENTO MANUAL AVULSO // ============================================================ diff --git a/src/lib/financial-snapshot-service.ts b/src/lib/financial-snapshot-service.ts index 8fbd978e..8e7bbf37 100644 --- a/src/lib/financial-snapshot-service.ts +++ b/src/lib/financial-snapshot-service.ts @@ -83,7 +83,9 @@ export interface CreateSnapshotResult { * Fluxo: * 1. Valida formato do mês ('YYYY-MM'). * 2. Lê todas as transações do projeto no período. - * 3. Aplica categorias salvas (learned_rules / categorias manuais). + * 3. Aplica categorias salvas (regras de `categorization_rules` / categorias + * manuais). Era "learned_rules" — tabela da categorização por inferência, + * aposentada com o `ai-categorizer.ts` em 2026-08-18 (G-08). * 4. Calcula KPIs canônicos via `computeKpis`. * 5. Lê KPIs complementares de `vw_financial_snapshot` (ghost tables) se existirem. * 6. Grava nova versão em `dre_monthly_snapshots` (nunca sobrescreve). diff --git a/src/lib/htr-consultant-agent.ts b/src/lib/htr-consultant-agent.ts index d0f085da..4cd27d30 100644 --- a/src/lib/htr-consultant-agent.ts +++ b/src/lib/htr-consultant-agent.ts @@ -57,20 +57,21 @@ export interface HTRSuggestion { /** * Modo Consultor disponível? Sinal de maturidade de dado (client-first). * - * - Se `CONSULTOR_V2` estava ON no recompile do dossier - * (`consultor_v2_at_compile`), usa `consultor_readiness.pronto` - * (≥3 snapshots ativos + folha). - * - Senão, fallback ao limiar legado `dossier.analyses_completed >= 30`. + * Régua única: `consultor_readiness.pronto` — os 12 meses da janela fechados + * (folha NÃO é gate desde 2026-06-05). + * + * D10 (Lucas, 2026-08-20): o fallback ao limiar legado `analyses_completed >= 30` + * FOI REMOVIDO. Ele nunca destravava — `analyses_completed` é 0 em todo projeto + * real (os escritores estão atrás da escada de publicação que a D3 aposenta) — + * e mantê-lo significava um dossiê velho amordaçar a IA de uma cliente com os + * 12 meses fechados. Sem readiness ⇒ modo Assistente, que é a degradação segura. * * Usado pelas rotas de chat pra decidir se libera tools/análise estratégica - * vs modo Assistente (orientação básica enquanto base de dados se forma). + * vs modo Assistente (orientação básica enquanto a apuração ainda corre). */ export function isModoConsultor(dossier: CompanyDossier | null): boolean { if (!dossier) return false; - if (dossier.consultor_v2_at_compile === true) { - return dossier.consultor_readiness?.pronto === true; - } - return (dossier.analyses_completed ?? 0) >= 30; + return dossier.consultor_readiness?.pronto === true; } // ============================================================ diff --git a/src/lib/iniciativas-gate.ts b/src/lib/iniciativas-gate.ts index b0628b22..0ec84a83 100644 --- a/src/lib/iniciativas-gate.ts +++ b/src/lib/iniciativas-gate.ts @@ -6,12 +6,14 @@ * — não é "vazia", é inacessível (a rota redireciona pro Plano de Voo). * * Sinal de "entregue" = `plano_voo_snapshots.status = 'aprovado'`. É o ato - * explícito do consultor em `WorkflowControls` ("aprovar = travar a meta"), não - * uma heurística: plano gerado mas ainda em `revisado` NÃO destrava. + * explícito do DONO do projeto em `WorkflowControls` ("aprovar = travar a + * meta"), não uma heurística: plano gerado mas ainda em `revisado` NÃO + * destrava. (Era "do consultor" — o papel deixou de ser etapa do fluxo em + * 2026-08-18, `saas-sem-consultor.md`; quem trava a própria meta é o dono.) * - * Encadeamento com o gate do Plano (`plano-voo-gate.ts`): o Plano precisa de ≥3 - * meses fechados; as Iniciativas precisam do Plano aprovado. Ou seja, o caminho - * inteiro é: 3 meses de dado → meta travada → iniciativas. + * Encadeamento com o gate do Plano (`plano-voo-gate.ts`): o Plano precisa dos + * **12 meses da janela** fechados; as Iniciativas precisam do Plano aprovado. Ou + * seja, o caminho inteiro é: 12 meses de dado → meta travada → iniciativas. * * Server-only (cria client via `@/lib/supabase/server`), espelhando * `plano-voo-gate.ts`. Na sidebar (client) o mesmo sinal é computado em diff --git a/src/lib/ofx-scan-service.ts b/src/lib/ofx-scan-service.ts index c42b6663..835bf480 100644 --- a/src/lib/ofx-scan-service.ts +++ b/src/lib/ofx-scan-service.ts @@ -457,7 +457,7 @@ export async function distribuirGrupo(input: DistribuirGrupoInput): Promise 0 diff --git a/src/lib/pacing/pacing-service.test.ts b/src/lib/pacing/pacing-service.test.ts new file mode 100644 index 00000000..2abaa056 --- /dev/null +++ b/src/lib/pacing/pacing-service.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi } from "vitest"; +import { lerMeta } from "./pacing-service"; +import { planoVooDocumentV3Schema } from "@/types/plano-voo-v2"; + +// `pacing-service` importa o client de browser no topo. Nenhum teste daqui o usa +// (o client é injetado por parâmetro), mas o import precisa resolver. +vi.mock("@/lib/supabase/client", () => ({ createClient: () => ({}) })); + +/** + * E5-B — a meta do pacing tem de vir do plano VIGENTE, não do mais novo. + * + * O bug: `lerMeta` ordenava por `version desc` sem filtro de status. Como + * `saveRevisedVersionAction` grava a edição do plano como `status='revisado'` + * com `version = max+1`, a primeira edição feita DEPOIS de a dona travar a meta + * roubava a referência das 4 telas que chamam este leitor — Cockpit, Capital de + * Giro, Simulador e o motor de Iniciativas — sem nada na tela avisar. + * + * O teste é sobre a QUERY, não sobre o Postgres: o que precisa ficar travado é o + * filtro. Um fake que devolvesse só a linha certa passaria mesmo com o bug de + * volta. + */ +function clientFalso(payload: unknown) { + const chamadas: { eq: [string, unknown][]; order?: [string, unknown]; colunas?: string } = { eq: [] }; + const q = { + select: (c: string) => { chamadas.colunas = c; return q; }, + eq: (col: string, val: unknown) => { chamadas.eq.push([col, val]); return q; }, + order: (col: string, opts: unknown) => { chamadas.order = [col, opts]; return q; }, + limit: () => q, + maybeSingle: async () => ({ data: payload === null ? null : { html_payload: payload }, error: null }), + }; + const sb = { from: () => q } as never; + return { sb, chamadas }; +} + +/** + * Payload de forma, só para a query ter o que devolver. NÃO é um v3 válido — os + * testes de query não dependem do parse, e o teste do parse usa spy. + */ +function docV3(receitaMeta: number) { + return { + metadata: { version: 9, document_version: "v3", generated_at: "2026-08-20T00:00:00Z" }, + meta_12m: { receita: { valor: receitaMeta, tipo: "crescimento" } }, + }; +} + +describe("lerMeta — E5-B", () => { + it("filtra por status='aprovado' e NÃO ordena por version", async () => { + const { sb, chamadas } = clientFalso(docV3(1_000_000)); + await lerMeta(sb, "proj-1"); + + // O filtro que faltava. Sem ele, rascunho vira "meta travada". + expect(chamadas.eq).toContainEqual(["status", "aprovado"]); + expect(chamadas.eq).toContainEqual(["project_id", "proj-1"]); + + // `version desc` era o bug: pegava a revisão mais nova por cima da aprovada. + // O desempate correto é `approved_at`, alinhado a `snapshotAprovadoVigente`. + expect(chamadas.order).toEqual(["approved_at", { ascending: false }]); + expect(chamadas.order?.[0]).not.toBe("version"); + }); + + it("entrega ao schema o CONTEÚDO de html_payload, não a linha", async () => { + // Montar um documento v3 válido exigiria o v2 inteiro (12 seções) — custo + // desproporcional para cobrir uma linha. O que precisa ser provado aqui é + // a extração: se `lerMeta` passasse a linha (`{ html_payload: ... }`) em + // vez do conteúdo, o parse falharia e a meta sumiria em silêncio — que é + // o mesmo sintoma de "não há plano aprovado", indistinguível na tela. + const spy = vi.spyOn(planoVooDocumentV3Schema, "safeParse"); + const payload = { metadata: { document_version: "v3" }, marcador: "conteudo" }; + const { sb } = clientFalso(payload); + + await lerMeta(sb, "proj-1"); + + expect(spy).toHaveBeenCalledWith(payload); + spy.mockRestore(); + }); + + it("pede a coluna do payload, não o id", async () => { + const { sb, chamadas } = clientFalso(docV3(1)); + await lerMeta(sb, "proj-1"); + expect(chamadas.colunas).toBe("html_payload"); + }); + + it("devolve null quando não há plano aprovado — degrada para diagnóstico", async () => { + const { sb } = clientFalso(null); + expect(await lerMeta(sb, "proj-1")).toBeNull(); + }); + + it("devolve null quando o vigente é v2 — nunca meta de forma errada", async () => { + const { sb } = clientFalso({ metadata: { document_version: "v2" }, secoes: [] }); + expect(await lerMeta(sb, "proj-1")).toBeNull(); + }); +}); diff --git a/src/lib/pacing/pacing-service.ts b/src/lib/pacing/pacing-service.ts index 6e3d0660..0efc8cf0 100644 --- a/src/lib/pacing/pacing-service.ts +++ b/src/lib/pacing/pacing-service.ts @@ -1,15 +1,17 @@ /** * Pacing — camada de I/O (cockpit-definicao §7, Fase 2). Client-callable. * - * Casa a META TRAVADA (último snapshot v3 do Plano de Voo, lido direto p/ não depender de - * server action) com o REALIZADO fechado (DRE v3 via `collapseTimeline` + DL/EBITDA de + * Casa a META TRAVADA (o plano VIGENTE — `status='aprovado'`, via `queryPlanoVigente`; + * lido direto p/ não depender de server action) com o REALIZADO fechado (DRE v3 via `collapseTimeline` + DL/EBITDA de * `endividamento_snapshots`) e roda o motor puro `montarPacing`. * * Sem meta ⇒ degrada para DIAGNÓSTICO (retrato de entrada: fase + as 4 métricas atuais), * nunca inventa pacing (modelo-meta-pacing §10). Defensivo: falha → estado vazio. */ +import type { SupabaseClient } from "@supabase/supabase-js"; import { createClient } from "@/lib/supabase/client"; +import { queryPlanoVigente } from "@/lib/plano-voo/snapshot-vigente"; import { planoVooDocumentV3Schema, type Meta12m } from "@/types/plano-voo-v2"; import { collapseTimeline, type DreTimelineRow, type DreTimelinePonto } from "@/lib/plano-voo/timeline"; import { classificarFase, type FaseFinanceira } from "@/lib/plano-voo/fase-financeira"; @@ -43,15 +45,20 @@ type SB = ReturnType; * continuam usando `carregarCockpitPacing` (browser client interno). */ export async function lerMeta(sb: SB, projectId: string): Promise { - const { data } = await sb - .from("plano_voo_snapshots") - .select("html_payload") - .eq("project_id", projectId) - .order("version", { ascending: false }) - .limit(1) - .maybeSingle(); - if (!data?.html_payload) return null; - const parsed = planoVooDocumentV3Schema.safeParse(data.html_payload); + // E5-B: a meta vem do plano VIGENTE (status='aprovado'), nunca do rascunho. + // Lia `version desc` sem filtro de status — e `saveRevisedVersionAction` grava + // a versão editada como 'revisado' com version = max+1. Depois de a dona travar + // a meta, a primeira edição do plano roubava a referência das 4 telas que + // chamam este leitor (Cockpit, Capital de Giro, Simulador, motor de + // Iniciativas), sem nada na tela dizer que o número mudou de dono. + const { data } = await queryPlanoVigente( + sb as unknown as SupabaseClient, + projectId, + "html_payload", + ); + const payload = (data as { html_payload: unknown } | null)?.html_payload; + if (!payload) return null; + const parsed = planoVooDocumentV3Schema.safeParse(payload); return parsed.success ? parsed.data.meta_12m : null; } diff --git a/src/lib/partes-relacionadas/sincronizar-socios.ts b/src/lib/partes-relacionadas/sincronizar-socios.ts new file mode 100644 index 00000000..1ebfd118 --- /dev/null +++ b/src/lib/partes-relacionadas/sincronizar-socios.ts @@ -0,0 +1,175 @@ +/** + * Sincroniza `client_socios` a partir do QSA já guardado em + * `companies.dados_receita`. Borda SUJA — só este arquivo fala com o banco; a + * extração fica pura em `socios-do-qsa.ts`. + * + * ── A regra que governa tudo aqui: MANUAL GANHA ───────────────────────────── + * O comentário da tabela no banco é explícito — "alimentado pelo QSA da Receita + * ou à mão, sócio de fato não registrado é comum em PME". Ou seja: o cadastro + * manual existe justamente para o caso que a Receita NÃO sabe. Se o consultor + * cadastrou alguém à mão, a sincronização **nunca sobrescreve** — só completa o + * que veio da Receita. + * + * Nada é deletado, nunca. Sócio que saiu do QSA continua no cadastro: ele + * movimentou o caixa no passado, e apagá-lo cegaria a detecção retroativa. + * + * Idempotente por `(client_id, upper(nome))` — o índice único que o banco já + * tem (`idx_client_socios_client_nome`). + */ + +import type { SupabaseClient } from "@supabase/supabase-js"; +import { extrairSociosDoQSA, type SocioExtraido } from "./socios-do-qsa"; + +export type AcaoSocio = "criado" | "atualizado" | "preservado_manual" | "inalterado"; + +export interface SocioSincronizado extends SocioExtraido { + acao: AcaoSocio; +} + +export interface ResultadoSync { + clientId: string; + /** Empresas do cliente que tinham QSA legível. */ + empresasComQsa: number; + criados: number; + atualizados: number; + preservadosManuais: number; + inalterados: number; + socios: SocioSincronizado[]; + /** `true` quando nada foi escrito (modo simulação). */ + dryRun: boolean; +} + +interface LinhaSocio { + id: string; + nome: string; + cargo: string | null; + cpf_mascara: string | null; + fonte: string; +} + +/** + * Roda a sincronização para UM cliente. + * + * `dryRun: true` faz o levantamento completo sem escrever nada — é como se + * prova o alcance antes de mexer no banco (método da casa: o gate é o check ao + * vivo, e o check vem antes da mutação). + */ +export async function sincronizarSociosDoCliente( + supabase: SupabaseClient, + clientId: string, + opts: { dryRun?: boolean } = {}, +): Promise { + const dryRun = opts.dryRun === true; + + const { data: empresas, error: erroEmpresas } = await supabase + .from("companies") + .select("id, dados_receita") + .eq("client_id", clientId); + + if (erroEmpresas) throw new Error(`Falha ao ler empresas: ${erroEmpresas.message}`); + + // Um cliente pode ter N empresas com o MESMO sócio. O índice único é por + // (client_id, nome), então a fusão tem de acontecer aqui, antes do upsert. + const porNome = new Map(); + let empresasComQsa = 0; + + for (const emp of empresas ?? []) { + const extraidos = extrairSociosDoQSA( + (emp as { dados_receita?: Record | null }).dados_receita, + ); + if (extraidos.length > 0) empresasComQsa++; + for (const s of extraidos) { + const chave = s.nome.toUpperCase(); + const anterior = porNome.get(chave); + porNome.set( + chave, + anterior + ? { + nome: anterior.nome, + cargo: anterior.cargo ?? s.cargo, + cpfMascara: anterior.cpfMascara ?? s.cpfMascara, + } + : s, + ); + } + } + + const { data: existentes, error: erroExistentes } = await supabase + .from("client_socios") + .select("id, nome, cargo, cpf_mascara, fonte") + .eq("client_id", clientId); + + if (erroExistentes) throw new Error(`Falha ao ler sócios: ${erroExistentes.message}`); + + const existentePorNome = new Map( + ((existentes ?? []) as LinhaSocio[]).map((r) => [r.nome.toUpperCase(), r]), + ); + + const resultado: ResultadoSync = { + clientId, + empresasComQsa, + criados: 0, + atualizados: 0, + preservadosManuais: 0, + inalterados: 0, + socios: [], + dryRun, + }; + + const aInserir: Record[] = []; + const aAtualizar: { id: string; cargo: string | null; cpf_mascara: string | null }[] = []; + + for (const socio of porNome.values()) { + const atual = existentePorNome.get(socio.nome.toUpperCase()); + + if (!atual) { + aInserir.push({ + client_id: clientId, + nome: socio.nome, + cargo: socio.cargo, + cpf_mascara: socio.cpfMascara, + fonte: "brasilapi", + }); + resultado.criados++; + resultado.socios.push({ ...socio, acao: "criado" }); + continue; + } + + if (atual.fonte === "manual") { + resultado.preservadosManuais++; + resultado.socios.push({ ...socio, acao: "preservado_manual" }); + continue; + } + + // Só completa o que falta — a máscara é o dado que importa e ela nunca + // deve ser apagada por um QSA que veio mais pobre numa releitura. + const cargoNovo = socio.cargo ?? atual.cargo; + const mascaraNova = socio.cpfMascara ?? atual.cpf_mascara; + if (cargoNovo === atual.cargo && mascaraNova === atual.cpf_mascara) { + resultado.inalterados++; + resultado.socios.push({ ...socio, acao: "inalterado" }); + continue; + } + + aAtualizar.push({ id: atual.id, cargo: cargoNovo, cpf_mascara: mascaraNova }); + resultado.atualizados++; + resultado.socios.push({ ...socio, acao: "atualizado" }); + } + + if (dryRun) return resultado; + + if (aInserir.length > 0) { + const { error } = await supabase.from("client_socios").insert(aInserir); + if (error) throw new Error(`Falha ao inserir sócios: ${error.message}`); + } + + for (const upd of aAtualizar) { + const { error } = await supabase + .from("client_socios") + .update({ cargo: upd.cargo, cpf_mascara: upd.cpf_mascara, updated_at: new Date().toISOString() }) + .eq("id", upd.id); + if (error) throw new Error(`Falha ao atualizar sócio ${upd.id}: ${error.message}`); + } + + return resultado; +} diff --git a/src/lib/partes-relacionadas/socios-do-qsa.test.ts b/src/lib/partes-relacionadas/socios-do-qsa.test.ts new file mode 100644 index 00000000..8091837b --- /dev/null +++ b/src/lib/partes-relacionadas/socios-do-qsa.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect } from "vitest"; +import { extrairSociosDoQSA, mascaraDeCpf } from "./socios-do-qsa"; + +/** + * Os payloads abaixo são a forma REAL medida em produção em 2026-08-17 + * (`companies.dados_receita.qsa`), não invenção de teste. + */ + +describe("mascaraDeCpf", () => { + it("extrai os 6 dígitos do formato da Receita", () => { + expect(mascaraDeCpf("***939098**")).toBe("939098"); + expect(mascaraDeCpf("***861048**")).toBe("861048"); + }); + + it("devolve null para o que não é máscara de CPF", () => { + expect(mascaraDeCpf(null)).toBeNull(); + expect(mascaraDeCpf(undefined)).toBeNull(); + expect(mascaraDeCpf("")).toBeNull(); + expect(mascaraDeCpf("12345678901")).toBeNull(); // CPF cru + expect(mascaraDeCpf("53708623000180")).toBeNull(); // CNPJ + expect(mascaraDeCpf("***93909**")).toBeNull(); // 5 dígitos + }); + + it("o resultado sempre satisfaz o CHECK do banco (^[0-9]{6}$)", () => { + const m = mascaraDeCpf("***939098**"); + expect(m).toMatch(/^[0-9]{6}$/); + }); +}); + +describe("extrairSociosDoQSA", () => { + it("extrai nome, cargo e máscara do payload enriquecido (Di Forni)", () => { + const socios = extrairSociosDoQSA({ + qsa: [ + { + pais: null, + nome_socio: "MARCOS ANTONIO DE SOUZA", + cnpj_cpf_do_socio: "***939098**", + qualificacao_socio: "49-Sócio-Administrador", + data_entrada_sociedade: "2015-03-10", + identificador_de_socio: 2, + nome_representante_legal: "", + cpf_representante_legal: "", + }, + ], + }); + + expect(socios).toHaveLength(1); + expect(socios[0]).toEqual({ + nome: "MARCOS ANTONIO DE SOUZA", + cargo: "49-Sócio-Administrador", + cpfMascara: "939098", + }); + }); + + it("degrada para nome só quando a empresa foi cadastrada à mão (Vertímetal)", () => { + const socios = extrairSociosDoQSA({ + qsa: [ + { nome_socio: "JOSE CARLOS PEREIRA", qualificacao_socio: "Sócio" }, + { nome_socio: "ANA LUCIA PEREIRA", qualificacao_socio: "Sócia" }, + ], + }); + + expect(socios).toHaveLength(2); + expect(socios[0].cpfMascara).toBeNull(); + expect(socios[1].nome).toBe("ANA LUCIA PEREIRA"); + }); + + it("ignora sócio pessoa jurídica — ele é empresa_grupo, não client_socios", () => { + const socios = extrairSociosDoQSA({ + qsa: [ + { nome_socio: "HOLDING PARTICIPACOES LTDA", identificador_de_socio: 1, cnpj_cpf_do_socio: "53708623000180" }, + { nome_socio: "MARIA APARECIDA LIMA", identificador_de_socio: 2, cnpj_cpf_do_socio: "***300568**" }, + ], + }); + + expect(socios).toHaveLength(1); + expect(socios[0].nome).toBe("MARIA APARECIDA LIMA"); + }); + + it("detecta PJ pelo documento de 14 dígitos mesmo sem identificador", () => { + const socios = extrairSociosDoQSA({ + qsa: [{ nome_socio: "ALGUMA COISA LTDA", cnpj_cpf_do_socio: "53.708.623/0001-80" }], + }); + expect(socios).toHaveLength(0); + }); + + it("inclui o representante legal como pessoa própria", () => { + const socios = extrairSociosDoQSA({ + qsa: [ + { + nome_socio: "ESPOLIO DE JOAO SILVA", + identificador_de_socio: 2, + cnpj_cpf_do_socio: "***111222**", + nome_representante_legal: "PEDRO SILVA", + cpf_representante_legal: "***333444**", + qualificacao_representante_legal: "Inventariante", + }, + ], + }); + + expect(socios).toHaveLength(2); + expect(socios.find((s) => s.nome === "PEDRO SILVA")).toEqual({ + nome: "PEDRO SILVA", + cargo: "Inventariante", + cpfMascara: "333444", + }); + }); + + it("não duplica o representante quando ele é o próprio sócio", () => { + const socios = extrairSociosDoQSA({ + qsa: [ + { + nome_socio: "CARLA MENDES", + identificador_de_socio: 2, + nome_representante_legal: "carla mendes", + }, + ], + }); + expect(socios).toHaveLength(1); + }); + + it("colapsa o mesmo sócio vindo de duas empresas, preservando a máscara", () => { + // O índice único do banco é (client_id, upper(nome)) — o extrator tem de + // deduplicar antes do upsert, e a ocorrência COM máscara precisa ganhar. + const socios = extrairSociosDoQSA({ + qsa: [ + { nome_socio: "RENATA PRUDENTE", identificador_de_socio: 2 }, + { nome_socio: "RENATA PRUDENTE", identificador_de_socio: 2, cnpj_cpf_do_socio: "***861048**", qualificacao_socio: "Administradora" }, + ], + }); + + expect(socios).toHaveLength(1); + expect(socios[0].cpfMascara).toBe("861048"); + expect(socios[0].cargo).toBe("Administradora"); + }); + + it("aceita o payload legado com { nome, qual }", () => { + const socios = extrairSociosDoQSA({ qsa: [{ nome: "ANTIGO FORMATO", qual: "10" }] }); + expect(socios).toEqual([{ nome: "ANTIGO FORMATO", cargo: "10", cpfMascara: null }]); + }); + + it("devolve vazio sem quebrar quando não há QSA", () => { + expect(extrairSociosDoQSA(null)).toEqual([]); + expect(extrairSociosDoQSA(undefined)).toEqual([]); + expect(extrairSociosDoQSA({})).toEqual([]); + expect(extrairSociosDoQSA({ qsa: "não é array" })).toEqual([]); + expect(extrairSociosDoQSA({ qsa: [null, {}, { nome_socio: "" }] })).toEqual([]); + }); +}); diff --git a/src/lib/partes-relacionadas/socios-do-qsa.ts b/src/lib/partes-relacionadas/socios-do-qsa.ts new file mode 100644 index 00000000..b92d7703 --- /dev/null +++ b/src/lib/partes-relacionadas/socios-do-qsa.ts @@ -0,0 +1,123 @@ +/** + * Extrai sócios pessoa física do QSA da Receita — núcleo PURO. + * + * ── Por que este arquivo existe ───────────────────────────────────────────── + * `client_socios` é a tabela que o detector de partes relacionadas lê + * (`cadastro.ts`), e o comentário dela no banco já dizia o desenho: + * + * "Alimentado pelo QSA da Receita (já guardado em companies.dados_receita) + * ou à mão — sócio de fato não registrado é comum em PME." + * + * O QSA sempre esteve lá. **Ninguém nunca escreveu na tabela** — grep em `src/` + * e `scripts/` só achava leitura. O detector, portanto, rodava sobre um cadastro + * de sócios vazio e nunca reconhecia um. Medido na Di Forni: 795 lançamentos com + * nome completo de sócio no descritor, todos caindo no bolo geral. + * + * ── O formato real, verificado em produção (2026-08-17) ───────────────────── + * DI FORNI PIZZARIA → cnpj_cpf_do_socio = "***939098**" + * PRUDENTE & SILVA → cnpj_cpf_do_socio = "***861048**" + * Vertímetal → só { nome_socio, qualificacao_socio } ← cadastro à mão + * + * A Receita publica o CPF mascarado nas posições 4–9. São esses 6 dígitos que + * `client_socios.cpf_mascara` guarda (CHECK `^[0-9]{6}$`) e que dão confiança + * `alta` ao detector — sem nunca armazenar CPF completo. + * + * Empresa não enriquecida (Vertímetal) degrada para nome só: entra com + * `cpfMascara: null` e o detector a trata com confiança `media`, que vira + * sugestão para o dono confirmar. Degradar é melhor que ignorar. + */ + +/** Um sócio pessoa física pronto para virar linha em `client_socios`. */ +export interface SocioExtraido { + nome: string; + /** `qualificacao_socio` da Receita (ex.: "49-Sócio-Administrador"). */ + cargo: string | null; + /** Dígitos 4–9 do CPF. `null` quando o QSA não trouxe a máscara. */ + cpfMascara: string | null; +} + +/** `***939098**` → `939098`. Qualquer outro formato → `null`. */ +export function mascaraDeCpf(valor: unknown): string | null { + if (typeof valor !== "string") return null; + const m = valor.trim().match(/^\*{3}(\d{6})\*{2}$/); + return m ? m[1] : null; +} + +/** + * `identificador_de_socio` da Receita: 1 = pessoa jurídica, 2 = pessoa física, + * 3 = estrangeiro. Sócio PJ **não entra aqui** — ele é `empresa_grupo` para o + * detector, e misturar os dois quebraria o CHECK de coerência de + * `transactions.contraparte_*` (socio exige `contraparte_socio_id`). + * + * Ausente ou irreconhecível → assume PF. A alternativa (descartar) perderia + * sócio de empresa não enriquecida, que é justamente quem mais precisa. + */ +function ehPessoaJuridica(o: Record): boolean { + const id = o.identificador_de_socio ?? o.identificador_socio; + if (id === 1 || id === "1") return true; + // Sem identificador, um documento de 14 dígitos denuncia PJ. + const doc = o.cnpj_cpf_do_socio; + if (typeof doc === "string" && doc.replace(/\D/g, "").length === 14) return true; + return false; +} + +function limpar(v: unknown): string { + return typeof v === "string" ? v.trim() : ""; +} + +/** + * Lê `companies.dados_receita` e devolve os sócios PF, já deduplicados por nome + * (o índice único do banco é `(client_id, upper(nome))`, então duas empresas do + * mesmo cliente com o mesmo sócio têm de colapsar antes do upsert). + * + * Inclui o **representante legal** quando existir e for outra pessoa: ele + * movimenta caixa tanto quanto o sócio e hoje era ignorado por completo. + * + * Quando o mesmo nome aparece duas vezes, a ocorrência **com máscara ganha** — + * a que identifica vale mais que a que só nomeia. + */ +export function extrairSociosDoQSA( + dadosReceita: Record | null | undefined, +): SocioExtraido[] { + const qsa = (dadosReceita as { qsa?: unknown } | null | undefined)?.qsa; + if (!Array.isArray(qsa)) return []; + + const porNome = new Map(); + + const acumular = (s: SocioExtraido) => { + if (!s.nome) return; + const chave = s.nome.toUpperCase(); + const existente = porNome.get(chave); + if (!existente) { + porNome.set(chave, s); + return; + } + // Máscara é o dado que dá confiança alta — nunca perder por sobrescrita. + porNome.set(chave, { + nome: existente.nome, + cargo: existente.cargo ?? s.cargo, + cpfMascara: existente.cpfMascara ?? s.cpfMascara, + }); + }; + + for (const item of qsa) { + const o = (item ?? {}) as Record; + if (ehPessoaJuridica(o)) continue; + + // `nome_socio` é o campo da BrasilAPI; `nome` cobre payload legado. + const nome = limpar(o.nome_socio ?? o.nome); + const cargo = limpar(o.qualificacao_socio ?? o.qual) || null; + acumular({ nome, cargo, cpfMascara: mascaraDeCpf(o.cnpj_cpf_do_socio) }); + + const nomeRep = limpar(o.nome_representante_legal); + if (nomeRep && nomeRep.toUpperCase() !== nome.toUpperCase()) { + acumular({ + nome: nomeRep, + cargo: limpar(o.qualificacao_representante_legal) || "Representante legal", + cpfMascara: mascaraDeCpf(o.cpf_representante_legal), + }); + } + } + + return [...porNome.values()]; +} diff --git a/src/lib/partes-relacionadas/varredura-contraparte.ts b/src/lib/partes-relacionadas/varredura-contraparte.ts new file mode 100644 index 00000000..f15403f8 --- /dev/null +++ b/src/lib/partes-relacionadas/varredura-contraparte.ts @@ -0,0 +1,192 @@ +/** + * Varredura RETROATIVA de contraparte — o par de `aplicarRegrasNoProjeto`, do + * lado do "com quem". + * + * ── Por que existe ────────────────────────────────────────────────────────── + * O detector só era chamado em UM lugar: `confirmarImportacao` + * (`financial-document-service.ts:590,630`). Isso significa que ele só enxerga + * o que o cadastro de sócios/empresas já sabia **no instante da importação**. + * Cadastrar um sócio depois não alcançava nada do histórico. + * + * Medido em produção em 2026-08-17, antes desta varredura existir: + * + * transações com contraparte marcada ....... 16 + * transações sem marca ..................... 18.081 + * + * E o próprio detector documenta 795 lançamentos com nome completo de sócio só + * na Di Forni. Ou seja: a inteligência estava construída e nunca tinha rodado + * sobre o histórico. + * + * ── Duas disciplinas herdadas de `aplicarRegrasNoProjeto` ─────────────────── + * + * 1. **Só toca linha com `contraparte_tipo IS NULL`.** Nunca sobrescreve + * decisão já tomada — nem do sistema, nem do dono. + * + * 2. **Grava `alta` e `media`, e a diferença aparece na tela.** A primeira + * versão desta varredura guardava só `alta`, tratando `media` como sugestão + * em memória. Três achados no código vivo derrubaram isso: + * + * - `transactions.contraparte_confianca` tem CHECK `IN ('alta','media')` — + * o schema previu a persistência das duas; + * - o gate JÁ renderiza `media` como *"identificada pelo nome, confira"* + * (`GateFormBody.tsx:1018,1039`), com o comentário: "o dono precisa saber + * que o sistema deduziu, senão confirma um palpite achando que é fato"; + * - **nada age sozinho a partir do tipo.** O consolidado só elimina com + * `contraparte_tipo='empresa_grupo'` **E** `contraparte_porta='operacao'` + * (`consolidated-dre-service.ts:47`), e a porta é escolha do dono. + * + * Persistir `media` portanto não decide nada — faz a tela **perguntar**. E é + * o que torna eficaz a trava da regra por token (LEVA 3): sem a linha + * marcada, uma regra ampla varreria aporte de sócio para dentro da receita. + * Medido: 900 lançamentos em PRUDENTE & SILVA nessa situação. + */ + +import type { SupabaseClient } from "@supabase/supabase-js"; +import { detectarContraparte, type CadastroDoCliente } from "./detector"; +import { carregarCadastroDoProjeto, camposContraparte } from "./cadastro"; + +/** Página do PostgREST — sem `.range()` a query volta 1000 linhas em silêncio. */ +const PAGINA = 1000; + +export interface SugestaoContraparte { + transactionId: string; + descritor: string; + tipo: string; + contraparteId: string | null; + motivo: string; +} + +export interface VarreduraContraparteResult { + projectId: string; + /** `false` quando o projeto não tem cadastro (sem cliente, ou sem empresa e sem sócio). */ + tinhaCadastro: boolean; + analisadas: number; + /** Total gravado (alta + media). */ + marcadas: number; + /** Documento bateu — o sistema pode agir. */ + porDocumento: number; + /** Só o nome bateu — gravado, mas a tela pede confirmação ao dono. */ + porNome: number; + porTipo: { propria: number; empresa_grupo: number; socio: number }; + /** Amostra das detecções por nome, para o relatório do dry-run. */ + sugestoes: SugestaoContraparte[]; + dryRun: boolean; +} + +export type ProgressoContraparte = + | { fase: "inicio" } + | { fase: "lendo"; lidas: number } + | { fase: "gravando"; grupos: number } + | { fase: "fim"; marcadas: number }; + +/** Chave de agrupamento — linhas com a MESMA marca vão num UPDATE só. */ +function chaveDaMarca(c: ReturnType): string { + return [c.contraparte_tipo, c.contraparte_company_id, c.contraparte_socio_id, c.contraparte_confianca].join("|"); +} + +export async function aplicarContrapartesNoProjeto( + supabase: SupabaseClient, + projectId: string, + opts: { dryRun?: boolean; onProgress?: (e: ProgressoContraparte) => void } = {}, +): Promise { + const dryRun = opts.dryRun === true; + // Wrapper defensivo: callback do caller não pode derrubar a varredura. + const emit = (e: ProgressoContraparte) => { + if (!opts.onProgress) return; + try { opts.onProgress(e); } catch { /* ignorado de propósito */ } + }; + + const base: VarreduraContraparteResult = { + projectId, + tinhaCadastro: false, + analisadas: 0, + marcadas: 0, + porDocumento: 0, + porNome: 0, + porTipo: { propria: 0, empresa_grupo: 0, socio: 0 }, + sugestoes: [], + dryRun, + }; + + emit({ fase: "inicio" }); + + const cadastro: CadastroDoCliente | null = await carregarCadastroDoProjeto(supabase, projectId); + if (!cadastro) return base; + base.tinhaCadastro = true; + + // ── Lê só o que ainda não tem marca ───────────────────────────────────── + const pendentes: { id: string; lancamento_original: string | null }[] = []; + for (let from = 0; ; from += PAGINA) { + const { data, error } = await supabase + .from("transactions") + .select("id, lancamento_original") + .eq("project_id", projectId) + .is("contraparte_tipo", null) + .order("id", { ascending: true }) + .range(from, from + PAGINA - 1); + + if (error) throw new Error(`Falha ao ler transações: ${error.message}`); + const lote = data ?? []; + pendentes.push(...(lote as typeof pendentes)); + emit({ fase: "lendo", lidas: pendentes.length }); + if (lote.length < PAGINA) break; + } + + base.analisadas = pendentes.length; + + // ── Detecta e agrupa ──────────────────────────────────────────────────── + const porMarca = new Map; ids: string[] }>(); + + for (const t of pendentes) { + const d = detectarContraparte(t.lancamento_original, cadastro); + if (!d) continue; + + if (d.confianca === "alta") { + base.porDocumento++; + } else { + base.porNome++; + // Amostra só para o relatório — a linha é gravada de qualquer forma, + // com `confianca='media'`, e é a tela que pede a confirmação. + if (base.sugestoes.length < 50) { + base.sugestoes.push({ + transactionId: t.id, + descritor: t.lancamento_original ?? "", + tipo: d.tipo, + contraparteId: d.contraparteId, + motivo: d.motivo, + }); + } + } + + const campos = camposContraparte(d); + const k = chaveDaMarca(campos); + const grupo = porMarca.get(k) ?? { campos, ids: [] }; + grupo.ids.push(t.id); + porMarca.set(k, grupo); + + base.marcadas++; + if (d.tipo === "propria" || d.tipo === "empresa_grupo" || d.tipo === "socio") { + base.porTipo[d.tipo]++; + } + } + + if (dryRun || porMarca.size === 0) return base; + + // ── Grava, um UPDATE por marca distinta ───────────────────────────────── + emit({ fase: "gravando", grupos: porMarca.size }); + + for (const grupo of porMarca.values()) { + // `.in()` em lotes: um IN gigante estoura o limite de URL do PostgREST. + for (let i = 0; i < grupo.ids.length; i += 200) { + const fatia = grupo.ids.slice(i, i + 200); + const { error } = await supabase + .from("transactions") + .update(grupo.campos) + .in("id", fatia); + if (error) throw new Error(`Falha ao marcar contraparte: ${error.message}`); + } + } + + emit({ fase: "fim", marcadas: base.marcadas }); + return base; +} diff --git a/src/lib/pe35-auto-gen-service.ts b/src/lib/pe35-auto-gen-service.ts index 0965cec7..3a5b0bbe 100644 --- a/src/lib/pe35-auto-gen-service.ts +++ b/src/lib/pe35-auto-gen-service.ts @@ -23,6 +23,7 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import { createClient } from "@/lib/supabase/server"; +import { snapshotAprovadoVigente } from "@/lib/plano-voo/snapshot-vigente"; import { gerarIniciativasDoFechamento, ultimoMesFechado, @@ -93,11 +94,19 @@ export async function checkAndTriggerFinanceiroHypotheses( async function getSemanaAtualDoPV( supabase: Awaited>, projectId: string, + snapshotId: string | null, ): Promise { + // Sem plano vigente não há semana corrente — a escalação cai na 1. + if (!snapshotId) return 1; + + // Só as ações do plano VIGENTE. Contar as do projeto inteiro somaria as + // semanas de um plano arquivado e jogaria a escalação para uma semana que + // não existe mais no plano em execução. const { data: acoes, error } = await supabase .from("cockpit_actions") .select("week, status") - .eq("project_id", projectId); + .eq("project_id", projectId) + .eq("snapshot_id", snapshotId); if (error || !acoes || acoes.length === 0) return 1; @@ -142,24 +151,15 @@ export async function escalateResultToCockpit( // client (cookies) — caminho original. const supabase = sb ?? (await createClient()); - const semanaAtual = await getSemanaAtualDoPV(supabase, input.projectId); + // O plano VIGENTE (aprovado), não o snapshot mais recente por `created_at`: + // a query antiga não filtrava status e podia grudar a ação num rascunho — + // que o Cockpit, agora que lê por snapshot vigente, jamais mostraria. + const snapshotId = input.snapshotId ?? (await snapshotAprovadoVigente(supabase, input.projectId)); + + const semanaAtual = await getSemanaAtualDoPV(supabase, input.projectId, snapshotId); const week = Math.min(semanaAtual + 1, MAX_WEEK); const phase = phaseFromWeek(week); - // snapshot_id é opcional na tabela (FK nullable) — se caller não passou, - // tenta pegar o snapshot ativo mais recente do projeto. - let snapshotId = input.snapshotId ?? null; - if (!snapshotId) { - const { data: snap } = await supabase - .from("plano_voo_snapshots") - .select("id") - .eq("project_id", input.projectId) - .order("created_at", { ascending: false }) - .limit(1) - .maybeSingle(); - snapshotId = snap?.id ?? null; - } - const { error } = await supabase.from("cockpit_actions").insert({ project_id: input.projectId, snapshot_id: snapshotId, diff --git a/src/lib/plano-voo-gate.ts b/src/lib/plano-voo-gate.ts index 27cfd402..933d8c7b 100644 --- a/src/lib/plano-voo-gate.ts +++ b/src/lib/plano-voo-gate.ts @@ -3,8 +3,11 @@ * legado "30 análises" (decisão D-3, 2026-05-30, trilha desligar-modulos-extras). * * Reusa `consultorPronto` (`consultor-readiness.ts`) — o mesmo sinal client-first - * que o dossiê já carimba em `consultor_readiness`: ≥3 snapshots mensais ATIVOS - * (`dre_monthly_snapshots`, `invalidated_at IS NULL`). Folha NÃO é gate (decisão + * que o dossiê já carimba em `consultor_readiness`: os **12 meses da janela** + * (`janela-12m.ts`) fechados — snapshots ATIVOS em `dre_monthly_snapshots`, + * `invalidated_at IS NULL`. Eram 3 quaisquer até 2026-07-28; hoje são 12, e por + * COBERTURA da janela: 12 meses velhos, ou com buraco no meio, não destravam. + * Folha NÃO é gate (decisão * 2026-06-05, `modelo-meta-pacing.md` §7). A empresa "destrava o Plano de Voo" * quando o dado real amadurece, não quando o checklist de análises soma 30. * @@ -15,7 +18,7 @@ import { createClient } from "@/lib/supabase/server"; import { consultorPronto, type Readiness } from "@/lib/consultor-readiness"; import { resolverEscopo } from "@/lib/grupo/escopo"; -import { cobertura, janelaPorDataDeEntrada } from "@/lib/plano-voo/janela-12m"; +import { cobertura, mesesFaltantes, janelaPorDataDeEntrada } from "@/lib/plano-voo/janela-12m"; /** Readiness + o nº de meses fechados (pra UI mostrar progresso X / mínimo). */ export type PlanoVooReadiness = Readiness & { @@ -34,6 +37,7 @@ export async function planoVooDestravado(projectId: string): Promise r.mes_referencia), + const fechados = ((snaps ?? []) as { mes_referencia: string }[]).map( + (r) => r.mes_referencia, ); - return { ...consultorPronto({ snapshotsAtivos: mesesFechados }), mesesFechados }; + const mesesFechados = cobertura(janela, fechados); + return { + ...consultorPronto({ mesesFaltantes: mesesFaltantes(janela, fechados) }), + mesesFechados, + }; } diff --git a/src/lib/plano-voo-v2-service.ts b/src/lib/plano-voo-v2-service.ts index 4457ce58..084fb68d 100644 --- a/src/lib/plano-voo-v2-service.ts +++ b/src/lib/plano-voo-v2-service.ts @@ -260,7 +260,7 @@ async function fetchConsultor( return { id: userId, - nome: (data?.full_name as string) || "Consultor ATR", + nome: (data?.full_name as string) || "ATR OS", email: (data?.email as string) || "consultor@atrgrowthbusiness.com.br", telefone: (data?.telefone as string | null) ?? null, }; @@ -386,7 +386,7 @@ async function computeDeterministicFields(c: CollectedInputs): Promise a + b, 0); // 1.100 + +function metaBase(fase: Meta12m["fase"]): Meta12m { + return { + fase, + mes_entrada: "2026-09", + meses: Array.from({ length: 12 }, (_, i) => `2026-${String(i + 1).padStart(2, "0")}`), + confianca: { faixa: "plena", mesesFechados: 12, rotulo: "12 meses fechados", portaAuto: true }, + receita: { + metrica: "receita", tipo: "crescimento", unidade: "BRL", + baseline: 1000, objetivo: TOTAL_RECEITA, curva: [...CURVA_RECEITA], + racional: "motor", baselineRunRate: 1050, objetivoRunRate: 1200, + }, + resultado: { + metrica: "resultado", tipo: "crescimento", unidade: "BRL", + baseline: 100, objetivo: 110, curva: Array.from({ length: 12 }, () => 110 / 12), + racional: "motor do resultado", objetivoRunRate: 130, + }, + margem: { + metrica: "margem", tipo: "nivel", unidade: "pct", + baseline: 0.1, objetivo: 0.15, curva: Array.from({ length: 12 }, () => 0.15), + racional: "motor da margem", + }, + endividamento: { + metrica: "endividamento", tipo: "nivel", unidade: "x", + baseline: 3, objetivo: 2.4, curva: Array.from({ length: 12 }, () => 2.4), + racional: "motor do endividamento", + }, + }; +} + +describe("ajustarReceitaDaMeta", () => { + it("a receita vira o número do dono e o crescimento é medido sobre o histórico dela", () => { + const r = ajustarReceitaDaMeta(metaBase("aproximacao"), 1500); + expect(r.meta.receita.objetivo).toBe(1500); + expect(r.crescimentoImplicito).toBeCloseTo(0.5, 10); // 1500/1000 - 1 + expect(r.receitaObjetivoOriginal).toBe(TOTAL_RECEITA); + }); + + it("a curva reescala mas PRESERVA a sazonalidade (a forma, não só o total)", () => { + const r = ajustarReceitaDaMeta(metaBase("aproximacao"), 2200); + const nova = r.meta.receita.curva; + expect(nova.reduce((a, b) => a + b, 0)).toBeCloseTo(2200, 6); + // Cada mês manteve a MESMA fatia do total que tinha antes. + nova.forEach((v, i) => { + expect(v / 2200).toBeCloseTo(CURVA_RECEITA[i] / TOTAL_RECEITA, 10); + }); + }); + + it("aproximação: o resultado ACOMPANHA a receita escolhida", () => { + const r = ajustarReceitaDaMeta(metaBase("aproximacao"), 1500); + expect(r.resultadoAncorado).toBe(false); + expect(r.meta.resultado.objetivo).toBeCloseTo(100 * 1.5, 10); + expect(r.meta.resultado.curva.reduce((a, b) => a + b, 0)).toBeCloseTo(150, 6); + }); + + it("recuperação: o resultado NÃO se move — mira o zero venha de onde vier a receita", () => { + const base = metaBase("recuperacao"); + const r = ajustarReceitaDaMeta(base, 5000); + expect(r.resultadoAncorado).toBe(true); + expect(r.meta.resultado.objetivo).toBe(base.resultado.objetivo); + expect(r.meta.resultado.curva).toEqual(base.resultado.curva); + expect(r.meta.resultado.racional).toMatch(/mira o zero/i); + }); + + it("fora do ICP: o resultado também fica ancorado, e o racional diz por quê", () => { + const base = metaBase("fora_icp"); + const r = ajustarReceitaDaMeta(base, 5000); + expect(r.resultadoAncorado).toBe(true); + expect(r.meta.resultado.objetivo).toBe(base.resultado.objetivo); + expect(r.meta.resultado.racional).toMatch(/fora do icp/i); + }); + + it("margem e endividamento NUNCA se movem — são metas de nível (modelo-meta-pacing)", () => { + const base = metaBase("aproximacao"); + for (const alvo of [0, 500, 1500, 99999]) { + const r = ajustarReceitaDaMeta(base, alvo); + expect(r.meta.margem).toEqual(base.margem); + expect(r.meta.endividamento).toEqual(base.endividamento); + } + }); + + it("o racional passa a dizer que o número é do dono — a meta não mente sobre a origem", () => { + const r = ajustarReceitaDaMeta(metaBase("aproximacao"), 1500); + expect(r.meta.receita.racional).toMatch(/definida pelo dono/i); + expect(r.meta.receita.racional).not.toBe("motor"); + }); + + it("o objetivo de run-rate do motor sai; a baseline (que é fato) fica", () => { + const r = ajustarReceitaDaMeta(metaBase("aproximacao"), 1500); + expect(r.meta.receita.objetivoRunRate).toBeUndefined(); + expect(r.meta.receita.baselineRunRate).toBe(1050); + }); + + it("entrada inválida devolve a meta INTACTA — a meta trava Cockpit e Iniciativas", () => { + const base = metaBase("aproximacao"); + for (const lixo of [NaN, Infinity, -1]) { + const r = ajustarReceitaDaMeta(base, lixo); + expect(r.meta).toEqual(base); + } + }); + + it("zerar a receita é permitido e não estoura a curva", () => { + const r = ajustarReceitaDaMeta(metaBase("aproximacao"), 0); + expect(r.meta.receita.objetivo).toBe(0); + expect(r.meta.receita.curva.every((v) => v === 0)).toBe(true); + expect(r.crescimentoImplicito).toBeCloseTo(-1, 10); + }); + + it("curva de origem toda zero cai em distribuição uniforme, sem NaN", () => { + const base = metaBase("aproximacao"); + base.receita.objetivo = 0; + base.receita.curva = Array.from({ length: 12 }, () => 0); + const r = ajustarReceitaDaMeta(base, 1200); + expect(r.meta.receita.curva.every((v) => v === 100)).toBe(true); + expect(r.meta.receita.curva.some(Number.isNaN)).toBe(false); + }); + + it("não muta a meta de entrada", () => { + const base = metaBase("aproximacao"); + const copia = JSON.parse(JSON.stringify(base)); + ajustarReceitaDaMeta(base, 1500); + expect(base).toEqual(copia); + }); +}); diff --git a/src/lib/plano-voo/ajuste-meta.ts b/src/lib/plano-voo/ajuste-meta.ts new file mode 100644 index 00000000..fcd40c9c --- /dev/null +++ b/src/lib/plano-voo/ajuste-meta.ts @@ -0,0 +1,140 @@ +/** + * A mão do cliente sobre a meta do motor — D11 (Lucas, 2026-08-20). + * + * A jornada diz: *"o cliente vai analisar e definir a meta com base no que o + * sistema indica, mas ele pode mexer nessa meta caso queira."* O campo é UM só: + * a **receita**. O resto o modelo recalcula. + * + * O QUE RECALCULA E O QUE NÃO — e por quê: + * + * - **receita**: vira o número dela. A curva de 12 meses é REESCALADA, não + * redistribuída do zero: multiplicar a curva pelo fator preserva exatamente a + * mesma sazonalidade que o motor mediu no histórico dela. (Redistribuir com os + * fatores originais daria o mesmo resultado; reescalar não exige carregar os + * fatores junto.) + * - **resultado**: segue a receita **onde o modelo manda seguir** — só na fase + * `aproximacao`, onde `objetivo = baseline × (1 + g)`. Em `recuperacao` o + * resultado mira o ZERO e ignora o crescimento da receita; em `fora_icp` ele + * fica parado. Fingir que segue nos três casos seria inventar meta. + * - **margem** e **endividamento**: NÃO se movem. São metas de **nível** + * ancoradas no melhor histórico da própria empresa — é o que o + * `modelo-meta-pacing.md` prescreve ("receita/resultado = crescimento, + * margem/endividamento = nível"). Derivá-las da receita seria mudar a lei do + * modelo, não a tela. + * + * O `racional` de cada métrica tocada é REESCRITO para dizer que o número é + * escolha do dono, não saída do motor. O campo existe para ser auditável — se + * ele continuasse contando a história do motor, a meta mentiria sobre a própria + * origem. + * + * PURO: sem I/O, sem deps server-only. Roda igual no server (ao travar) e no + * client (na pré-visualização enquanto ela digita). + */ + +import type { Meta12m } from "@/types/plano-voo-v2"; + +type MetaMetrica = Meta12m["receita"]; + +/** `12345.6` → `R$ 12.346`. Local, para o racional não depender de UI. */ +function brl(v: number): string { + return v.toLocaleString("pt-BR", { + style: "currency", + currency: "BRL", + maximumFractionDigits: 0, + }); +} + +/** `0.153` → `15,3%`. */ +function pct(v: number): string { + return `${(v * 100).toFixed(1).replace(".", ",")}%`; +} + +/** + * Reescala uma curva de 12 meses para um novo total, preservando a forma. + * + * Quando o total antigo é zero (ou a curva é toda zero) não há forma a + * preservar — cai em distribuição uniforme, que é o único palpite honesto. + */ +function reescalarCurva(curva: number[], totalAntigo: number, totalNovo: number): number[] { + const somaAbs = curva.reduce((a, v) => a + Math.abs(v), 0); + if (totalAntigo === 0 || somaAbs === 0) { + return Array.from({ length: curva.length }, () => totalNovo / curva.length); + } + const fator = totalNovo / totalAntigo; + return curva.map((v) => v * fator); +} + +export interface AjusteMeta { + /** A meta com a mão do dono aplicada. */ + meta: Meta12m; + /** Crescimento anual que o número dela implica sobre o próprio histórico. */ + crescimentoImplicito: number; + /** O que o motor tinha proposto — para a tela mostrar "você mudou de X para Y". */ + receitaObjetivoOriginal: number; + /** `true` quando o resultado NÃO acompanhou (recuperação / fora do ICP). */ + resultadoAncorado: boolean; +} + +/** + * Aplica a receita-alvo escolhida pelo dono e devolve a meta recalculada. + * + * `receitaObjetivo` é o faturamento total pretendido para os 12 meses da janela. + * Valor não-finito ou negativo é recusado (devolve a meta intacta) — a tela não + * deve chegar aqui com lixo, mas a meta é o que trava o Cockpit e as Iniciativas: + * ela não pode ser corrompida por um campo vazio. + */ +export function ajustarReceitaDaMeta(meta: Meta12m, receitaObjetivo: number): AjusteMeta { + const original = meta.receita.objetivo; + + if (!Number.isFinite(receitaObjetivo) || receitaObjetivo < 0) { + return { + meta, + crescimentoImplicito: + meta.receita.baseline > 0 ? original / meta.receita.baseline - 1 : 0, + receitaObjetivoOriginal: original, + resultadoAncorado: meta.fase !== "aproximacao", + }; + } + + const g = + meta.receita.baseline > 0 ? receitaObjetivo / meta.receita.baseline - 1 : 0; + + const receita: MetaMetrica = { + ...meta.receita, + objetivo: receitaObjetivo, + curva: reescalarCurva(meta.receita.curva, original, receitaObjetivo), + racional: `Meta definida pelo dono: ${brl(receitaObjetivo)} em 12 meses — ${pct(g)} sobre os ${brl(meta.receita.baseline)} do próprio histórico. A distribuição mês a mês segue a sazonalidade medida na empresa.`, + // O objetivo de run-rate era proposta do motor; com a meta escolhida à + // mão ele descreveria um número que não vale mais. A baseline fica: é fato. + objetivoRunRate: undefined, + }; + + const resultadoAncorado = meta.fase !== "aproximacao"; + + const resultado: MetaMetrica = resultadoAncorado + ? { + ...meta.resultado, + racional: + meta.fase === "recuperacao" + ? `${meta.resultado.racional} Mexer na receita não desloca este alvo: em recuperação o resultado mira o zero, venha de onde vier o faturamento.` + : `${meta.resultado.racional} Mexer na receita não desloca este alvo: fora do ICP o modelo não força meta de resultado.`, + } + : { + ...meta.resultado, + objetivo: meta.resultado.baseline * (1 + g), + curva: reescalarCurva( + meta.resultado.curva, + meta.resultado.objetivo, + meta.resultado.baseline * (1 + g), + ), + racional: `Acompanha a receita escolhida: ${pct(g)} sobre os ${brl(meta.resultado.baseline)} do histórico (alavancagem da receita).`, + objetivoRunRate: undefined, + }; + + return { + meta: { ...meta, receita, resultado }, + crescimentoImplicito: g, + receitaObjetivoOriginal: original, + resultadoAncorado, + }; +} diff --git a/src/lib/plano-voo/janela-12m.test.ts b/src/lib/plano-voo/janela-12m.test.ts index 434561bc..5b708112 100644 --- a/src/lib/plano-voo/janela-12m.test.ts +++ b/src/lib/plano-voo/janela-12m.test.ts @@ -6,6 +6,7 @@ import { janelaPorMesDeEntrada, janelaPorDataDeEntrada, cobertura, + mesesFaltantes, } from "./janela-12m"; describe("somarMeses", () => { @@ -88,3 +89,36 @@ describe("cobertura", () => { expect(cobertura(j, comBuraco)).toBe(MESES_JANELA - 1); }); }); + +describe("mesesFaltantes", () => { + // Entrada em 2026-05 ⇒ janela 2025-05 … 2026-04 (ver janelaPorMesDeEntrada). + const j = janelaPorMesDeEntrada("2026-05"); + + it("janela vazia: faltam os 12, em ordem", () => { + expect(mesesFaltantes(j, [])).toEqual(j.meses); + }); + + it("janela cheia: não falta nada", () => { + expect(mesesFaltantes(j, j.meses)).toEqual([]); + }); + + it("nomeia exatamente o buraco do meio", () => { + const comBuraco = j.meses.filter((m) => m !== "2025-09"); + expect(mesesFaltantes(j, comBuraco)).toEqual(["2025-09"]); + }); + + it("mês fechado FORA da janela não preenche buraco dentro dela", () => { + // A armadilha: contar snapshots soltos faria parecer coberto. + expect(mesesFaltantes(j, ["2023-01", "2030-12"])).toEqual(j.meses); + }); + + it("é o complemento exato de cobertura", () => { + const alguns = ["2025-05", "2025-06", "2026-04"]; + expect(mesesFaltantes(j, alguns).length).toBe(MESES_JANELA - cobertura(j, alguns)); + }); + + it("sai do mais antigo para o mais recente", () => { + const faltam = mesesFaltantes(j, ["2025-07"]); + expect(faltam).toEqual([...faltam].sort()); + }); +}); diff --git a/src/lib/plano-voo/janela-12m.ts b/src/lib/plano-voo/janela-12m.ts index 0a5e3381..f24b1efc 100644 --- a/src/lib/plano-voo/janela-12m.ts +++ b/src/lib/plano-voo/janela-12m.ts @@ -81,3 +81,18 @@ export function cobertura(janela: Janela12m, mesesFechados: readonly string[]): const fechados = new Set(mesesFechados); return janela.meses.filter((m) => fechados.has(m)).length; } + +/** + * QUAIS meses da janela ainda não têm snapshot fechado, do mais antigo para o + * mais recente. É o complemento de `cobertura` — e existe porque contar não + * basta: o sistema precisa **cobrar** ("faltam mar/2026 e dez/2025"), não só + * trancar ("faltam 2 meses"). Sem a lista, a tela é estruturalmente incapaz de + * dizer ao cliente o que fazer a seguir. + */ +export function mesesFaltantes( + janela: Janela12m, + mesesFechados: readonly string[], +): string[] { + const fechados = new Set(mesesFechados); + return janela.meses.filter((m) => !fechados.has(m)); +} diff --git a/src/lib/plano-voo/snapshot-vigente.test.ts b/src/lib/plano-voo/snapshot-vigente.test.ts new file mode 100644 index 00000000..fdc565f2 --- /dev/null +++ b/src/lib/plano-voo/snapshot-vigente.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import type { SupabaseClient } from "@supabase/supabase-js"; +import { snapshotAprovadoVigente } from "./snapshot-vigente"; + +/** + * Client falso que grava a query montada e devolve a linha combinada. + * + * O teste não é sobre o Postgres — é sobre a query: o bug que motivou este + * módulo foi justamente uma query sem filtro de status (`created_at` desc, sem + * `status = 'aprovado'`), que grudava ação de Cockpit num rascunho. Então o que + * precisa estar travado é o FILTRO, não o resultado. + */ +function clientFalso(linha: { id: string } | null) { + const chamadas: { eq: [string, unknown][]; order?: [string, unknown]; limit?: number; tabela?: string } = { eq: [] }; + const q = { + select: () => q, + eq: (col: string, val: unknown) => { chamadas.eq.push([col, val]); return q; }, + order: (col: string, opts: unknown) => { chamadas.order = [col, opts]; return q; }, + limit: (n: number) => { chamadas.limit = n; return q; }, + maybeSingle: async () => ({ data: linha, error: null }), + }; + const sb = { from: (t: string) => { chamadas.tabela = t; return q; } } as unknown as SupabaseClient; + return { sb, chamadas }; +} + +describe("snapshotAprovadoVigente", () => { + it("devolve o id do snapshot aprovado", async () => { + const { sb } = clientFalso({ id: "snap-aprovado" }); + expect(await snapshotAprovadoVigente(sb, "proj-1")).toBe("snap-aprovado"); + }); + + it("devolve null quando o projeto não tem plano aprovado", async () => { + const { sb } = clientFalso(null); + expect(await snapshotAprovadoVigente(sb, "proj-1")).toBeNull(); + }); + + it("filtra por status='aprovado' — é o filtro que faltava e causou o bug", async () => { + const { sb, chamadas } = clientFalso({ id: "x" }); + await snapshotAprovadoVigente(sb, "proj-1"); + + expect(chamadas.tabela).toBe("plano_voo_snapshots"); + expect(chamadas.eq).toContainEqual(["status", "aprovado"]); + expect(chamadas.eq).toContainEqual(["project_id", "proj-1"]); + }); + + it("desempata pelo approved_at mais recente, não pelo created_at", async () => { + const { sb, chamadas } = clientFalso({ id: "x" }); + await snapshotAprovadoVigente(sb, "proj-1"); + + // Se o arquivamento do plano anterior falhar, sobram dois 'aprovado'. + // Ordenar por approved_at desc + limit 1 faz o mais recente ganhar, em vez + // de um empate resolvido pelo humor do planner. + expect(chamadas.order).toEqual(["approved_at", { ascending: false }]); + expect(chamadas.limit).toBe(1); + }); +}); diff --git a/src/lib/plano-voo/snapshot-vigente.ts b/src/lib/plano-voo/snapshot-vigente.ts new file mode 100644 index 00000000..f14a574d --- /dev/null +++ b/src/lib/plano-voo/snapshot-vigente.ts @@ -0,0 +1,64 @@ +/** + * Qual é o plano de voo VIGENTE de um projeto? + * + * Vigente = o snapshot com `status = 'aprovado'`. O `approvePlanoVooV2Action` + * arquiva os aprovados anteriores (`status = 'arquivado'`), então no caminho + * feliz existe no máximo um — a ordenação por `approved_at` é defesa para o caso + * de esse UPDATE de arquivamento falhar: sobrando dois, ganha o mais recente, + * nunca um empate silencioso. + * + * ───────────────────────────────────────────────────────────────────────────── + * POR QUE ISTO EXISTE + * + * `cockpit_actions` guarda `snapshot_id`, mas o Cockpit lia as ações **por + * `project_id`**. Como aprovar um plano novo arquiva o anterior sem mexer nas + * ações dele, o segundo plano fazia o Cockpit mostrar 24 ações — 12 do plano + * arquivado misturadas com 12 do novo. E a "limpeza" que existia no approve + * apagava `snapshot_id = `, que nunca tem linha nenhuma: código + * morto apontando para o alvo errado. + * + * A saída é **ler**, não apagar: filtrar pelo snapshot vigente preserva o + * histórico — as ações antigas e o progresso que o dono marcou nelas continuam + * na tabela — e ainda resolve as ações órfãs (`snapshot_id` nulo) que sobram + * quando um admin exclui planos, já que a FK é `ON DELETE SET NULL`. + * + * Recebe o `SupabaseClient` por parâmetro (padrão de `auth/project-access.ts`): + * o Cockpit chama com o client do usuário, o motor de escalação do HTR chama + * com o service client. + */ +import type { SupabaseClient } from "@supabase/supabase-js"; + +/** + * O PREDICADO de "vigente", em um lugar só. + * + * Existe porque `lerMeta` (pacing) precisa do `html_payload` do plano vigente, e + * `snapshotAprovadoVigente` precisa do `id` — mesma pergunta, colunas diferentes. + * Escrever a query duas vezes é como o filtro de status se perde de novo: foi + * exatamente assim que `lerMeta` acabou lendo `version desc` sem status e + * chamando rascunho de "meta travada" nas 4 telas que a consomem (E5-B). + * + * Quem precisar de outra coluna passa por aqui — nunca remonta a query. + */ +export function queryPlanoVigente( + supabase: SupabaseClient, + projectId: string, + colunas: string, +) { + return supabase + .from("plano_voo_snapshots") + .select(colunas) + .eq("project_id", projectId) + .eq("status", "aprovado") + .order("approved_at", { ascending: false }) + .limit(1) + .maybeSingle(); +} + +export async function snapshotAprovadoVigente( + supabase: SupabaseClient, + projectId: string, +): Promise { + const { data } = await queryPlanoVigente(supabase, projectId, "id"); + + return (data as { id: string } | null)?.id ?? null; +} diff --git a/src/lib/regra-contains.test.ts b/src/lib/regra-contains.test.ts new file mode 100644 index 00000000..c53cdc30 --- /dev/null +++ b/src/lib/regra-contains.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { escaparParaIlike } from "./financial-document-service"; + +/** + * A regra por token contido vira `ILIKE '%token%'`. `%` e `_` são curingas no + * LIKE — se o pattern do cliente contiver um deles sem escape, a regra pega + * MAIS do que ele viu no preview. Erro pra mais numa regra ampla é exatamente + * o que varre movimentação de sócio para dentro da receita. + */ +describe("escaparParaIlike", () => { + it("deixa token comum intacto", () => { + expect(escaparParaIlike("MAQUININHA")).toBe("MAQUININHA"); + expect(escaparParaIlike("PIX RECEBIDO")).toBe("PIX RECEBIDO"); + }); + + it("escapa o curinga de múltiplos caracteres", () => { + expect(escaparParaIlike("100%")).toBe("100\\%"); + expect(escaparParaIlike("%%")).toBe("\\%\\%"); + }); + + it("escapa o curinga de um caractere", () => { + expect(escaparParaIlike("PIX_MOBILE")).toBe("PIX\\_MOBILE"); + }); + + it("escapa a barra invertida ANTES dos curingas", () => { + // Se `\` fosse escapada por último, escaparíamos os próprios escapes + // e o pattern sairia corrompido. + expect(escaparParaIlike("A\\B")).toBe("A\\\\B"); + expect(escaparParaIlike("A\\%B")).toBe("A\\\\\\%B"); + }); + + it("tolera vazio e nulo sem quebrar", () => { + expect(escaparParaIlike("")).toBe(""); + expect(escaparParaIlike(undefined as unknown as string)).toBe(""); + expect(escaparParaIlike(null as unknown as string)).toBe(""); + }); + + it("um token só de curingas não vira 'pega tudo'", () => { + // Sem escape, `%` viraria `%%%` = todo lançamento do projeto. + const escapado = escaparParaIlike("%"); + expect(`%${escapado}%`).toBe("%\\%%"); + expect(escapado).not.toBe("%"); + }); +}); diff --git a/src/types/supabase.ts b/src/types/supabase.ts index 5810a9a0..944c66c5 100644 --- a/src/types/supabase.ts +++ b/src/types/supabase.ts @@ -1378,6 +1378,7 @@ export type Database = { is_active: boolean | null is_global: boolean | null is_seed: boolean + linha_id: string | null match_type: string | null ocorrencias: number | null pattern: string @@ -1397,6 +1398,7 @@ export type Database = { is_active?: boolean | null is_global?: boolean | null is_seed?: boolean + linha_id?: string | null match_type?: string | null ocorrencias?: number | null pattern: string @@ -1416,6 +1418,7 @@ export type Database = { is_active?: boolean | null is_global?: boolean | null is_seed?: boolean + linha_id?: string | null match_type?: string | null ocorrencias?: number | null pattern?: string @@ -1427,6 +1430,13 @@ export type Database = { updated_at?: string } Relationships: [ + { + foreignKeyName: "categorization_rules_linha_id_fkey" + columns: ["linha_id"] + isOneToOne: false + referencedRelation: "linhas_operacionais" + referencedColumns: ["id"] + }, { foreignKeyName: "categorization_rules_project_id_fkey" columns: ["project_id"] @@ -2041,6 +2051,63 @@ export type Database = { }, ] } + client_socios: { + Row: { + ativo: boolean + cargo: string | null + client_id: string + cpf_hash: string | null + cpf_mascara: string | null + created_at: string + fonte: string + id: string + nome: string + participacao_percent: number | null + updated_at: string + } + Insert: { + ativo?: boolean + cargo?: string | null + client_id: string + cpf_hash?: string | null + cpf_mascara?: string | null + created_at?: string + fonte?: string + id?: string + nome: string + participacao_percent?: number | null + updated_at?: string + } + Update: { + ativo?: boolean + cargo?: string | null + client_id?: string + cpf_hash?: string | null + cpf_mascara?: string | null + created_at?: string + fonte?: string + id?: string + nome?: string + participacao_percent?: number | null + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "client_socios_client_id_fkey" + columns: ["client_id"] + isOneToOne: false + referencedRelation: "client_project_summary" + referencedColumns: ["client_id"] + }, + { + foreignKeyName: "client_socios_client_id_fkey" + columns: ["client_id"] + isOneToOne: false + referencedRelation: "clients" + referencedColumns: ["id"] + }, + ] + } clients: { Row: { contador_email: string | null @@ -3455,6 +3522,8 @@ export type Database = { dre_monthly_snapshots: { Row: { created_by: string | null + desatualizado_em: string | null + desatualizado_motivo: Json | null dre_detalhado: Json frozen_at: string id: string @@ -3471,6 +3540,8 @@ export type Database = { } Insert: { created_by?: string | null + desatualizado_em?: string | null + desatualizado_motivo?: Json | null dre_detalhado?: Json frozen_at?: string id?: string @@ -3487,6 +3558,8 @@ export type Database = { } Update: { created_by?: string | null + desatualizado_em?: string | null + desatualizado_motivo?: Json | null dre_detalhado?: Json frozen_at?: string id?: string @@ -3656,11 +3729,13 @@ export type Database = { alimenta_projecao: boolean categorizavel: boolean category_id: string + comportamento: string | null created_at: string created_by: string | null descricao: string | null destino: string gera_sinteticos: string[] | null + grupo_rotulo: string | null hibrido: boolean id: string impeditivo_nivel: string | null @@ -3677,11 +3752,13 @@ export type Database = { alimenta_projecao?: boolean categorizavel?: boolean category_id: string + comportamento?: string | null created_at?: string created_by?: string | null descricao?: string | null destino: string gera_sinteticos?: string[] | null + grupo_rotulo?: string | null hibrido?: boolean id: string impeditivo_nivel?: string | null @@ -3698,11 +3775,13 @@ export type Database = { alimenta_projecao?: boolean categorizavel?: boolean category_id?: string + comportamento?: string | null created_at?: string created_by?: string | null descricao?: string | null destino?: string gera_sinteticos?: string[] | null + grupo_rotulo?: string | null hibrido?: boolean id?: string impeditivo_nivel?: string | null @@ -4771,6 +4850,181 @@ export type Database = { }, ] } + giro_monthly_snapshots: { + Row: { + avisos: Json + buraco_efeito_eficiencia: number | null + buraco_efeito_volume: number | null + ciclo_financeiro: number | null + cobertura_antecipacao: number | null + cobertura_aporte: number | null + cobertura_bancaria_pct: number | null + cobertura_emprestimo: number | null + cod_diario: number | null + compras_mes: number | null + cp_fonte: string | null + cp_valor: number | null + cr_fonte: string | null + cr_valor: number | null + created_by: string | null + custo_giro_desagio: number | null + custo_giro_juros: number | null + delta_buraco: number | null + delta_ncg: number | null + desatualizado_em: string | null + desatualizado_motivo: Json | null + estoque_fonte: string | null + estoque_valor: number | null + frozen_at: string + id: string + informado_em: string | null + informado_por: string | null + invalidated_at: string | null + invalidated_reason: string | null + mes_referencia: string + ncg: number | null + ncg_efeito_eficiencia: number | null + ncg_efeito_volume: number | null + nivel_efetivo: number + pct_categorizado: number | null + perfil_calendario: Json + pme: number | null + pmp: number | null + pmr: number | null + profundidade: number | null + project_id: string + razao_buraco: number | null + razao_ncg: number | null + receita_mes: number | null + saldo_inicio_mes: number | null + vale_dia: number | null + vale_valor: number | null + version: number + } + Insert: { + avisos?: Json + buraco_efeito_eficiencia?: number | null + buraco_efeito_volume?: number | null + ciclo_financeiro?: number | null + cobertura_antecipacao?: number | null + cobertura_aporte?: number | null + cobertura_bancaria_pct?: number | null + cobertura_emprestimo?: number | null + cod_diario?: number | null + compras_mes?: number | null + cp_fonte?: string | null + cp_valor?: number | null + cr_fonte?: string | null + cr_valor?: number | null + created_by?: string | null + custo_giro_desagio?: number | null + custo_giro_juros?: number | null + delta_buraco?: number | null + delta_ncg?: number | null + desatualizado_em?: string | null + desatualizado_motivo?: Json | null + estoque_fonte?: string | null + estoque_valor?: number | null + frozen_at?: string + id?: string + informado_em?: string | null + informado_por?: string | null + invalidated_at?: string | null + invalidated_reason?: string | null + mes_referencia: string + ncg?: number | null + ncg_efeito_eficiencia?: number | null + ncg_efeito_volume?: number | null + nivel_efetivo?: number + pct_categorizado?: number | null + perfil_calendario?: Json + pme?: number | null + pmp?: number | null + pmr?: number | null + profundidade?: number | null + project_id: string + razao_buraco?: number | null + razao_ncg?: number | null + receita_mes?: number | null + saldo_inicio_mes?: number | null + vale_dia?: number | null + vale_valor?: number | null + version?: number + } + Update: { + avisos?: Json + buraco_efeito_eficiencia?: number | null + buraco_efeito_volume?: number | null + ciclo_financeiro?: number | null + cobertura_antecipacao?: number | null + cobertura_aporte?: number | null + cobertura_bancaria_pct?: number | null + cobertura_emprestimo?: number | null + cod_diario?: number | null + compras_mes?: number | null + cp_fonte?: string | null + cp_valor?: number | null + cr_fonte?: string | null + cr_valor?: number | null + created_by?: string | null + custo_giro_desagio?: number | null + custo_giro_juros?: number | null + delta_buraco?: number | null + delta_ncg?: number | null + desatualizado_em?: string | null + desatualizado_motivo?: Json | null + estoque_fonte?: string | null + estoque_valor?: number | null + frozen_at?: string + id?: string + informado_em?: string | null + informado_por?: string | null + invalidated_at?: string | null + invalidated_reason?: string | null + mes_referencia?: string + ncg?: number | null + ncg_efeito_eficiencia?: number | null + ncg_efeito_volume?: number | null + nivel_efetivo?: number + pct_categorizado?: number | null + perfil_calendario?: Json + pme?: number | null + pmp?: number | null + pmr?: number | null + profundidade?: number | null + project_id?: string + razao_buraco?: number | null + razao_ncg?: number | null + receita_mes?: number | null + saldo_inicio_mes?: number | null + vale_dia?: number | null + vale_valor?: number | null + version?: number + } + Relationships: [ + { + foreignKeyName: "giro_monthly_snapshots_project_id_fkey" + columns: ["project_id"] + isOneToOne: false + referencedRelation: "projects" + referencedColumns: ["id"] + }, + { + foreignKeyName: "giro_monthly_snapshots_project_id_fkey" + columns: ["project_id"] + isOneToOne: false + referencedRelation: "projects_with_client" + referencedColumns: ["id"] + }, + { + foreignKeyName: "giro_monthly_snapshots_project_id_fkey" + columns: ["project_id"] + isOneToOne: false + referencedRelation: "vw_company_context" + referencedColumns: ["project_id"] + }, + ] + } health_scores: { Row: { calculated_at: string | null @@ -5711,6 +5965,84 @@ export type Database = { }, ] } + linhas_operacionais: { + Row: { + ativo: boolean + created_at: string + created_by: string | null + id: string + nome: string + ordem: number | null + project_id: string + rateio_metodo: string + rateio_peso: number | null + tipo: string + updated_at: string + } + Insert: { + ativo?: boolean + created_at?: string + created_by?: string | null + id?: string + nome: string + ordem?: number | null + project_id: string + rateio_metodo?: string + rateio_peso?: number | null + tipo?: string + updated_at?: string + } + Update: { + ativo?: boolean + created_at?: string + created_by?: string | null + id?: string + nome?: string + ordem?: number | null + project_id?: string + rateio_metodo?: string + rateio_peso?: number | null + tipo?: string + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "linhas_operacionais_created_by_fkey" + columns: ["created_by"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + { + foreignKeyName: "linhas_operacionais_created_by_fkey" + columns: ["created_by"] + isOneToOne: false + referencedRelation: "v_sdr_pipeline_stats" + referencedColumns: ["sdr_id"] + }, + { + foreignKeyName: "linhas_operacionais_project_id_fkey" + columns: ["project_id"] + isOneToOne: false + referencedRelation: "projects" + referencedColumns: ["id"] + }, + { + foreignKeyName: "linhas_operacionais_project_id_fkey" + columns: ["project_id"] + isOneToOne: false + referencedRelation: "projects_with_client" + referencedColumns: ["id"] + }, + { + foreignKeyName: "linhas_operacionais_project_id_fkey" + columns: ["project_id"] + isOneToOne: false + referencedRelation: "vw_company_context" + referencedColumns: ["project_id"] + }, + ] + } lite_categories: { Row: { color: string | null @@ -6892,6 +7224,7 @@ export type Database = { created_at: string email: string empresa: string | null + guia_visto: Json id: string must_change_password: boolean | null nome: string @@ -6906,6 +7239,7 @@ export type Database = { created_at?: string email: string empresa?: string | null + guia_visto?: Json id: string must_change_password?: boolean | null nome: string @@ -6920,6 +7254,7 @@ export type Database = { created_at?: string email?: string empresa?: string | null + guia_visto?: Json id?: string must_change_password?: boolean | null nome?: string @@ -7795,6 +8130,99 @@ export type Database = { }, ] } + recebivel_parcelas: { + Row: { + created_at: string + created_by: string | null + data_esperada: string + data_real: string | null + descritor: string | null + id: string + mes_origem: string + n_parcela: number + origem_transaction_id: string | null + project_id: string + status: string + total_parcelas: number + transaction_id_conciliada: string | null + updated_at: string + valor_esperado: number + valor_real: number | null + } + Insert: { + created_at?: string + created_by?: string | null + data_esperada: string + data_real?: string | null + descritor?: string | null + id?: string + mes_origem: string + n_parcela: number + origem_transaction_id?: string | null + project_id: string + status?: string + total_parcelas: number + transaction_id_conciliada?: string | null + updated_at?: string + valor_esperado: number + valor_real?: number | null + } + Update: { + created_at?: string + created_by?: string | null + data_esperada?: string + data_real?: string | null + descritor?: string | null + id?: string + mes_origem?: string + n_parcela?: number + origem_transaction_id?: string | null + project_id?: string + status?: string + total_parcelas?: number + transaction_id_conciliada?: string | null + updated_at?: string + valor_esperado?: number + valor_real?: number | null + } + Relationships: [ + { + foreignKeyName: "recebivel_parcelas_conciliada_fk" + columns: ["transaction_id_conciliada"] + isOneToOne: false + referencedRelation: "transactions" + referencedColumns: ["id"] + }, + { + foreignKeyName: "recebivel_parcelas_origem_fk" + columns: ["origem_transaction_id"] + isOneToOne: false + referencedRelation: "transactions" + referencedColumns: ["id"] + }, + { + foreignKeyName: "recebivel_parcelas_project_fk" + columns: ["project_id"] + isOneToOne: false + referencedRelation: "projects" + referencedColumns: ["id"] + }, + { + foreignKeyName: "recebivel_parcelas_project_fk" + columns: ["project_id"] + isOneToOne: false + referencedRelation: "projects_with_client" + referencedColumns: ["id"] + }, + { + foreignKeyName: "recebivel_parcelas_project_fk" + columns: ["project_id"] + isOneToOne: false + referencedRelation: "vw_company_context" + referencedColumns: ["project_id"] + }, + ] + } sdr_comissoes: { Row: { client_id: string | null @@ -8319,12 +8747,97 @@ export type Database = { }, ] } + transacao_linha_split: { + Row: { + created_at: string + created_by: string | null + id: string + linha_id: string + project_id: string + transaction_id: string + valor: number + } + Insert: { + created_at?: string + created_by?: string | null + id?: string + linha_id: string + project_id: string + transaction_id: string + valor: number + } + Update: { + created_at?: string + created_by?: string | null + id?: string + linha_id?: string + project_id?: string + transaction_id?: string + valor?: number + } + Relationships: [ + { + foreignKeyName: "transacao_linha_split_created_by_fkey" + columns: ["created_by"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + { + foreignKeyName: "transacao_linha_split_created_by_fkey" + columns: ["created_by"] + isOneToOne: false + referencedRelation: "v_sdr_pipeline_stats" + referencedColumns: ["sdr_id"] + }, + { + foreignKeyName: "transacao_linha_split_linha_id_fkey" + columns: ["linha_id"] + isOneToOne: false + referencedRelation: "linhas_operacionais" + referencedColumns: ["id"] + }, + { + foreignKeyName: "transacao_linha_split_project_id_fkey" + columns: ["project_id"] + isOneToOne: false + referencedRelation: "projects" + referencedColumns: ["id"] + }, + { + foreignKeyName: "transacao_linha_split_project_id_fkey" + columns: ["project_id"] + isOneToOne: false + referencedRelation: "projects_with_client" + referencedColumns: ["id"] + }, + { + foreignKeyName: "transacao_linha_split_project_id_fkey" + columns: ["project_id"] + isOneToOne: false + referencedRelation: "vw_company_context" + referencedColumns: ["project_id"] + }, + { + foreignKeyName: "transacao_linha_split_transaction_id_fkey" + columns: ["transaction_id"] + isOneToOne: false + referencedRelation: "transactions" + referencedColumns: ["id"] + }, + ] + } transactions: { Row: { ano_exercicio: number | null categoria_id: string | null categoria_nome: string | null conta_numero: string | null + contraparte_company_id: string | null + contraparte_confianca: string | null + contraparte_porta: string | null + contraparte_socio_id: string | null + contraparte_tipo: string | null contrato_id: string | null created_at: string credito: number | null @@ -8338,6 +8851,7 @@ export type Database = { is_demo: boolean lancamento_original: string lancamento_tratado: string + linha_id: string | null manually_reviewed: boolean | null project_id: string row_index: number | null @@ -8353,6 +8867,11 @@ export type Database = { categoria_id?: string | null categoria_nome?: string | null conta_numero?: string | null + contraparte_company_id?: string | null + contraparte_confianca?: string | null + contraparte_porta?: string | null + contraparte_socio_id?: string | null + contraparte_tipo?: string | null contrato_id?: string | null created_at?: string credito?: number | null @@ -8366,6 +8885,7 @@ export type Database = { is_demo?: boolean lancamento_original: string lancamento_tratado: string + linha_id?: string | null manually_reviewed?: boolean | null project_id: string row_index?: number | null @@ -8381,6 +8901,11 @@ export type Database = { categoria_id?: string | null categoria_nome?: string | null conta_numero?: string | null + contraparte_company_id?: string | null + contraparte_confianca?: string | null + contraparte_porta?: string | null + contraparte_socio_id?: string | null + contraparte_tipo?: string | null contrato_id?: string | null created_at?: string credito?: number | null @@ -8394,6 +8919,7 @@ export type Database = { is_demo?: boolean lancamento_original?: string lancamento_tratado?: string + linha_id?: string | null manually_reviewed?: boolean | null project_id?: string row_index?: number | null @@ -8405,6 +8931,20 @@ export type Database = { tipo_origem?: string } Relationships: [ + { + foreignKeyName: "transactions_contraparte_company_id_fkey" + columns: ["contraparte_company_id"] + isOneToOne: false + referencedRelation: "companies" + referencedColumns: ["id"] + }, + { + foreignKeyName: "transactions_contraparte_socio_id_fkey" + columns: ["contraparte_socio_id"] + isOneToOne: false + referencedRelation: "client_socios" + referencedColumns: ["id"] + }, { foreignKeyName: "transactions_contrato_id_fkey" columns: ["contrato_id"] @@ -8419,6 +8959,13 @@ export type Database = { referencedRelation: "financial_documents" referencedColumns: ["id"] }, + { + foreignKeyName: "transactions_linha_id_fkey" + columns: ["linha_id"] + isOneToOne: false + referencedRelation: "linhas_operacionais" + referencedColumns: ["id"] + }, { foreignKeyName: "transactions_project_id_fkey" columns: ["project_id"] @@ -8501,6 +9048,55 @@ export type Database = { } Relationships: [] } + project_access: { + Row: { + granted_at: string | null + granted_by: string | null + id: string | null + project_id: string | null + role: string | null + user_id: string | null + } + Insert: { + granted_at?: string | null + granted_by?: string | null + id?: string | null + project_id?: string | null + role?: string | null + user_id?: string | null + } + Update: { + granted_at?: string | null + granted_by?: string | null + id?: string | null + project_id?: string | null + role?: string | null + user_id?: string | null + } + Relationships: [ + { + foreignKeyName: "project_members_project_id_fkey" + columns: ["project_id"] + isOneToOne: false + referencedRelation: "projects" + referencedColumns: ["id"] + }, + { + foreignKeyName: "project_members_project_id_fkey" + columns: ["project_id"] + isOneToOne: false + referencedRelation: "projects_with_client" + referencedColumns: ["id"] + }, + { + foreignKeyName: "project_members_project_id_fkey" + columns: ["project_id"] + isOneToOne: false + referencedRelation: "vw_company_context" + referencedColumns: ["project_id"] + }, + ] + } projects_with_client: { Row: { client_cnpj: string | null @@ -8759,8 +9355,6 @@ export type Database = { } vw_company_context: { Row: { - analyses_completed: number | null - analyses_total: number | null area_atuacao: string | null client_name: string | null client_nome_fantasia: string | null @@ -9162,8 +9756,10 @@ export type Database = { Returns: { categoria_id: string categoria_nome: string + excecoes: number lancamento_tratado: string ocorrencias: number + pendentes: number sentido: string subcategoria_id: string subcategoria_nome: string diff --git a/supabase/migrations/20260818000000_categorization_rules_match_type.sql b/supabase/migrations/20260818000000_categorization_rules_match_type.sql new file mode 100644 index 00000000..5afe3dad --- /dev/null +++ b/supabase/migrations/20260818000000_categorization_rules_match_type.sql @@ -0,0 +1,44 @@ +-- Regra de categorização por TOKEN CONTIDO. +-- +-- POR QUÊ +-- As 49 regras genéricas da casa (`categorization-rules.ts:REGRAS_CATEGORIZACAO`) +-- são REGEX. As regras que o cliente cria eram só igualdade exata +-- (`.eq("lancamento_tratado", pattern)`). O sistema sabia casar por padrão, mas +-- só para si mesmo. +-- +-- Medido em produção (2026-08-17, `scripts/diag-tokens-cauda.ts`): +-- PRUDENTE & SILVA — 1.358 grupos de 1 ocorrência, cada um uma decisão. +-- Destes, 1.157 (85%) contêm o token "MAQUININHA", com 100% de pureza de +-- sentido: são recebimentos de 1.157 clientes diferentes pelo MESMO canal, +-- todos venda no balcão. 1.157 decisões idênticas que deveriam ser uma. +-- Com token: 1.808 decisões caem para ~529 (-71%). +-- +-- RETROCOMPATIBILIDADE +-- `DEFAULT 'exact'` faz toda regra existente continuar casando por igualdade, +-- byte a byte. Nada muda de comportamento até alguém criar uma regra 'contains'. +-- +-- SEGURANÇA (a trava que acompanha esta coluna no código) +-- Regra 'contains' só se aplica a linhas com `contraparte_tipo IS NULL` — ver +-- `filtroDeRegra()` em `financial-document-service.ts`. Sem isso, uma regra +-- ampla em entradas varreria aporte de sócio para dentro da receita e +-- contaminaria margem, ponto de equilíbrio e a meta do Plano de Voo. + +ALTER TABLE "public"."categorization_rules" + ADD COLUMN IF NOT EXISTS "match_type" "text" NOT NULL DEFAULT 'exact'; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'categorization_rules_match_type_chk' + ) THEN + ALTER TABLE "public"."categorization_rules" + ADD CONSTRAINT "categorization_rules_match_type_chk" + CHECK ("match_type" IN ('exact', 'contains')); + END IF; +END $$; + +COMMENT ON COLUMN "public"."categorization_rules"."match_type" IS + 'Como o pattern casa com transactions.lancamento_tratado: exact = igualdade (padrão histórico) | contains = ILIKE %pattern%. Regra contains só se aplica a linhas SEM contraparte detectada (contraparte_tipo IS NULL) — ver filtroDeRegra() em financial-document-service.ts.'; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260818010000_saas_dono_opera_o_proprio_plano.sql b/supabase/migrations/20260818010000_saas_dono_opera_o_proprio_plano.sql new file mode 100644 index 00000000..ce55b9b1 --- /dev/null +++ b/supabase/migrations/20260818010000_saas_dono_opera_o_proprio_plano.sql @@ -0,0 +1,71 @@ +-- ============================================================================= +-- SaaS sem consultor — fatia 1: o dono gera e trava a PRÓPRIA meta +-- Lei: docs/atros-v3/saas-sem-consultor.md (decisão Lucas, 18/ago/2026) +-- Método: docs/atros-v3/metodo.md (F2 — um leitor por vez, grep-de-prova na hora) +-- ============================================================================= +-- +-- O QUE MUDA E POR QUÊ +-- +-- `canOperateProject` foi invertida no app: membro do projeto opera, e a +-- autoridade vem de ser dono — não do degrau na hierarquia global. Sozinha, essa +-- inversão é INERTE: estas duas policies fixam `pm.role = 'consultor'`, então o +-- dono passaria no app e apanharia da RLS. São exatamente os dois INSERTs do +-- caminho de escrita do Plano de Voo: +-- +-- plano_voo_snapshots_insert → gerar a meta +-- cockpit_actions_insert → aprovar (grava as 12 semanas) +-- +-- Nenhuma outra policy dessas tabelas dá caminho ao dono (as demais são +-- `is_admin` puro ou membership sem filtro), então o par abaixo é o bloqueio +-- inteiro. Verificado policy a policy contra a baseline. +-- +-- O que NÃO muda aqui: SELECT e UPDATE das duas tabelas já aceitam qualquer +-- membro (sem filtro de papel) — ficam intactos. E o DELETE segue admin-only. +-- +-- ESCOPO: só o Plano de Voo. O colapso do bypass de 3 papéis (97 policies) e o +-- resto do balde `role='consultor'` (34 policies) vêm em migrations próprias, +-- uma família por vez. +-- ============================================================================= + +-- --------------------------------------------------------------------------- +-- 1. plano_voo_snapshots — gerar a meta +-- --------------------------------------------------------------------------- +DROP POLICY IF EXISTS "plano_voo_snapshots_insert" ON "public"."plano_voo_snapshots"; + +CREATE POLICY "plano_voo_snapshots_insert" ON "public"."plano_voo_snapshots" + FOR INSERT + WITH CHECK ( + public.is_admin(auth.uid()) + OR EXISTS ( + SELECT 1 FROM public.project_members pm + WHERE pm.project_id = plano_voo_snapshots.project_id + AND pm.user_id = auth.uid() + ) + ); + +COMMENT ON POLICY "plano_voo_snapshots_insert" ON "public"."plano_voo_snapshots" IS + 'SaaS sem consultor: qualquer MEMBRO do projeto gera a meta — o dono inclusive. O gate de qualidade (12 meses da janela fechados) é do app (plano-voo-gate.ts), não da RLS.'; + +-- --------------------------------------------------------------------------- +-- 2. cockpit_actions — aprovar (as 12 semanas do plano) +-- --------------------------------------------------------------------------- +DROP POLICY IF EXISTS "cockpit_actions_insert" ON "public"."cockpit_actions"; + +CREATE POLICY "cockpit_actions_insert" ON "public"."cockpit_actions" + FOR INSERT + WITH CHECK ( + public.is_admin(auth.uid()) + OR EXISTS ( + SELECT 1 FROM public.project_members pm + WHERE pm.project_id = cockpit_actions.project_id + AND pm.user_id = auth.uid() + ) + ); + +COMMENT ON POLICY "cockpit_actions_insert" ON "public"."cockpit_actions" IS + 'SaaS sem consultor: qualquer MEMBRO do projeto grava as ações do plano aprovado.'; + +-- --------------------------------------------------------------------------- +-- PostgREST: recarregar o schema cache +-- --------------------------------------------------------------------------- +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260818020000_saas_vinculo_manda_nao_papel.sql b/supabase/migrations/20260818020000_saas_vinculo_manda_nao_papel.sql new file mode 100644 index 00000000..c02d001f --- /dev/null +++ b/supabase/migrations/20260818020000_saas_vinculo_manda_nao_papel.sql @@ -0,0 +1,1021 @@ +-- ============================================================================= +-- SaaS sem consultor — fatia 2+3: a autoridade vem do VÍNCULO, não do papel +-- Lei: docs/atros-v3/saas-sem-consultor.md · Método: docs/atros-v3/metodo.md +-- +-- GERADA por scripts/saas/gerar-migration-fatia2.ts (que usa o transformador em +-- transformar-policies.ts). Não editar à mão: reveja o gerador e regere, senão +-- o SQL e o script divergem. +-- ============================================================================= +-- +-- O QUE MUDA +-- +-- 1. Nasce a view `project_access` — igual à `client_project_access`, SEM o +-- filtro `role = 'cliente'`. É o que o modelo novo pede: consultor e cliente +-- têm o MESMO acesso, então a view que 97 policies consomem não pode mais +-- excluir o consultor. +-- +-- 2. O disjunto de bypass por papel GLOBAL sai das policies +-- (`profiles.role IN ('owner','admin','consultor')`). `is_admin()` já cobre +-- owner/admin; o consultor passa a entrar pelo vínculo — mesma porta do +-- cliente, que é exatamente a decisão de 18/ago. +-- +-- 3. `AND pm.role = 'consultor'` sai dos testes de membership: qualquer MEMBRO +-- do projeto opera. Era o padrão que a lei não tinha mapeado e o que de fato +-- travava o dono (36 policies em 22 tabelas). +-- +-- `client_project_access` FICA VIVA (método F2: repoint antes de deletar). Ela +-- deixa de ter leitor em policy, mas ainda é lida por funções e pelo código — +-- a aposentadoria é passo próprio, com grep-de-prova na hora. +-- +-- FORA desta migration, de propósito: +-- · Comercial/leads (`commercial_*`, `leads_owner_insert`) — staff-only por +-- desenho e módulo desligado; remover o bypass deixaria a policy sem nenhum +-- disjunto, ou seja, negaria tudo. +-- · `client_project_access_deprecated` — tabela morta, policies próprias. +-- · as policies que liberam por "estar logado" (`auth.role() = 'authenticated'`) +-- — furo de multi-tenant, escopo próprio e mais urgente que este. +-- +-- Policies reescritas nesta migration: 139 +-- ============================================================================= + + +-- --------------------------------------------------------------------------- +-- 1. A view sem filtro de papel +-- +-- Espelha `client_project_access` no que importa: MESMAS colunas +-- (`created_at AS granted_at`), MESMO owner e SEM `security_invoker` — a view +-- roda como owner e por isso não bate na RLS de `project_members` (que só +-- permite `user_id = auth.uid()`). Sem isso, toda policy que a consome passaria +-- a enxergar apenas o próprio vínculo e quebraria em silêncio. +-- +-- `role` entra como coluna (a irmã não tem) para quem precise distinguir dono +-- de convidado sem ir na tabela — a autorização NÃO usa. +-- --------------------------------------------------------------------------- +CREATE OR REPLACE VIEW "public"."project_access" AS + SELECT "id", + "user_id", + "project_id", + "role", + "granted_by", + "created_at" AS "granted_at" + FROM "public"."project_members"; + +ALTER VIEW "public"."project_access" OWNER TO "postgres"; + +COMMENT ON VIEW "public"."project_access" IS 'Vínculo usuário×projeto SEM filtro de papel — cliente (dono) e consultor (convidado) têm o mesmo acesso (SaaS sem consultor, 18/ago/2026). Substitui client_project_access nas policies. NÃO usar para inserções — use project_members.'; + +GRANT ALL ON TABLE "public"."project_access" TO "anon"; +GRANT ALL ON TABLE "public"."project_access" TO "authenticated"; +GRANT ALL ON TABLE "public"."project_access" TO "service_role"; + + +-- --------------------------------------------------------------------------- +-- 2. As policies +-- --------------------------------------------------------------------------- + +-- ····· bank_account_balance_anchors ····· + +DROP POLICY IF EXISTS "balance_anchors_insert" ON "public"."bank_account_balance_anchors"; +CREATE POLICY "balance_anchors_insert" ON "public"."bank_account_balance_anchors" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "balance_anchors_select" ON "public"."bank_account_balance_anchors"; +CREATE POLICY "balance_anchors_select" ON "public"."bank_account_balance_anchors" FOR SELECT USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +-- ····· bank_accounts ····· + +DROP POLICY IF EXISTS "bank_accounts_cliente_insert" ON "public"."bank_accounts"; +CREATE POLICY "bank_accounts_cliente_insert" ON "public"."bank_accounts" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("company_id" IN ( SELECT "co"."id" + FROM (("public"."companies" "co" + JOIN "public"."projects" "pr" ON (("pr"."client_id" = "co"."client_id"))) + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + WHERE ("pa"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "bank_accounts_cliente_update" ON "public"."bank_accounts"; +CREATE POLICY "bank_accounts_cliente_update" ON "public"."bank_accounts" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR ("company_id" IN ( SELECT "co"."id" + FROM (("public"."companies" "co" + JOIN "public"."projects" "pr" ON (("pr"."client_id" = "co"."client_id"))) + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + WHERE ("pa"."user_id" = "auth"."uid"())))) WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("company_id" IN ( SELECT "co"."id" + FROM (("public"."companies" "co" + JOIN "public"."projects" "pr" ON (("pr"."client_id" = "co"."client_id"))) + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + WHERE ("pa"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "bank_accounts_insert" ON "public"."bank_accounts"; +CREATE POLICY "bank_accounts_insert" ON "public"."bank_accounts" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"())); + +DROP POLICY IF EXISTS "bank_accounts_select" ON "public"."bank_accounts"; +CREATE POLICY "bank_accounts_select" ON "public"."bank_accounts" FOR SELECT USING ("public"."is_admin"("auth"."uid"()) + OR ("company_id" IN ( SELECT "co"."id" + FROM (("public"."companies" "co" + JOIN "public"."projects" "pr" ON (("pr"."client_id" = "co"."client_id"))) + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + WHERE ("pa"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "bank_accounts_update" ON "public"."bank_accounts"; +CREATE POLICY "bank_accounts_update" ON "public"."bank_accounts" FOR UPDATE USING ("public"."is_admin"("auth"."uid"())); + +-- ····· bank_balances ····· + +DROP POLICY IF EXISTS "bank_balances_insert" ON "public"."bank_balances"; +CREATE POLICY "bank_balances_insert" ON "public"."bank_balances" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR (EXISTS ( SELECT 1 + FROM "public"."project_members" "pm" + WHERE (("pm"."project_id" = "bank_balances"."project_id") AND ("pm"."user_id" = "auth"."uid"()))))); + +-- ····· card_acquirer_rates ····· + +DROP POLICY IF EXISTS "card_rates_delete" ON "public"."card_acquirer_rates"; +CREATE POLICY "card_rates_delete" ON "public"."card_acquirer_rates" FOR DELETE USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "card_rates_insert" ON "public"."card_acquirer_rates"; +CREATE POLICY "card_rates_insert" ON "public"."card_acquirer_rates" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "card_rates_select" ON "public"."card_acquirer_rates"; +CREATE POLICY "card_rates_select" ON "public"."card_acquirer_rates" FOR SELECT USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "card_rates_update" ON "public"."card_acquirer_rates"; +CREATE POLICY "card_rates_update" ON "public"."card_acquirer_rates" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +-- ····· cash_flow_params ····· + +DROP POLICY IF EXISTS "cash_flow_params_insert" ON "public"."cash_flow_params"; +CREATE POLICY "cash_flow_params_insert" ON "public"."cash_flow_params" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR (EXISTS ( SELECT 1 + FROM "public"."project_members" "pm" + WHERE (("pm"."project_id" = "cash_flow_params"."project_id") AND ("pm"."user_id" = "auth"."uid"()))))); + +DROP POLICY IF EXISTS "cash_flow_params_update" ON "public"."cash_flow_params"; +CREATE POLICY "cash_flow_params_update" ON "public"."cash_flow_params" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR (EXISTS ( SELECT 1 + FROM "public"."project_members" "pm" + WHERE (("pm"."project_id" = "cash_flow_params"."project_id") AND ("pm"."user_id" = "auth"."uid"()))))); + +-- ····· categorization_patterns ····· + +DROP POLICY IF EXISTS "categorization_patterns_insert" ON "public"."categorization_patterns"; +CREATE POLICY "categorization_patterns_insert" ON "public"."categorization_patterns" FOR INSERT WITH CHECK (public.is_admin(auth.uid())); + +DROP POLICY IF EXISTS "categorization_patterns_update" ON "public"."categorization_patterns"; +CREATE POLICY "categorization_patterns_update" ON "public"."categorization_patterns" FOR UPDATE USING (public.is_admin(auth.uid())); + +-- ····· client_documents ····· + +DROP POLICY IF EXISTS "client_documents_insert" ON "public"."client_documents"; +CREATE POLICY "client_documents_insert" ON "public"."client_documents" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("client_id" IN ( SELECT "p"."client_id" + FROM ("public"."projects" "p" + JOIN "public"."project_members" "pm" ON (("pm"."project_id" = "p"."id"))) + WHERE (("pm"."user_id" = "auth"."uid"()))))); + +DROP POLICY IF EXISTS "client_documents_update" ON "public"."client_documents"; +CREATE POLICY "client_documents_update" ON "public"."client_documents" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR ("client_id" IN ( SELECT "p"."client_id" + FROM ("public"."projects" "p" + JOIN "public"."project_members" "pm" ON (("pm"."project_id" = "p"."id"))) + WHERE (("pm"."user_id" = "auth"."uid"()))))); + +-- ····· client_onboarding ····· + +DROP POLICY IF EXISTS "client_onboarding_insert" ON "public"."client_onboarding"; +CREATE POLICY "client_onboarding_insert" ON "public"."client_onboarding" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("client_id" IN ( SELECT "p"."client_id" + FROM ("public"."projects" "p" + JOIN "public"."project_members" "pm" ON (("pm"."project_id" = "p"."id"))) + WHERE (("pm"."user_id" = "auth"."uid"()))))); + +-- ····· client_socios ····· + +DROP POLICY IF EXISTS "client_socios_insert" ON "public"."client_socios"; +CREATE POLICY "client_socios_insert" ON "public"."client_socios" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"())); + +DROP POLICY IF EXISTS "client_socios_select" ON "public"."client_socios"; +CREATE POLICY "client_socios_select" ON "public"."client_socios" FOR SELECT USING ("public"."is_admin"("auth"."uid"()) + OR ("client_id" IN ( SELECT "pr"."client_id" + FROM ("public"."projects" "pr" + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + WHERE ("pa"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "client_socios_update" ON "public"."client_socios"; +CREATE POLICY "client_socios_update" ON "public"."client_socios" FOR UPDATE USING ("public"."is_admin"("auth"."uid"())); + +-- ····· clients ····· + +DROP POLICY IF EXISTS "clients_update" ON "public"."clients"; +CREATE POLICY "clients_update" ON "public"."clients" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR ("id" IN ( SELECT "p"."client_id" + FROM ("public"."projects" "p" + JOIN "public"."project_members" "pm" ON (("pm"."project_id" = "p"."id"))) + WHERE (("pm"."user_id" = "auth"."uid"()))))); + +-- ····· companies ····· + +DROP POLICY IF EXISTS "companies_cliente_insert" ON "public"."companies"; +CREATE POLICY "companies_cliente_insert" ON "public"."companies" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("client_id" IN ( SELECT "pr"."client_id" + FROM ("public"."projects" "pr" + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + WHERE ("pa"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "companies_cliente_update" ON "public"."companies"; +CREATE POLICY "companies_cliente_update" ON "public"."companies" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR ("client_id" IN ( SELECT "pr"."client_id" + FROM ("public"."projects" "pr" + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + WHERE ("pa"."user_id" = "auth"."uid"())))) WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("client_id" IN ( SELECT "pr"."client_id" + FROM ("public"."projects" "pr" + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + WHERE ("pa"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "companies_insert" ON "public"."companies"; +CREATE POLICY "companies_insert" ON "public"."companies" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"())); + +DROP POLICY IF EXISTS "companies_select" ON "public"."companies"; +CREATE POLICY "companies_select" ON "public"."companies" FOR SELECT USING ("public"."is_admin"("auth"."uid"()) + OR ("client_id" IN ( SELECT "pr"."client_id" + FROM ("public"."projects" "pr" + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + WHERE ("pa"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "companies_update" ON "public"."companies"; +CREATE POLICY "companies_update" ON "public"."companies" FOR UPDATE USING ("public"."is_admin"("auth"."uid"())); + +-- ····· company_ai_dossiers ····· + +DROP POLICY IF EXISTS "company_ai_dossiers_delete" ON "public"."company_ai_dossiers"; +CREATE POLICY "company_ai_dossiers_delete" ON "public"."company_ai_dossiers" FOR DELETE USING (public.is_admin(auth.uid())); + +DROP POLICY IF EXISTS "company_ai_dossiers_insert" ON "public"."company_ai_dossiers"; +CREATE POLICY "company_ai_dossiers_insert" ON "public"."company_ai_dossiers" FOR INSERT WITH CHECK (public.is_admin(auth.uid()) + OR project_id IN ( + SELECT project_access.project_id FROM public.project_access + WHERE project_access.user_id = auth.uid() + )); + +DROP POLICY IF EXISTS "company_ai_dossiers_select" ON "public"."company_ai_dossiers"; +CREATE POLICY "company_ai_dossiers_select" ON "public"."company_ai_dossiers" FOR SELECT USING (public.is_admin(auth.uid()) + OR project_id IN ( + SELECT project_access.project_id FROM public.project_access + WHERE project_access.user_id = auth.uid() + )); + +DROP POLICY IF EXISTS "company_ai_dossiers_update" ON "public"."company_ai_dossiers"; +CREATE POLICY "company_ai_dossiers_update" ON "public"."company_ai_dossiers" FOR UPDATE USING (public.is_admin(auth.uid()) + OR project_id IN ( + SELECT project_access.project_id FROM public.project_access + WHERE project_access.user_id = auth.uid() + )); + +-- ····· consultant_memories ····· + +DROP POLICY IF EXISTS "consultant_memories_delete" ON "public"."consultant_memories"; +CREATE POLICY "consultant_memories_delete" ON "public"."consultant_memories" FOR DELETE USING (public.is_admin(auth.uid())); + +DROP POLICY IF EXISTS "consultant_memories_insert" ON "public"."consultant_memories"; +CREATE POLICY "consultant_memories_insert" ON "public"."consultant_memories" FOR INSERT WITH CHECK (public.is_admin(auth.uid()) + OR project_id IN ( + SELECT project_access.project_id FROM public.project_access + WHERE project_access.user_id = auth.uid() + )); + +DROP POLICY IF EXISTS "consultant_memories_select" ON "public"."consultant_memories"; +CREATE POLICY "consultant_memories_select" ON "public"."consultant_memories" FOR SELECT USING (public.is_admin(auth.uid()) + OR project_id IN ( + SELECT project_access.project_id FROM public.project_access + WHERE project_access.user_id = auth.uid() + )); + +DROP POLICY IF EXISTS "consultant_memories_update" ON "public"."consultant_memories"; +CREATE POLICY "consultant_memories_update" ON "public"."consultant_memories" FOR UPDATE USING (public.is_admin(auth.uid()) + OR project_id IN ( + SELECT project_access.project_id FROM public.project_access + WHERE project_access.user_id = auth.uid() + )); + +-- ····· contas_pagar ····· + +DROP POLICY IF EXISTS "contas_pagar_delete" ON "public"."contas_pagar"; +CREATE POLICY "contas_pagar_delete" ON "public"."contas_pagar" FOR DELETE USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "contas_pagar_insert" ON "public"."contas_pagar"; +CREATE POLICY "contas_pagar_insert" ON "public"."contas_pagar" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "contas_pagar_select" ON "public"."contas_pagar"; +CREATE POLICY "contas_pagar_select" ON "public"."contas_pagar" FOR SELECT USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "contas_pagar_update" ON "public"."contas_pagar"; +CREATE POLICY "contas_pagar_update" ON "public"."contas_pagar" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +-- ····· cost_audit_reports ····· + +DROP POLICY IF EXISTS "cost_audit_reports_insert" ON "public"."cost_audit_reports"; +CREATE POLICY "cost_audit_reports_insert" ON "public"."cost_audit_reports" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR (EXISTS ( SELECT 1 + FROM "public"."project_members" "pm" + WHERE (("pm"."project_id" = "cost_audit_reports"."project_id") AND ("pm"."user_id" = "auth"."uid"()))))); + +DROP POLICY IF EXISTS "cost_audit_reports_update" ON "public"."cost_audit_reports"; +CREATE POLICY "cost_audit_reports_update" ON "public"."cost_audit_reports" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR (EXISTS ( SELECT 1 + FROM "public"."project_members" "pm" + WHERE (("pm"."project_id" = "cost_audit_reports"."project_id") AND ("pm"."user_id" = "auth"."uid"()))))); + +-- ····· dre_detalhamento_antecipacao_linha ····· + +DROP POLICY IF EXISTS "dal_delete" ON "public"."dre_detalhamento_antecipacao_linha"; +CREATE POLICY "dal_delete" ON "public"."dre_detalhamento_antecipacao_linha" FOR DELETE USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "dal_insert" ON "public"."dre_detalhamento_antecipacao_linha"; +CREATE POLICY "dal_insert" ON "public"."dre_detalhamento_antecipacao_linha" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "dal_select" ON "public"."dre_detalhamento_antecipacao_linha"; +CREATE POLICY "dal_select" ON "public"."dre_detalhamento_antecipacao_linha" FOR SELECT USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "dal_update" ON "public"."dre_detalhamento_antecipacao_linha"; +CREATE POLICY "dal_update" ON "public"."dre_detalhamento_antecipacao_linha" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +-- ····· dre_detalhamento_contrato ····· + +DROP POLICY IF EXISTS "ddc_delete" ON "public"."dre_detalhamento_contrato"; +CREATE POLICY "ddc_delete" ON "public"."dre_detalhamento_contrato" FOR DELETE USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "ddc_insert" ON "public"."dre_detalhamento_contrato"; +CREATE POLICY "ddc_insert" ON "public"."dre_detalhamento_contrato" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "ddc_select" ON "public"."dre_detalhamento_contrato"; +CREATE POLICY "ddc_select" ON "public"."dre_detalhamento_contrato" FOR SELECT USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "ddc_update" ON "public"."dre_detalhamento_contrato"; +CREATE POLICY "ddc_update" ON "public"."dre_detalhamento_contrato" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +-- ····· dre_detalhamento ····· + +DROP POLICY IF EXISTS "dre_detalhamento_delete" ON "public"."dre_detalhamento"; +CREATE POLICY "dre_detalhamento_delete" ON "public"."dre_detalhamento" FOR DELETE USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "dre_detalhamento_insert" ON "public"."dre_detalhamento"; +CREATE POLICY "dre_detalhamento_insert" ON "public"."dre_detalhamento" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "dre_detalhamento_select" ON "public"."dre_detalhamento"; +CREATE POLICY "dre_detalhamento_select" ON "public"."dre_detalhamento" FOR SELECT USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "dre_detalhamento_update" ON "public"."dre_detalhamento"; +CREATE POLICY "dre_detalhamento_update" ON "public"."dre_detalhamento" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +-- ····· dre_reports ····· + +DROP POLICY IF EXISTS "dre_reports_insert" ON "public"."dre_reports"; +CREATE POLICY "dre_reports_insert" ON "public"."dre_reports" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR (EXISTS ( SELECT 1 + FROM "public"."project_members" "pm" + WHERE (("pm"."project_id" = "dre_reports"."project_id") AND ("pm"."user_id" = "auth"."uid"()))))); + +DROP POLICY IF EXISTS "dre_reports_update" ON "public"."dre_reports"; +CREATE POLICY "dre_reports_update" ON "public"."dre_reports" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR (EXISTS ( SELECT 1 + FROM "public"."project_members" "pm" + WHERE (("pm"."project_id" = "dre_reports"."project_id") AND ("pm"."user_id" = "auth"."uid"()))))); + +-- ····· employees ····· + +DROP POLICY IF EXISTS "employees_cliente_insert" ON "public"."employees"; +CREATE POLICY "employees_cliente_insert" ON "public"."employees" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" + WHERE ("cpa"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "employees_cliente_update" ON "public"."employees"; +CREATE POLICY "employees_cliente_update" ON "public"."employees" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" + WHERE ("cpa"."user_id" = "auth"."uid"())))) WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" + WHERE ("cpa"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "employees_insert" ON "public"."employees"; +CREATE POLICY "employees_insert" ON "public"."employees" FOR INSERT WITH CHECK (public.is_admin(auth.uid()) + OR project_id IN ( + SELECT project_access.project_id FROM public.project_access + WHERE project_access.user_id = auth.uid() + )); + +DROP POLICY IF EXISTS "employees_select" ON "public"."employees"; +CREATE POLICY "employees_select" ON "public"."employees" FOR SELECT USING (public.is_admin(auth.uid()) + OR project_id IN ( + SELECT project_access.project_id FROM public.project_access + WHERE project_access.user_id = auth.uid() + )); + +DROP POLICY IF EXISTS "employees_update" ON "public"."employees"; +CREATE POLICY "employees_update" ON "public"."employees" FOR UPDATE USING (public.is_admin(auth.uid()) + OR project_id IN ( + SELECT project_access.project_id FROM public.project_access + WHERE project_access.user_id = auth.uid() + )); + +-- ····· endividamento_contratos ····· + +DROP POLICY IF EXISTS "endiv_contratos_delete" ON "public"."endividamento_contratos"; +CREATE POLICY "endiv_contratos_delete" ON "public"."endividamento_contratos" FOR DELETE USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "endiv_contratos_insert" ON "public"."endividamento_contratos"; +CREATE POLICY "endiv_contratos_insert" ON "public"."endividamento_contratos" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "endiv_contratos_select" ON "public"."endividamento_contratos"; +CREATE POLICY "endiv_contratos_select" ON "public"."endividamento_contratos" FOR SELECT USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "endiv_contratos_update" ON "public"."endividamento_contratos"; +CREATE POLICY "endiv_contratos_update" ON "public"."endividamento_contratos" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +-- ····· endividamento_snapshots ····· + +DROP POLICY IF EXISTS "endiv_snap_insert" ON "public"."endividamento_snapshots"; +CREATE POLICY "endiv_snap_insert" ON "public"."endividamento_snapshots" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "endiv_snap_select" ON "public"."endividamento_snapshots"; +CREATE POLICY "endiv_snap_select" ON "public"."endividamento_snapshots" FOR SELECT USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +-- ····· estoque_produtos ····· + +DROP POLICY IF EXISTS "estoque_produtos_delete" ON "public"."estoque_produtos"; +CREATE POLICY "estoque_produtos_delete" ON "public"."estoque_produtos" FOR DELETE USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "estoque_produtos_insert" ON "public"."estoque_produtos"; +CREATE POLICY "estoque_produtos_insert" ON "public"."estoque_produtos" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "estoque_produtos_select" ON "public"."estoque_produtos"; +CREATE POLICY "estoque_produtos_select" ON "public"."estoque_produtos" FOR SELECT USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "estoque_produtos_update" ON "public"."estoque_produtos"; +CREATE POLICY "estoque_produtos_update" ON "public"."estoque_produtos" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +-- ····· file_uploads ····· + +DROP POLICY IF EXISTS "file_uploads_insert" ON "public"."file_uploads"; +CREATE POLICY "file_uploads_insert" ON "public"."file_uploads" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR (EXISTS ( SELECT 1 + FROM "public"."project_members" "pm" + WHERE (("pm"."project_id" = "file_uploads"."project_id") AND ("pm"."user_id" = "auth"."uid"()))))); + +DROP POLICY IF EXISTS "file_uploads_update" ON "public"."file_uploads"; +CREATE POLICY "file_uploads_update" ON "public"."file_uploads" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR (EXISTS ( SELECT 1 + FROM "public"."project_members" "pm" + WHERE (("pm"."project_id" = "file_uploads"."project_id") AND ("pm"."user_id" = "auth"."uid"()))))); + +-- ····· financial_documents ····· + +DROP POLICY IF EXISTS "financial_documents_cliente_insert" ON "public"."financial_documents"; +CREATE POLICY "financial_documents_cliente_insert" ON "public"."financial_documents" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" + WHERE ("cpa"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "financial_documents_cliente_update" ON "public"."financial_documents"; +CREATE POLICY "financial_documents_cliente_update" ON "public"."financial_documents" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" + WHERE ("cpa"."user_id" = "auth"."uid"())))) WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" + WHERE ("cpa"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "financial_documents_insert" ON "public"."financial_documents"; +CREATE POLICY "financial_documents_insert" ON "public"."financial_documents" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"())); + +-- ····· financial_goals ····· + +DROP POLICY IF EXISTS "financial_goals_insert" ON "public"."financial_goals"; +CREATE POLICY "financial_goals_insert" ON "public"."financial_goals" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR (EXISTS ( SELECT 1 + FROM "public"."project_members" "pm" + WHERE (("pm"."project_id" = "financial_goals"."project_id") AND ("pm"."user_id" = "auth"."uid"()))))); + +DROP POLICY IF EXISTS "financial_goals_update" ON "public"."financial_goals"; +CREATE POLICY "financial_goals_update" ON "public"."financial_goals" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR (EXISTS ( SELECT 1 + FROM "public"."project_members" "pm" + WHERE (("pm"."project_id" = "financial_goals"."project_id") AND ("pm"."user_id" = "auth"."uid"()))))); + +-- ····· financial_snapshots ····· + +DROP POLICY IF EXISTS "financial_snapshots_insert" ON "public"."financial_snapshots"; +CREATE POLICY "financial_snapshots_insert" ON "public"."financial_snapshots" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR (EXISTS ( SELECT 1 + FROM "public"."project_members" "pm" + WHERE (("pm"."project_id" = "financial_snapshots"."project_id") AND ("pm"."user_id" = "auth"."uid"()))))); + +DROP POLICY IF EXISTS "financial_snapshots_update" ON "public"."financial_snapshots"; +CREATE POLICY "financial_snapshots_update" ON "public"."financial_snapshots" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR (EXISTS ( SELECT 1 + FROM "public"."project_members" "pm" + WHERE (("pm"."project_id" = "financial_snapshots"."project_id") AND ("pm"."user_id" = "auth"."uid"()))))); + +-- ····· folha_uploads ····· + +DROP POLICY IF EXISTS "folha_uploads_delete" ON "public"."folha_uploads"; +CREATE POLICY "folha_uploads_delete" ON "public"."folha_uploads" FOR DELETE USING ("public"."is_admin"("auth"."uid"())); + +DROP POLICY IF EXISTS "folha_uploads_insert" ON "public"."folha_uploads"; +CREATE POLICY "folha_uploads_insert" ON "public"."folha_uploads" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "folha_uploads_select" ON "public"."folha_uploads"; +CREATE POLICY "folha_uploads_select" ON "public"."folha_uploads" FOR SELECT USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "folha_uploads_update" ON "public"."folha_uploads"; +CREATE POLICY "folha_uploads_update" ON "public"."folha_uploads" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +-- ····· health_scores ····· + +DROP POLICY IF EXISTS "health_scores_insert" ON "public"."health_scores"; +CREATE POLICY "health_scores_insert" ON "public"."health_scores" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR (EXISTS ( SELECT 1 + FROM "public"."project_members" "pm" + WHERE (("pm"."project_id" = "health_scores"."project_id") AND ("pm"."user_id" = "auth"."uid"()))))); + +-- ····· ingestion_documents ····· + +DROP POLICY IF EXISTS "ingestion_documents_delete" ON "public"."ingestion_documents"; +CREATE POLICY "ingestion_documents_delete" ON "public"."ingestion_documents" FOR DELETE USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "ingestion_documents_insert" ON "public"."ingestion_documents"; +CREATE POLICY "ingestion_documents_insert" ON "public"."ingestion_documents" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "ingestion_documents_select" ON "public"."ingestion_documents"; +CREATE POLICY "ingestion_documents_select" ON "public"."ingestion_documents" FOR SELECT USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "ingestion_documents_update" ON "public"."ingestion_documents"; +CREATE POLICY "ingestion_documents_update" ON "public"."ingestion_documents" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +-- ····· linhas_operacionais ····· + +DROP POLICY IF EXISTS "linhas_operacionais_delete" ON "public"."linhas_operacionais"; +CREATE POLICY "linhas_operacionais_delete" ON "public"."linhas_operacionais" FOR DELETE USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" + WHERE ("cpa"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "linhas_operacionais_insert" ON "public"."linhas_operacionais"; +CREATE POLICY "linhas_operacionais_insert" ON "public"."linhas_operacionais" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" + WHERE ("cpa"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "linhas_operacionais_select" ON "public"."linhas_operacionais"; +CREATE POLICY "linhas_operacionais_select" ON "public"."linhas_operacionais" FOR SELECT USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" + WHERE ("cpa"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "linhas_operacionais_update" ON "public"."linhas_operacionais"; +CREATE POLICY "linhas_operacionais_update" ON "public"."linhas_operacionais" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" + WHERE ("cpa"."user_id" = "auth"."uid"())))) WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" + WHERE ("cpa"."user_id" = "auth"."uid"())))); + +-- ····· loan_contract_pmt_overrides ····· + +DROP POLICY IF EXISTS "lcpo_delete" ON "public"."loan_contract_pmt_overrides"; +CREATE POLICY "lcpo_delete" ON "public"."loan_contract_pmt_overrides" FOR DELETE USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "lcpo_insert" ON "public"."loan_contract_pmt_overrides"; +CREATE POLICY "lcpo_insert" ON "public"."loan_contract_pmt_overrides" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "lcpo_select" ON "public"."loan_contract_pmt_overrides"; +CREATE POLICY "lcpo_select" ON "public"."loan_contract_pmt_overrides" FOR SELECT USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "lcpo_update" ON "public"."loan_contract_pmt_overrides"; +CREATE POLICY "lcpo_update" ON "public"."loan_contract_pmt_overrides" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +-- ····· loan_contracts ····· + +DROP POLICY IF EXISTS "loan_contracts_delete" ON "public"."loan_contracts"; +CREATE POLICY "loan_contracts_delete" ON "public"."loan_contracts" FOR DELETE USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "loan_contracts_insert" ON "public"."loan_contracts"; +CREATE POLICY "loan_contracts_insert" ON "public"."loan_contracts" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "loan_contracts_select" ON "public"."loan_contracts"; +CREATE POLICY "loan_contracts_select" ON "public"."loan_contracts" FOR SELECT USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "loan_contracts_update" ON "public"."loan_contracts"; +CREATE POLICY "loan_contracts_update" ON "public"."loan_contracts" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +-- ····· margin_analyses ····· + +DROP POLICY IF EXISTS "margin_analyses_insert" ON "public"."margin_analyses"; +CREATE POLICY "margin_analyses_insert" ON "public"."margin_analyses" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR (EXISTS ( SELECT 1 + FROM "public"."project_members" "pm" + WHERE (("pm"."project_id" = "margin_analyses"."project_id") AND ("pm"."user_id" = "auth"."uid"()))))); + +DROP POLICY IF EXISTS "margin_analyses_update" ON "public"."margin_analyses"; +CREATE POLICY "margin_analyses_update" ON "public"."margin_analyses" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR (EXISTS ( SELECT 1 + FROM "public"."project_members" "pm" + WHERE (("pm"."project_id" = "margin_analyses"."project_id") AND ("pm"."user_id" = "auth"."uid"()))))); + +-- ····· margin_items ····· + +DROP POLICY IF EXISTS "margin_items_insert" ON "public"."margin_items"; +CREATE POLICY "margin_items_insert" ON "public"."margin_items" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR (EXISTS ( SELECT 1 + FROM ("public"."margin_analyses" "ma" + JOIN "public"."project_members" "pm" ON (("pm"."project_id" = "ma"."project_id"))) + WHERE (("ma"."id" = "margin_items"."analysis_id") AND ("pm"."user_id" = "auth"."uid"()))))); + +DROP POLICY IF EXISTS "margin_items_update" ON "public"."margin_items"; +CREATE POLICY "margin_items_update" ON "public"."margin_items" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR (EXISTS ( SELECT 1 + FROM ("public"."margin_analyses" "ma" + JOIN "public"."project_members" "pm" ON (("pm"."project_id" = "ma"."project_id"))) + WHERE (("ma"."id" = "margin_items"."analysis_id") AND ("pm"."user_id" = "auth"."uid"()))))); + +-- ····· module_analyses ····· + +DROP POLICY IF EXISTS "module_analyses_insert" ON "public"."module_analyses"; +CREATE POLICY "module_analyses_insert" ON "public"."module_analyses" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR (EXISTS ( SELECT 1 + FROM ("public"."project_modules" "pmod" + JOIN "public"."project_members" "pm" ON (("pm"."project_id" = "pmod"."project_id"))) + WHERE (("pmod"."id" = "module_analyses"."module_id") AND ("pm"."user_id" = "auth"."uid"()))))); + +DROP POLICY IF EXISTS "module_analyses_update" ON "public"."module_analyses"; +CREATE POLICY "module_analyses_update" ON "public"."module_analyses" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR (EXISTS ( SELECT 1 + FROM ("public"."project_modules" "pmod" + JOIN "public"."project_members" "pm" ON (("pm"."project_id" = "pmod"."project_id"))) + WHERE (("pmod"."id" = "module_analyses"."module_id") AND ("pm"."user_id" = "auth"."uid"()))))); + +-- ····· pending_ofx_distributions ····· + +DROP POLICY IF EXISTS "pending_ofx_cliente_delete" ON "public"."pending_ofx_distributions"; +CREATE POLICY "pending_ofx_cliente_delete" ON "public"."pending_ofx_distributions" FOR DELETE USING ("public"."is_admin"("auth"."uid"()) + OR ("client_id" IN ( SELECT "pr"."client_id" + FROM ("public"."projects" "pr" + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + WHERE ("pa"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "pending_ofx_cliente_update" ON "public"."pending_ofx_distributions"; +CREATE POLICY "pending_ofx_cliente_update" ON "public"."pending_ofx_distributions" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR ("client_id" IN ( SELECT "pr"."client_id" + FROM ("public"."projects" "pr" + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + WHERE ("pa"."user_id" = "auth"."uid"())))) WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("client_id" IN ( SELECT "pr"."client_id" + FROM ("public"."projects" "pr" + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + WHERE ("pa"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "pending_ofx_delete" ON "public"."pending_ofx_distributions"; +CREATE POLICY "pending_ofx_delete" ON "public"."pending_ofx_distributions" FOR DELETE USING ("public"."is_admin"("auth"."uid"())); + +DROP POLICY IF EXISTS "pending_ofx_insert" ON "public"."pending_ofx_distributions"; +CREATE POLICY "pending_ofx_insert" ON "public"."pending_ofx_distributions" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("client_id" IN ( SELECT "pr"."client_id" + FROM ("public"."projects" "pr" + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + WHERE ("pa"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "pending_ofx_select" ON "public"."pending_ofx_distributions"; +CREATE POLICY "pending_ofx_select" ON "public"."pending_ofx_distributions" FOR SELECT USING ("public"."is_admin"("auth"."uid"()) + OR ("client_id" IN ( SELECT "pr"."client_id" + FROM ("public"."projects" "pr" + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + WHERE ("pa"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "pending_ofx_update" ON "public"."pending_ofx_distributions"; +CREATE POLICY "pending_ofx_update" ON "public"."pending_ofx_distributions" FOR UPDATE USING ("public"."is_admin"("auth"."uid"())); + +-- ····· products_services ····· + +DROP POLICY IF EXISTS "products_services_insert" ON "public"."products_services"; +CREATE POLICY "products_services_insert" ON "public"."products_services" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR (EXISTS ( SELECT 1 + FROM "public"."project_members" "pm" + WHERE (("pm"."project_id" = "products_services"."project_id") AND ("pm"."user_id" = "auth"."uid"()))))); + +DROP POLICY IF EXISTS "products_services_update" ON "public"."products_services"; +CREATE POLICY "products_services_update" ON "public"."products_services" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR (EXISTS ( SELECT 1 + FROM "public"."project_members" "pm" + WHERE (("pm"."project_id" = "products_services"."project_id") AND ("pm"."user_id" = "auth"."uid"()))))); + +-- ····· project_module_unification ····· + +DROP POLICY IF EXISTS "pmu_insert" ON "public"."project_module_unification"; +CREATE POLICY "pmu_insert" ON "public"."project_module_unification" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "pmu_select" ON "public"."project_module_unification"; +CREATE POLICY "pmu_select" ON "public"."project_module_unification" FOR SELECT USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "pmu_update" ON "public"."project_module_unification"; +CREATE POLICY "pmu_update" ON "public"."project_module_unification" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +-- ····· project_scenarios ····· + +DROP POLICY IF EXISTS "project_scenarios_insert" ON "public"."project_scenarios"; +CREATE POLICY "project_scenarios_insert" ON "public"."project_scenarios" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR (EXISTS ( SELECT 1 + FROM "public"."project_members" "pm" + WHERE (("pm"."project_id" = "project_scenarios"."project_id") AND ("pm"."user_id" = "auth"."uid"()))))); + +DROP POLICY IF EXISTS "project_scenarios_update" ON "public"."project_scenarios"; +CREATE POLICY "project_scenarios_update" ON "public"."project_scenarios" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR (EXISTS ( SELECT 1 + FROM "public"."project_members" "pm" + WHERE (("pm"."project_id" = "project_scenarios"."project_id") AND ("pm"."user_id" = "auth"."uid"()))))); + +-- ····· project_transitions ····· + +DROP POLICY IF EXISTS "project_transitions_insert" ON "public"."project_transitions"; +CREATE POLICY "project_transitions_insert" ON "public"."project_transitions" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR (EXISTS ( SELECT 1 + FROM "public"."project_members" "pm" + WHERE (("pm"."project_id" = "project_transitions"."project_id") AND ("pm"."user_id" = "auth"."uid"()))))); + +-- ····· projection_scenarios ····· + +DROP POLICY IF EXISTS "projection_scenarios_delete" ON "public"."projection_scenarios"; +CREATE POLICY "projection_scenarios_delete" ON "public"."projection_scenarios" FOR DELETE USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "projection_scenarios_insert" ON "public"."projection_scenarios"; +CREATE POLICY "projection_scenarios_insert" ON "public"."projection_scenarios" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "projection_scenarios_select" ON "public"."projection_scenarios"; +CREATE POLICY "projection_scenarios_select" ON "public"."projection_scenarios" FOR SELECT USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "projection_scenarios_update" ON "public"."projection_scenarios"; +CREATE POLICY "projection_scenarios_update" ON "public"."projection_scenarios" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +-- ····· recebivel_parcelas ····· + +DROP POLICY IF EXISTS "recebivel_parcelas_delete" ON "public"."recebivel_parcelas"; +CREATE POLICY "recebivel_parcelas_delete" ON "public"."recebivel_parcelas" FOR DELETE USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "recebivel_parcelas_insert" ON "public"."recebivel_parcelas"; +CREATE POLICY "recebivel_parcelas_insert" ON "public"."recebivel_parcelas" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "recebivel_parcelas_select" ON "public"."recebivel_parcelas"; +CREATE POLICY "recebivel_parcelas_select" ON "public"."recebivel_parcelas" FOR SELECT USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "recebivel_parcelas_update" ON "public"."recebivel_parcelas"; +CREATE POLICY "recebivel_parcelas_update" ON "public"."recebivel_parcelas" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "project_access"."project_id" + FROM "public"."project_access" + WHERE ("project_access"."user_id" = "auth"."uid"())))); + +-- ····· service_composition ····· + +DROP POLICY IF EXISTS "service_composition_insert" ON "public"."service_composition"; +CREATE POLICY "service_composition_insert" ON "public"."service_composition" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR (EXISTS ( SELECT 1 + FROM ("public"."products_services" "ps" + JOIN "public"."project_members" "pm" ON (("pm"."project_id" = "ps"."project_id"))) + WHERE (("ps"."id" = "service_composition"."service_id") AND ("pm"."user_id" = "auth"."uid"()))))); + +-- ····· transacao_linha_split ····· + +DROP POLICY IF EXISTS "transacao_linha_split_delete" ON "public"."transacao_linha_split"; +CREATE POLICY "transacao_linha_split_delete" ON "public"."transacao_linha_split" FOR DELETE USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" + WHERE ("cpa"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "transacao_linha_split_insert" ON "public"."transacao_linha_split"; +CREATE POLICY "transacao_linha_split_insert" ON "public"."transacao_linha_split" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" + WHERE ("cpa"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "transacao_linha_split_select" ON "public"."transacao_linha_split"; +CREATE POLICY "transacao_linha_split_select" ON "public"."transacao_linha_split" FOR SELECT USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" + WHERE ("cpa"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "transacao_linha_split_update" ON "public"."transacao_linha_split"; +CREATE POLICY "transacao_linha_split_update" ON "public"."transacao_linha_split" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" + WHERE ("cpa"."user_id" = "auth"."uid"())))) WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" + WHERE ("cpa"."user_id" = "auth"."uid"())))); + +-- ····· transactions ····· + +DROP POLICY IF EXISTS "transactions_cliente_insert" ON "public"."transactions"; +CREATE POLICY "transactions_cliente_insert" ON "public"."transactions" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" + WHERE ("cpa"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "transactions_cliente_update" ON "public"."transactions"; +CREATE POLICY "transactions_cliente_update" ON "public"."transactions" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" + WHERE ("cpa"."user_id" = "auth"."uid"())))) WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("project_id" IN ( SELECT "cpa"."project_id" + FROM "public"."project_access" "cpa" + WHERE ("cpa"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "transactions_insert" ON "public"."transactions"; +CREATE POLICY "transactions_insert" ON "public"."transactions" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR (EXISTS ( SELECT 1 + FROM "public"."project_members" "pm" + WHERE (("pm"."project_id" = "transactions"."project_id") AND ("pm"."user_id" = "auth"."uid"()))))); + +DROP POLICY IF EXISTS "transactions_insert_consultor" ON "public"."transactions"; +CREATE POLICY "transactions_insert_consultor" ON "public"."transactions" FOR INSERT WITH CHECK ("public"."is_admin"("auth"."uid"())); + +DROP POLICY IF EXISTS "transactions_update" ON "public"."transactions"; +CREATE POLICY "transactions_update" ON "public"."transactions" FOR UPDATE USING ("public"."is_admin"("auth"."uid"()) + OR (EXISTS ( SELECT 1 + FROM "public"."project_members" "pm" + WHERE (("pm"."project_id" = "transactions"."project_id") AND ("pm"."user_id" = "auth"."uid"()))))); + + +-- --------------------------------------------------------------------------- +-- PostgREST: recarregar o schema cache +-- --------------------------------------------------------------------------- +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260818030000_saas_dono_conserta_o_proprio_cadastro.sql b/supabase/migrations/20260818030000_saas_dono_conserta_o_proprio_cadastro.sql new file mode 100644 index 00000000..16c12750 --- /dev/null +++ b/supabase/migrations/20260818030000_saas_dono_conserta_o_proprio_cadastro.sql @@ -0,0 +1,119 @@ +-- ============================================================================= +-- SaaS sem consultor — fatia 4: o dono conserta o próprio CADASTRO +-- Lei: docs/atros-v3/saas-sem-consultor.md · Método: docs/atros-v3/metodo.md +-- ============================================================================= +-- +-- A DECISÃO (Lucas, 18/ago/2026) +-- +-- Dado financeiro APURADO segue admin-only para escrita destrutiva; CADASTRO +-- abre para o dono. O motivo é o modelo: cadastro errado o dono tem de poder +-- consertar sozinho — sob SaaS não existe suporte para fazer por ele. +-- +-- fica admin-only → transactions · financial_documents · plano_voo_snapshots +-- abre pro dono → client_socios · bank_accounts · companies +-- +-- O QUE ISSO CONSERTA +-- +-- `client_socios` era o único buraco de verdade do modelo: não existia par +-- `_cliente_insert/_update` (ao contrário de companies, bank_accounts e +-- pending_ofx, que já tinham), então o dono NÃO CADASTRAVA os próprios sócios — +-- e a lei diz explicitamente que é ele quem cadastra empresa e sócios na +-- contratação. Também é o que a leva de partes relacionadas precisa: o QSA +-- alimenta o cadastro de sócios, e o detector de contraparte lê dali. +-- +-- Em `bank_accounts` e `companies` o que faltava era só o DELETE (INSERT/UPDATE +-- já tinham caminho pelo par `_cliente_*`). +-- +-- As formas de JOIN abaixo NÃO foram inventadas: são as mesmas das policies de +-- SELECT irmãs, que já provaram o caminho `client_id`/`company_id` → projeto. +-- +-- AINDA admin-only, e de propósito (não decidido — levantar quando doer): +-- bank_balances · cash_flow_params · client_documents · financial_goals · +-- folha_uploads · endividamento_snapshots · margin_* · module_analyses … +-- o dono INSERE e ATUALIZA esses (fatia 2 destravou), mas não APAGA. Incoerente +-- em alguns casos; deliberado até virar decisão. +-- ============================================================================= + + +-- --------------------------------------------------------------------------- +-- 1. client_socios — o dono cadastra, corrige e remove os próprios sócios +-- +-- Vínculo: client_socios.client_id → projects.client_id → project_access. +-- `WITH CHECK` explícito no UPDATE: sem ele o Postgres reusa o USING para a +-- linha nova (seguro), mas explícito documenta que não se move um sócio para +-- outro client_id. +-- --------------------------------------------------------------------------- +DROP POLICY IF EXISTS "client_socios_insert" ON "public"."client_socios"; +CREATE POLICY "client_socios_insert" ON "public"."client_socios" FOR INSERT + WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("client_id" IN ( SELECT "pr"."client_id" + FROM ("public"."projects" "pr" + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + WHERE ("pa"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "client_socios_update" ON "public"."client_socios"; +CREATE POLICY "client_socios_update" ON "public"."client_socios" FOR UPDATE + USING ("public"."is_admin"("auth"."uid"()) + OR ("client_id" IN ( SELECT "pr"."client_id" + FROM ("public"."projects" "pr" + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + WHERE ("pa"."user_id" = "auth"."uid"())))) + WITH CHECK ("public"."is_admin"("auth"."uid"()) + OR ("client_id" IN ( SELECT "pr"."client_id" + FROM ("public"."projects" "pr" + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + WHERE ("pa"."user_id" = "auth"."uid"())))); + +DROP POLICY IF EXISTS "client_socios_delete" ON "public"."client_socios"; +CREATE POLICY "client_socios_delete" ON "public"."client_socios" FOR DELETE + USING ("public"."is_admin"("auth"."uid"()) + OR ("client_id" IN ( SELECT "pr"."client_id" + FROM ("public"."projects" "pr" + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + WHERE ("pa"."user_id" = "auth"."uid"())))); + +COMMENT ON POLICY "client_socios_insert" ON "public"."client_socios" IS + 'SaaS sem consultor: o dono do projeto cadastra os próprios sócios (é ele quem contrata e cadastra). Vínculo por client_id → projects → project_access.'; + + +-- --------------------------------------------------------------------------- +-- 2. bank_accounts — o dono remove uma conta que cadastrou errado +-- +-- Vínculo: bank_accounts.company_id → companies.client_id → projects → project_access +-- (mesma cadeia do `bank_accounts_select`). +-- --------------------------------------------------------------------------- +DROP POLICY IF EXISTS "bank_accounts_delete" ON "public"."bank_accounts"; +CREATE POLICY "bank_accounts_delete" ON "public"."bank_accounts" FOR DELETE + USING ("public"."is_admin"("auth"."uid"()) + OR ("company_id" IN ( SELECT "co"."id" + FROM (("public"."companies" "co" + JOIN "public"."projects" "pr" ON (("pr"."client_id" = "co"."client_id"))) + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + WHERE ("pa"."user_id" = "auth"."uid"())))); + +COMMENT ON POLICY "bank_accounts_delete" ON "public"."bank_accounts" IS + 'SaaS sem consultor: cadastro é do dono — conta bancária cadastrada errado ele mesmo remove. Dado financeiro apurado (transactions) segue admin-only.'; + + +-- --------------------------------------------------------------------------- +-- 3. companies — o dono remove um CNPJ que cadastrou errado +-- +-- Vínculo: companies.client_id → projects.client_id → project_access +-- (mesma cadeia do `companies_cliente_insert`). +-- --------------------------------------------------------------------------- +DROP POLICY IF EXISTS "companies_delete" ON "public"."companies"; +CREATE POLICY "companies_delete" ON "public"."companies" FOR DELETE + USING ("public"."is_admin"("auth"."uid"()) + OR ("client_id" IN ( SELECT "pr"."client_id" + FROM ("public"."projects" "pr" + JOIN "public"."project_access" "pa" ON (("pa"."project_id" = "pr"."id"))) + WHERE ("pa"."user_id" = "auth"."uid"())))); + +COMMENT ON POLICY "companies_delete" ON "public"."companies" IS + 'SaaS sem consultor: cadastro é do dono — CNPJ cadastrado errado ele mesmo remove. Multi-CNPJ: remover a empresa não remove transações (transactions_delete segue admin-only).'; + + +-- --------------------------------------------------------------------------- +-- PostgREST: recarregar o schema cache +-- --------------------------------------------------------------------------- +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260818040000_vazamento_escopa_tabelas_de_projeto.sql b/supabase/migrations/20260818040000_vazamento_escopa_tabelas_de_projeto.sql new file mode 100644 index 00000000..9f058270 --- /dev/null +++ b/supabase/migrations/20260818040000_vazamento_escopa_tabelas_de_projeto.sql @@ -0,0 +1,209 @@ +-- ============================================================================= +-- Vazamento multi-tenant — lote 1: escopar o que é dado de PROJETO +-- Método: docs/atros-v3/metodo.md (F2 — repoint antes de deletar, um leitor por vez) +-- ============================================================================= +-- +-- O PROBLEMA +-- +-- 31 policies liberam acesso por simplesmente ESTAR LOGADO +-- (`auth.role() = 'authenticated'`, ou `USING (true)`). Como policies permissivas +-- se somam por **OR**, elas anulam todo o escopo por `project_members` das +-- tabelas que tocam: não adianta a policy vizinha exigir vínculo se a do lado +-- libera pra qualquer autenticado. +-- +-- Sob o modelo antigo — só a equipe da ATR tinha login — isso era desleixo +-- contido. Com o **signup self-service em produção**, virou vazamento entre +-- clientes: um cliente logado alcança dado de outro. +-- +-- ESTE LOTE cobre o que é claramente dado de projeto e tem chave de escopo. +-- Fora dele, de propósito: +-- · `projects` e `profiles` — 111 e 77 leitores em `src/`, e a substituição +-- envolve decisão de produto. Lote próprio, com repoint dos dois leitores +-- que hoje só funcionam pela policy aberta (`PulseCheckModal.tsx` e +-- `app/actions.ts`). +-- · catálogos globais compartilhados (`dre_categories`, `financial_benchmarks`, +-- `class_*`, `library_content`, `subcategories`) — leitura por autenticado é +-- o desenho correto, não vazamento. +-- · `lite_*` — produto separado, já escopado por `lite_get_company_id()`. +-- +-- A RÉGUA é a mesma do SaaS sem consultor: **membro do projeto** (view +-- `project_access`) ou `is_admin()`. Nenhuma regra nova, nenhum conceito novo. +-- ============================================================================= + + +-- --------------------------------------------------------------------------- +-- 1. Tabelas de projeto sem NENHUM leitor em `src/` (zero risco de repoint) +-- +-- `project_tasks`, `project_meetings`, `project_deliverables`: 0 usos de +-- `.from()` no código. Ficam escopadas — e não admin-only — porque se alguém +-- voltar a usá-las, a regra certa já está no lugar. +-- --------------------------------------------------------------------------- + +DROP POLICY IF EXISTS "Usuários autenticados podem gerenciar tarefas" ON "public"."project_tasks"; +DROP POLICY IF EXISTS "Usuários autenticados podem ver tarefas" ON "public"."project_tasks"; + +CREATE POLICY "project_tasks_membro" ON "public"."project_tasks" + USING (public.is_admin(auth.uid()) + OR project_id IN ( SELECT project_access.project_id + FROM public.project_access + WHERE project_access.user_id = auth.uid())) + WITH CHECK (public.is_admin(auth.uid()) + OR project_id IN ( SELECT project_access.project_id + FROM public.project_access + WHERE project_access.user_id = auth.uid())); + + +DROP POLICY IF EXISTS "Usuários autenticados podem gerenciar reuniões" ON "public"."project_meetings"; +DROP POLICY IF EXISTS "Usuários autenticados podem ver reuniões" ON "public"."project_meetings"; + +CREATE POLICY "project_meetings_membro" ON "public"."project_meetings" + USING (public.is_admin(auth.uid()) + OR project_id IN ( SELECT project_access.project_id + FROM public.project_access + WHERE project_access.user_id = auth.uid())) + WITH CHECK (public.is_admin(auth.uid()) + OR project_id IN ( SELECT project_access.project_id + FROM public.project_access + WHERE project_access.user_id = auth.uid())); + + +-- As duas policies por `projects.created_by` ficam: são mais estreitas que esta +-- e não fazem mal — quem criou o projeto continua alcançando o entregável. +DROP POLICY IF EXISTS "Usuários autenticados podem gerenciar entregáveis" ON "public"."project_deliverables"; +DROP POLICY IF EXISTS "Usuários autenticados podem ver entregáveis" ON "public"."project_deliverables"; + +CREATE POLICY "project_deliverables_membro" ON "public"."project_deliverables" + USING (public.is_admin(auth.uid()) + OR project_id IN ( SELECT project_access.project_id + FROM public.project_access + WHERE project_access.user_id = auth.uid())) + WITH CHECK (public.is_admin(auth.uid()) + OR project_id IN ( SELECT project_access.project_id + FROM public.project_access + WHERE project_access.user_id = auth.uid())); + + +-- `time_entries` é apontamento de horas da equipe ATR, não do cliente. A policy +-- "próprios registros" (own + admin) já é a regra certa e FICA; some só o SELECT +-- que deixava qualquer logado ver as horas de todo mundo. +DROP POLICY IF EXISTS "Usuários autenticados podem ver registros de horas" ON "public"."time_entries"; + + +-- --------------------------------------------------------------------------- +-- 2. Tabelas de projeto COM leitores — escopadas pela mesma régua +-- --------------------------------------------------------------------------- + +-- `categorization_rules` (project_id) — 2 leitores. É a inteligência de +-- categorização do cliente: hoje qualquer logado LÊ e ESCREVE a de todo mundo. +DROP POLICY IF EXISTS "Usuários autenticados podem gerenciar regras" ON "public"."categorization_rules"; +DROP POLICY IF EXISTS "Usuários autenticados podem ver regras" ON "public"."categorization_rules"; + +CREATE POLICY "categorization_rules_membro" ON "public"."categorization_rules" + USING (public.is_admin(auth.uid()) + OR project_id IN ( SELECT project_access.project_id + FROM public.project_access + WHERE project_access.user_id = auth.uid())) + WITH CHECK (public.is_admin(auth.uid()) + OR project_id IN ( SELECT project_access.project_id + FROM public.project_access + WHERE project_access.user_id = auth.uid())); + + +-- `learned_rules` (client_id, não project_id) — 4 leitores. Escopa pelo cliente +-- dono dos projetos do usuário. (G-08 do backlog quer avaliar o DROP desta +-- tabela; escopar não atrapalha essa decisão, só para o vazamento enquanto isso.) +DROP POLICY IF EXISTS "Usuários autenticados podem gerenciar regras" ON "public"."learned_rules"; +DROP POLICY IF EXISTS "Usuários autenticados podem ver regras" ON "public"."learned_rules"; + +CREATE POLICY "learned_rules_membro" ON "public"."learned_rules" + USING (public.is_admin(auth.uid()) + OR client_id IN ( SELECT pr.client_id + FROM public.projects pr + JOIN public.project_access pa ON pa.project_id = pr.id + WHERE pa.user_id = auth.uid())) + WITH CHECK (public.is_admin(auth.uid()) + OR client_id IN ( SELECT pr.client_id + FROM public.projects pr + JOIN public.project_access pa ON pa.project_id = pr.id + WHERE pa.user_id = auth.uid())); + + +-- `project_modules` (project_id) — 22 leitores, TODOS dentro de +-- `/projetos/[id]/...` (conferido um a um). Escopar não muda nada pra quem já +-- está no contexto de um projeto do qual é membro. +DROP POLICY IF EXISTS "Usuários autenticados podem gerenciar módulos" ON "public"."project_modules"; +DROP POLICY IF EXISTS "Usuários autenticados podem ver módulos" ON "public"."project_modules"; + +CREATE POLICY "project_modules_membro" ON "public"."project_modules" + USING (public.is_admin(auth.uid()) + OR project_id IN ( SELECT project_access.project_id + FROM public.project_access + WHERE project_access.user_id = auth.uid())) + WITH CHECK (public.is_admin(auth.uid()) + OR project_id IN ( SELECT project_access.project_id + FROM public.project_access + WHERE project_access.user_id = auth.uid())); + + +-- --------------------------------------------------------------------------- +-- 3. `dre_subcategories` — catálogo híbrido: sistema é global, custom é do projeto +-- +-- A leitura precisa continuar deixando passar o catálogo de sistema +-- (`is_system`) e as linhas globais (`project_id IS NULL`) — é o catálogo da +-- casa. O que sai é a terceira perna, `auth.role() = 'authenticated'`, que +-- entregava as subcategorias personalizadas de todo projeto a qualquer logado. +-- +-- Na escrita, o `is_custom = true` fica (ninguém edita catálogo de sistema) e +-- ganha o escopo por projeto. `financial-document-service.ts:1662` sempre grava +-- com `project_id` e `is_custom: true`, então o caminho vivo passa. +-- --------------------------------------------------------------------------- +DROP POLICY IF EXISTS "Leitura de subcategorias" ON "public"."dre_subcategories"; +CREATE POLICY "Leitura de subcategorias" ON "public"."dre_subcategories" FOR SELECT + USING (is_system = true + OR project_id IS NULL + OR public.is_admin(auth.uid()) + OR project_id IN ( SELECT project_access.project_id + FROM public.project_access + WHERE project_access.user_id = auth.uid())); + +DROP POLICY IF EXISTS "Criação de subcategorias personalizadas" ON "public"."dre_subcategories"; +CREATE POLICY "Criação de subcategorias personalizadas" ON "public"."dre_subcategories" FOR INSERT + WITH CHECK (is_custom = true + AND (public.is_admin(auth.uid()) + OR project_id IN ( SELECT project_access.project_id + FROM public.project_access + WHERE project_access.user_id = auth.uid()))); + +DROP POLICY IF EXISTS "Atualização de subcategorias personalizadas" ON "public"."dre_subcategories"; +CREATE POLICY "Atualização de subcategorias personalizadas" ON "public"."dre_subcategories" FOR UPDATE + USING (is_custom = true + AND (public.is_admin(auth.uid()) + OR project_id IN ( SELECT project_access.project_id + FROM public.project_access + WHERE project_access.user_id = auth.uid()))); + + +-- --------------------------------------------------------------------------- +-- 4. `activity_log` — o log é do usuário +-- +-- Não tem `project_id`, só `user_id`. O único leitor +-- (`profile-service.ts:245`) já filtra por `user_id`, então escopar não muda +-- nada pra ele. O `INSERT WITH CHECK (true)` some: NADA em `src/` insere aqui, +-- então ele não servia a nenhum caminho vivo — servia pra forjar log em nome de +-- outro. Escritas de servidor seguem entrando por `service_role`, que não passa +-- por RLS. +-- --------------------------------------------------------------------------- +DROP POLICY IF EXISTS "Usuários autenticados podem ver logs" ON "public"."activity_log"; +DROP POLICY IF EXISTS "Sistema pode inserir logs" ON "public"."activity_log"; + +CREATE POLICY "activity_log_select_own" ON "public"."activity_log" FOR SELECT + USING (user_id = auth.uid() OR public.is_admin(auth.uid())); + +CREATE POLICY "activity_log_insert_own" ON "public"."activity_log" FOR INSERT + WITH CHECK (user_id = auth.uid() OR public.is_admin(auth.uid())); + + +-- --------------------------------------------------------------------------- +-- PostgREST: recarregar o schema cache +-- --------------------------------------------------------------------------- +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260818050000_vazamento_projects_e_profiles.sql b/supabase/migrations/20260818050000_vazamento_projects_e_profiles.sql new file mode 100644 index 00000000..355440e7 --- /dev/null +++ b/supabase/migrations/20260818050000_vazamento_projects_e_profiles.sql @@ -0,0 +1,130 @@ +-- ============================================================================= +-- Vazamento multi-tenant — lote 2: `projects` e `profiles` +-- Método: docs/atros-v3/metodo.md (F2 — repoint antes de deletar, um leitor por vez) +-- ============================================================================= +-- +-- O QUE ESTAVA ABERTO +-- +-- projects SELECT · UPDATE · INSERT → `auth.role() = 'authenticated'` +-- profiles SELECT → `USING (true)` +-- +-- Ou seja: qualquer usuário logado listava **todos os projetos da plataforma** e +-- podia dar UPDATE em qualquer um — inclusive em `data_inicio`, que é o campo +-- que define a janela de 12 meses da meta. E enxergava nome, e-mail e papel de +-- todo mundo. Com signup self-service em produção, isso é vazamento entre +-- clientes, não desleixo interno. +-- +-- Policies permissivas se somam por **OR**: enquanto essas existirem, o escopo +-- por `project_members` das vizinhas não vale nada. +-- +-- ───────────────────────────────────────────────────────────────────────────── +-- POR QUE DÁ PRA SIMPLESMENTE DERRUBAR AS DE `projects` +-- +-- Cada comando já tem um leitor legítimo cobrindo, conferido policy a policy no +-- dump de 18/ago: +-- +-- SELECT → `projects_members` (vínculo) + `projects_admin` (is_admin ou sdr) +-- INSERT → `projects_insert_admin` (admin/sdr); o cadastro do cliente entra +-- por `service_role` (`api/onboarding/welcome`, `empresa-actions`), +-- que não passa por RLS +-- UPDATE → `projects_update_admin` (admin/sdr) + a policy de membro criada aqui +-- +-- Os dois leitores que hoje só funcionam pela policy aberta continuam +-- funcionando pela policy de MEMBRO, sem tocar em código: +-- · `src/components/SaaS/PulseCheckModal.tsx:91,128` — update no browser +-- · `src/app/actions.ts` (`updateProject`) — client de usuário +-- (`createProject`/`updateProject` não têm nenhum chamador em `src/`: código +-- morto. Mesmo assim a porta de membro cobre, então nada depende disso.) +-- +-- ───────────────────────────────────────────────────────────────────────────── +-- A ARMADILHA DO SUBSELECT (e por que `project_access` é obrigatória aqui) +-- +-- `project_members` tem RLS: `project_members_self` deixa o usuário ver APENAS a +-- própria linha. Um subselect nela dentro de policy roda como o usuário — então +-- procurar o vínculo de OUTRA pessoa volta **vazio, sem erro**. +-- +-- É por isso que `projects_members` funciona (ela filtra `pm.user_id = auth.uid()` +-- — a própria linha) e é por isso que a policy de `profiles` abaixo NÃO pode usar +-- `project_members`: ela precisa enxergar o vínculo do COLEGA. A view +-- `project_access` roda como owner (sem `security_invoker`), então atravessa. +-- Escrita com `project_members`, esta policy passaria no review e negaria tudo em +-- silêncio. +-- ============================================================================= + + +-- --------------------------------------------------------------------------- +-- 1. projects — fecha as três portas abertas, abre a de membro no UPDATE +-- --------------------------------------------------------------------------- +DROP POLICY IF EXISTS "Usuários autenticados podem ver projetos" ON "public"."projects"; +DROP POLICY IF EXISTS "Usuários autenticados podem inserir projetos" ON "public"."projects"; +DROP POLICY IF EXISTS "Usuários autenticados podem atualizar projetos" ON "public"."projects"; + +CREATE POLICY "projects_membro_update" ON "public"."projects" FOR UPDATE + USING (public.is_admin(auth.uid()) + OR id IN ( SELECT project_access.project_id + FROM public.project_access + WHERE project_access.user_id = auth.uid())) + WITH CHECK (public.is_admin(auth.uid()) + OR id IN ( SELECT project_access.project_id + FROM public.project_access + WHERE project_access.user_id = auth.uid())); + +COMMENT ON POLICY "projects_membro_update" ON "public"."projects" IS + 'SaaS sem consultor: membro do projeto edita o próprio projeto. Substitui a policy que liberava UPDATE para qualquer autenticado — que permitia mexer no data_inicio (a janela de 12m da meta) de qualquer cliente.'; + + +-- --------------------------------------------------------------------------- +-- 2. profiles — eu, admin, e quem divide projeto comigo +-- +-- `profiles_own_select` (id = auth.uid()) e `profiles_admin_all` já cobrem as +-- duas primeiras pernas e ficam intactas. O que nasce aqui é só a terceira: o +-- cliente enxerga quem está no projeto dele (o consultor que ele convidou, o +-- staff que é membro), e mais ninguém. +-- +-- O JOIN é `project_access` × `project_access` pelo mesmo `project_id`: "existe +-- projeto em que eu e essa pessoa somos ambos membros?". +-- --------------------------------------------------------------------------- +DROP POLICY IF EXISTS "Usuários podem ver todos os perfis" ON "public"."profiles"; + +CREATE POLICY "profiles_select_colega_de_projeto" ON "public"."profiles" FOR SELECT + USING (EXISTS ( SELECT 1 + FROM public.project_access meu + JOIN public.project_access dele ON dele.project_id = meu.project_id + WHERE meu.user_id = auth.uid() + AND dele.user_id = profiles.id)); + +COMMENT ON POLICY "profiles_select_colega_de_projeto" ON "public"."profiles" IS + 'Terceira perna do SELECT de profiles (as outras são profiles_own_select e profiles_admin_all): enxerga-se quem compartilha projeto. Usa a view project_access de propósito — project_members tem RLS own-row e o subselect voltaria vazio em silêncio.'; + + +-- --------------------------------------------------------------------------- +-- 3. class_access — conceder acesso é ato de admin, não auto-serviço +-- +-- Estava `INSERT WITH CHECK (true)`: qualquer usuário se concedia acesso a +-- qualquer módulo de curso (o SELECT já era só da própria linha, então dava pra +-- inserir e ver). A coluna `concedido_por` diz o que a policy não dizia — é +-- concessão, não auto-serviço. +-- +-- Zero usos de `.from("class_access")` em `src/`: nada a repointar. +-- --------------------------------------------------------------------------- +DROP POLICY IF EXISTS "class_access_insert" ON "public"."class_access"; + +CREATE POLICY "class_access_insert" ON "public"."class_access" FOR INSERT + WITH CHECK (public.is_admin(auth.uid())); + + +-- --------------------------------------------------------------------------- +-- DÍVIDA ANOTADA, não mexida aqui +-- +-- `projects_select_client` ainda lê `client_project_access_deprecated` (tabela +-- morta). Hoje é redundante com `projects_members`, mas só o dado prova que todo +-- mundo que está na deprecated também está em `project_members` — derrubar sem +-- essa conferência é trocar um vazamento por um bloqueio. Fica para a +-- aposentadoria da tabela, com grep-de-prova na hora. +-- --------------------------------------------------------------------------- + + +-- --------------------------------------------------------------------------- +-- PostgREST: recarregar o schema cache +-- --------------------------------------------------------------------------- +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260820010000_readiness_para_de_ler_a_escada_morta.sql b/supabase/migrations/20260820010000_readiness_para_de_ler_a_escada_morta.sql new file mode 100644 index 00000000..5767444a --- /dev/null +++ b/supabase/migrations/20260820010000_readiness_para_de_ler_a_escada_morta.sql @@ -0,0 +1,243 @@ +-- --------------------------------------------------------------------------- +-- D3 (Lucas, 2026-08-20) — `get_dre_readiness` para de ler a escada de publicação +-- --------------------------------------------------------------------------- +-- +-- O PROBLEMA (achado na fatia 2 do plano de correção do pente fino): +-- +-- A função tirava o `publish_status` daqui: +-- +-- SELECT ma.publish_status FROM module_analyses ma +-- JOIN project_modules pm ON ma.module_id = pm.id +-- WHERE pm.project_id = p_project_id AND pm.tipo = 'financeiro' AND ma.numero = 1; +-- IF v_publish_status IS NULL THEN v_publish_status := 'rascunho'; END IF; +-- +-- `module_analyses.publish_status` só era escrito pela rota `/financeiro/publicar`, +-- que NUNCA teve caller no front (o `PublishDREModal` não tinha ponto de montagem +-- — achado E3-F). Medido: **0 projetos** saíram de `rascunho`. +-- +-- Consequência: a tool de IA `getDreReadiness` — cuja descrição ensina o modelo +-- que "rascunho = 0-2 meses fechados, sistema bloqueado" — informava exatamente +-- isso para uma cliente com 32 meses apurados. O dado do mês estava certo na tela +-- e errado na boca do sistema. +-- +-- A CORREÇÃO: +-- +-- 1. `publish_status` passa a ser DERIVADO da contagem de meses fechados, com os +-- mesmos cortes que a descrição da tool já declarava (0-2 / 3-11 / 12+). Vira +-- um rótulo de MATURIDADE do dado, não um estado de publicação. +-- 2. A publicação foi aposentada: `pode_publicar`, `target_threshold`, +-- `last_published_at` e `published_by` passam a ser constantes (false/NULL). +-- O mês fecha por `apurar → gate → preview → concluir`, que grava o snapshot +-- em `POST /dre/fechar-mes`. Não há segundo caminho. +-- 3. `bloqueios` deixa de falar de publicação e passa a listar o que impede +-- FECHAR O MÊS — que é a pergunta real do gate: pendências de categorização e +-- os três impeditivos hard (empréstimo, antecipação, sócio). E passa a ser +-- sempre computado, não só quando `pode_publicar` era falso. +-- +-- O QUE ESTA MIGRATION **NÃO** FAZ, de propósito: +-- +-- - Não responde "faltam N dos 12 meses para destravar o Plano". Essa pergunta +-- tem dono: `consultorPronto` + `janelaPorMesDeEntrada` (janela ancorada na +-- ENTRADA do cliente). Esta função conta TODOS os snapshots ativos, que é outro +-- número. Dois donos para a mesma pergunta = os dois divergem na tela. +-- - Não muda a ASSINATURA (mesmas colunas, mesma ordem). `CREATE OR REPLACE` sem +-- `DROP` preserva os GRANTs — trocar a assinatura exigiria DROP+CREATE e +-- re-GRANT (armadilha já catalogada na casa). +-- - Não mexe em `module_analyses`. A tabela segue de pé, agora sem leitor no +-- caminho do cliente; a aposentadoria dela é item próprio. +-- --------------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION "public"."get_dre_readiness"("p_project_id" "uuid") +RETURNS TABLE( + "project_id" "uuid", + "publish_status" "text", + "snapshots_total" integer, + "snapshots_consecutivos" integer, + "earliest_closed" "text", + "latest_closed" "text", + "anos_completos" integer[], + "cobertura_atual_dias" integer, + "pode_publicar" boolean, + "target_threshold" "text", + "last_published_at" timestamp with time zone, + "published_by" "uuid", + "bloqueios" "text"[] +) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +#variable_conflict use_column +DECLARE + v_snapshots_ativos TEXT[]; + v_snapshots_total INT := 0; + v_consecutivos INT := 0; + v_earliest TEXT; + v_latest TEXT; + v_anos INT[]; + v_cobertura_dias INT; + v_maturidade TEXT; + v_bloqueios TEXT[] := ARRAY[]::TEXT[]; + v_pendentes_count INT; + v_total_tx INT; + v_imped_emprestimo INT; + v_imped_antecipacao INT; + v_imped_socio INT; +BEGIN + -- 1. Meses fechados (snapshot ativo, versão mais recente por mês). + SELECT ARRAY_AGG(mes_referencia ORDER BY mes_referencia) + INTO v_snapshots_ativos + FROM ( + SELECT DISTINCT ON (mes_referencia) mes_referencia + FROM public.dre_monthly_snapshots + WHERE dre_monthly_snapshots.project_id = p_project_id + AND invalidated_at IS NULL + ORDER BY mes_referencia, version DESC + ) sub; + + v_snapshots_total := COALESCE(array_length(v_snapshots_ativos, 1), 0); + + IF v_snapshots_total > 0 THEN + v_earliest := v_snapshots_ativos[1]; + v_latest := v_snapshots_ativos[v_snapshots_total]; + + DECLARE + v_cursor_mes DATE; + v_streak INT := 1; + v_idx INT := v_snapshots_total - 1; + v_prev_mes DATE; + BEGIN + v_cursor_mes := TO_DATE(v_latest || '-01', 'YYYY-MM-DD'); + WHILE v_idx >= 1 LOOP + v_prev_mes := TO_DATE(v_snapshots_ativos[v_idx] || '-01', 'YYYY-MM-DD'); + IF v_prev_mes = v_cursor_mes - INTERVAL '1 month' THEN + v_streak := v_streak + 1; + v_cursor_mes := v_prev_mes; + v_idx := v_idx - 1; + ELSE + EXIT; + END IF; + END LOOP; + v_consecutivos := v_streak; + END; + + SELECT ARRAY_AGG(ano ORDER BY ano) + INTO v_anos + FROM ( + SELECT + CAST(SUBSTRING(mes_referencia FROM 1 FOR 4) AS INT) AS ano, + COUNT(*) AS qtd + FROM unnest(v_snapshots_ativos) AS mes_referencia + GROUP BY ano + HAVING COUNT(*) = 12 + ) anos_completos; + + v_cobertura_dias := CURRENT_DATE + - (TO_DATE(v_latest || '-01', 'YYYY-MM-DD') + + INTERVAL '1 month' - INTERVAL '1 day')::DATE; + END IF; + + -- 2. Maturidade do dado — DERIVADA da contagem, não mais lida de + -- `module_analyses.publish_status` (escada aposentada, D3). Os cortes são + -- os mesmos que a descrição da tool de IA já declarava. + v_maturidade := CASE + WHEN v_snapshots_total >= 12 THEN 'consolidada' + WHEN v_snapshots_total >= 3 THEN 'preliminar' + ELSE 'rascunho' + END; + + -- 3. Pendentes de categorização — o mês não fecha com pendência. + SELECT + COUNT(*), + COUNT(*) FILTER (WHERE categoria_id IS NULL OR subcategoria_id IS NULL) + INTO v_total_tx, v_pendentes_count + FROM public.transactions + WHERE transactions.project_id = p_project_id; + + -- 4.a) Empréstimo hard. + SELECT COUNT(*) + INTO v_imped_emprestimo + FROM public.transactions t + JOIN public.dre_subcategories s ON s.id = t.subcategoria_id + WHERE t.project_id = p_project_id + AND s.impeditivo_tipo = 'emprestimo' + AND s.impeditivo_nivel = 'hard' + AND NOT EXISTS ( + SELECT 1 FROM public.dre_detalhamento d + WHERE d.project_id = p_project_id + AND d.tipo = 'emprestimo' + AND d.mes_referencia = to_char(t.data, 'YYYY-MM') + AND d.resultado IS NOT NULL + ); + + -- 4.b) Antecipação hard. + SELECT COUNT(*) + INTO v_imped_antecipacao + FROM public.transactions t + JOIN public.dre_subcategories s ON s.id = t.subcategoria_id + WHERE t.project_id = p_project_id + AND s.impeditivo_tipo = 'antecipacao' + AND s.impeditivo_nivel = 'hard' + AND NOT EXISTS ( + SELECT 1 FROM public.dre_detalhamento d + WHERE d.project_id = p_project_id + AND d.tipo = 'antecipacao' + AND d.mes_referencia = to_char(t.data, 'YYYY-MM') + AND d.resultado IS NOT NULL + ); + + -- 4.c) Sócio hard. + SELECT COUNT(*) + INTO v_imped_socio + FROM public.transactions t + JOIN public.dre_subcategories s ON s.id = t.subcategoria_id + WHERE t.project_id = p_project_id + AND s.impeditivo_tipo = 'socio' + AND s.impeditivo_nivel = 'hard' + AND t.socio_natureza IS NULL; + + -- 5. Bloqueios — agora SEMPRE computados, e só sobre FECHAR O MÊS. + -- (Antes só rodavam quando `pode_publicar` era falso, e falavam de + -- publicação preliminar — vocabulário de uma escada que não existe mais.) + IF v_pendentes_count > 0 AND v_total_tx > 0 THEN + v_bloqueios := array_append(v_bloqueios, + v_pendentes_count || ' lançamento(s) ainda sem categoria — o mês não fecha com pendência.'); + END IF; + IF v_imped_emprestimo > 0 THEN + v_bloqueios := array_append(v_bloqueios, + v_imped_emprestimo || ' lançamento(s) de "Paguei Parcela de Empréstimo" / "Empréstimo Recebido" precisam de saldo/taxa/prazo no gate (o sistema decompõe em juros e amortização).'); + END IF; + IF v_imped_antecipacao > 0 THEN + v_bloqueios := array_append(v_bloqueios, + v_imped_antecipacao || ' lançamento(s) de "Antecipei Recebíveis" precisam de bruto/taxa/prazo médio no gate (o sistema isola o deságio).'); + END IF; + IF v_imped_socio > 0 THEN + v_bloqueios := array_append(v_bloqueios, + v_imped_socio || ' lançamento(s) com sócio precisam ter a natureza confirmada (aporte/mútuo/devolução/distribuição/pró-labore).'); + END IF; + + RETURN QUERY SELECT + p_project_id, + v_maturidade, + v_snapshots_total, + v_consecutivos, + v_earliest, + v_latest, + COALESCE(v_anos, ARRAY[]::INT[]), + v_cobertura_dias, + -- Publicação aposentada (D3): não há o que publicar. Colunas mantidas + -- só para preservar a assinatura (e com ela os GRANTs). + false, + NULL::TEXT, + NULL::TIMESTAMPTZ, + NULL::UUID, + v_bloqueios; +END; +$$; + +COMMENT ON FUNCTION "public"."get_dre_readiness"("uuid") IS +'Maturidade da apuração + o que impede fechar o mês. `publish_status` é rótulo DERIVADO da contagem de meses fechados (0-2 rascunho / 3-11 preliminar / 12+ consolidada) — a escada de publicação foi aposentada em 2026-08-20 (D3) e as colunas pode_publicar/target_threshold/last_published_at/published_by são vestigiais. Para "faltam N dos 12 meses do Plano de Voo", o dono da pergunta é consultorPronto + janelaPorMesDeEntrada, NÃO esta função (ela conta todos os snapshots, não a janela da entrada).'; + +-- --------------------------------------------------------------------------- +-- PostgREST: recarregar o schema cache +-- --------------------------------------------------------------------------- +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260820020000_view_perde_o_placar_das_30_analises.sql b/supabase/migrations/20260820020000_view_perde_o_placar_das_30_analises.sql new file mode 100644 index 00000000..f1063342 --- /dev/null +++ b/supabase/migrations/20260820020000_view_perde_o_placar_das_30_analises.sql @@ -0,0 +1,99 @@ +-- --------------------------------------------------------------------------- +-- D10 (Lucas, 2026-08-20) — `vw_company_context` perde o placar das 30 análises +-- --------------------------------------------------------------------------- +-- +-- O PROBLEMA: +-- +-- A view carregava duas colunas do modelo de diagnóstico aposentado: +-- +-- ( SELECT count(*) FROM module_analyses ma JOIN project_modules pm ... +-- WHERE pm.project_id = p.id AND ma.status = 'concluido') AS analyses_completed, +-- 30 AS analyses_total, +-- +-- O `30` é literal em SQL — herança de "3 módulos × 10 análises". Com +-- MODULOS_EXTRAS OFF só existe o financeiro (teto 10), e `analyses_completed` +-- é **0 em todo projeto real** porque os escritores viviam atrás da escada de +-- publicação aposentada na D3. O par ia inteiro para o prompt da IA como +-- "PROGRESSO (0/30 análises)" — logo abaixo de um snapshot financeiro com 12 +-- meses fechados. O modelo lia o placar e concluía que não havia dado. +-- +-- O consumidor no código já foi removido (fatia 1 e 3 do plano de correção). +-- Esta migration tira a fonte, para que o placar não volte por descuido. +-- +-- O QUE MUDA: a view perde EXATAMENTE duas colunas — `analyses_completed` e +-- `analyses_total`. Todo o resto é byte-idêntico. +-- +-- O QUE **NÃO** MUDA, de propósito: +-- +-- - `completed_analyses` FICA. Ela não é fantasma: é a fonte dos gaps +-- qualitativos de comercial/gestão, que são feature atrás da flag +-- MODULOS_EXTRAS. Com a flag OFF vem vazia; com ela ON volta a valer. +-- - `modules_status` FICA. Está sem leitor hoje, mas é estado de MÓDULO, não o +-- placar de análises — fora do escopo desta decisão. +-- - `faturamento_anual`, `funcionarios_quantidade` e `area_atuacao` FICAM como +-- NULL. São dívida declarada de OUTRA trilha ("a Fase 2 reescreverá o +-- consumidor", no comentário original da view). Deletar órfão de trilha +-- alheia dentro de sessão de feature é anti-padrão da lei de método. +-- +-- POR QUE DROP + CREATE E NÃO REPLACE: `CREATE OR REPLACE VIEW` não remove +-- colunas — exige a mesma lista. O DROP leva os GRANTs junto, então eles são +-- reemitidos no fim (mesma armadilha já catalogada para funções). +-- +-- DEPOIS DE APLICAR: regerar `src/types/supabase.ts` e `db/ESTRUTURA.md` +-- (`npx tsx scripts/db/gerar-estrutura.ts`), senão o tipo gerado segue +-- anunciando colunas que não existem mais. +-- --------------------------------------------------------------------------- + +DROP VIEW IF EXISTS "public"."vw_company_context"; + +CREATE VIEW "public"."vw_company_context" AS + SELECT "p"."id" AS "project_id", + "p"."nome" AS "project_name", + "p"."status" AS "project_status", + "c"."nome" AS "client_name", + "c"."nome_fantasia" AS "client_nome_fantasia", + COALESCE("co_self"."cnpj", "co_principal"."cnpj") AS "cnpj", + COALESCE("co_self"."segmento", "co_principal"."segmento") AS "segmento", + COALESCE("co_self"."porte", "co_principal"."porte") AS "porte", + COALESCE("co_self"."cnae_codigo", "co_principal"."cnae_codigo") AS "cnae_codigo", + COALESCE("co_self"."cnae_descricao", "co_principal"."cnae_descricao") AS "cnae_descricao", + NULL::numeric AS "faturamento_anual", + NULL::integer AS "funcionarios_quantidade", + COALESCE("co_self"."regime_tributario", "co_principal"."regime_tributario") AS "regime_tributario", + NULL::"text" AS "area_atuacao", + ( SELECT "json_agg"("json_build_object"('tipo', "pm"."tipo", 'status', "pm"."status", 'progresso', "pm"."progresso", 'ordem', "pm"."ordem") ORDER BY "pm"."ordem") AS "json_agg" + FROM "public"."project_modules" "pm" + WHERE ("pm"."project_id" = "p"."id")) AS "modules_status", + ( SELECT "json_agg"("json_build_object"('module', "pm"."tipo", 'numero', "ma"."numero", 'nome', "ma"."nome", 'status', "ma"."status", 'resultado', "ma"."resultado", 'parecer', "ma"."parecer", 'data_conclusao', "ma"."data_conclusao") ORDER BY "pm"."ordem", "ma"."numero") AS "json_agg" + FROM ("public"."module_analyses" "ma" + JOIN "public"."project_modules" "pm" ON (("ma"."module_id" = "pm"."id"))) + WHERE (("pm"."project_id" = "p"."id") AND ("ma"."status" = 'concluido'::"text"))) AS "completed_analyses", + ( SELECT "json_agg"("json_build_object"('hypothesis', "h"."title", 'outcome', "r"."outcome", 'learning', "r"."ai_learning_notes")) AS "json_agg" + FROM (("public"."atr_hypotheses" "h" + LEFT JOIN "public"."atr_tests" "t" ON (("t"."hypothesis_id" = "h"."id"))) + LEFT JOIN "public"."atr_results" "r" ON (("r"."test_id" = "t"."id"))) + WHERE (("h"."project_id" = "p"."id") AND ("r"."id" IS NOT NULL))) AS "htr_history" + FROM ((("public"."projects" "p" + LEFT JOIN "public"."clients" "c" ON (("c"."id" = "p"."client_id"))) + LEFT JOIN "public"."companies" "co_self" ON (("co_self"."id" = "p"."company_id"))) + LEFT JOIN "public"."companies" "co_principal" ON ((("co_principal"."client_id" = "p"."client_id") AND ("co_principal"."is_principal" = true)))); + + +ALTER VIEW "public"."vw_company_context" OWNER TO "postgres"; + + +COMMENT ON VIEW "public"."vw_company_context" IS 'View consolidada do contexto da empresa para injeção nos prompts de IA. Após Frente C: dossier vem da company do projeto (individual) ou da company principal do grupo (consolidado). Campos faturamento_anual/funcionarios_quantidade/area_atuacao retornam NULL — saíram do schema, a Fase 2 reescreverá o consumidor. Em 2026-08-20 (D10) saíram analyses_completed e analyses_total (`30` literal): o modelo de 30 análises foi aposentado e o placar ia para o prompt da IA como "0/30" mesmo com 12 meses fechados.'; + + +-- --------------------------------------------------------------------------- +-- O DROP levou os GRANTs — reemitir exatamente os que existiam. +-- --------------------------------------------------------------------------- +GRANT ALL ON TABLE "public"."vw_company_context" TO "anon"; +GRANT ALL ON TABLE "public"."vw_company_context" TO "authenticated"; +GRANT ALL ON TABLE "public"."vw_company_context" TO "service_role"; + + +-- --------------------------------------------------------------------------- +-- PostgREST: recarregar o schema cache +-- --------------------------------------------------------------------------- +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260820030000_antecipacao_ganha_o_modo_por_taxa.sql b/supabase/migrations/20260820030000_antecipacao_ganha_o_modo_por_taxa.sql new file mode 100644 index 00000000..002fa5c4 --- /dev/null +++ b/supabase/migrations/20260820030000_antecipacao_ganha_o_modo_por_taxa.sql @@ -0,0 +1,38 @@ +-- E3-B — o switch da antecipação: o modo "sei a taxa que pago" +-- +-- O extrato credita o LÍQUIDO; a face está no borderô, que a maior parte do ICP +-- não tem em mãos. Até aqui o gate exigia a face — e um campo de formulário +-- trancava o fechamento do mês (e com ele as 7 perguntas do produto) para todo +-- cliente que desconta duplicata. +-- +-- A face deixa de ser obrigatória: quem sabe a taxa declara % ao mês + prazo, e +-- o sistema deriva a face do líquido (face = líquido / (1 − taxa × prazo/30)). +-- Esse caminho é um MODO próprio, não um 'agregado' com um campo diferente: +-- quem ler 'agregado' depois tem o direito de assumir que a face foi declarada +-- pelo dono, e no modo por taxa ela é ESTIMADA. Esconder isso dentro do `dados` +-- é como um número estimado vira um número medido no mês seguinte. +-- +-- Só adiciona valor ao CHECK — nenhuma linha existente muda de modo. + +ALTER TABLE "public"."dre_detalhamento" + DROP CONSTRAINT IF EXISTS "dre_detalhamento_modo_check"; + +ALTER TABLE "public"."dre_detalhamento" + ADD CONSTRAINT "dre_detalhamento_modo_check" + CHECK ("modo" = ANY (ARRAY[ + 'agregado'::"text", + 'detalhado'::"text", + 'por_contrato'::"text", + 'por_linha'::"text", + 'hibrido'::"text", + 'por_taxa'::"text" + ])); + +COMMENT ON COLUMN "public"."dre_detalhamento"."modo" IS + 'Como o detalhamento foi informado. antecipacao: agregado/por_linha/hibrido = a FACE veio do dono (borderô); por_taxa = o dono declarou a taxa e a face foi DERIVADA do líquido (número estimado — a tela é obrigada a rotular). emprestimo: agregado/por_contrato.'; + +-- A coluna de taxa da linha passa a ter dois sentidos; o comentário precisa dizer. +COMMENT ON COLUMN "public"."dre_detalhamento_antecipacao_linha"."taxa_media" IS + 'Taxa da operação em % do VALOR BRUTO (face) — ex: 3.2 = 3,2%. NULL = usa média ponderada das linhas preenchidas. Não confundir com a taxa % AO MÊS do modo por_taxa, que vive em dre_detalhamento.dados e é convertida pelo prazo antes de virar % da face.'; + +NOTIFY pgrst, 'reload schema';