# Handoff - Integração CVCRM (Construtor de Vendas) · atualizado 2026-07-03

> **CROSS-INTEGRATION (objetivo principal) — viabilidade investigada 2026-07-03:** ver doc dedicado
> [cvcrm-cross-integration-viabilidade.md](cvcrm-cross-integration-viabilidade.md). TL;DR: o Exent Hub tem
> lead-level riquíssimo (`fetchLeads` → campanha/adset/ad/keyword + email+phone), mas **o join VENDA↔lead
> está quebrado na origem** (recadastro no CV): leads internet casam 20/20 com Exent Hub por email, mas
> vendas casam 0% (email/tel/CPF/idcliente todos falham). Atribuição por venda inviável com dado atual.
> Viável: funil de leads Exent Hub × custo por campanha/keyword/anúncio. Retomar por esse doc. Continua sem commit.

> **Editor de templates — gráficos não pareavam a 50%:** limitação GERAL do editor (não é bug cvcrm):
> (a) blocos já colocados não arrastam entre linhas (só `CanvasRow` é sortable; `CanvasBlock` não), (b)
> `integrationItems` fixava `default_size=12` p/ todo bloco de integração → dois gráficos nunca dividiam
> uma linha (o 2º entra a 100% e não cabe nos 6 restantes; e não dá p/ arrastar o já-colocado). Fix
> (baixo risco): `TabBlockDefinitions::integrationItems` passou a respeitar `$block['default_size'] ?? 12`;
> os 4 gráficos cvcrm que pareiam no tab (Evolução, Origem dos Leads, Estoque por Situação, Vendas por
> Mídia) ganharam `default_size => 6` em `MetricDefinitions::BLOCKS['cvcrm']` → entram a 50% no catálogo e
> podem ser arrastados lado a lado (6+6). Só backend (catálogo é server-side; `size` vem do def ao vivo,
> sem seeder/build; optimize:clear). Efeito colateral bom: exent_hub (único outro com default_size no BLOCKS)
> passa a refletir 6 no catálogo p/ ranking/donut/campanhas/keywords. **Pendência conhecida (não feita):**
> arrastar bloco JÁ colocado entre linhas (rearranjo livre) exige feature nova de DnD no editor.
> **🐞 Blocos cvcrm renderizavam a 25% (50% de 50%) no template:** os 4 gráficos que pareiam estão dentro
> de grids `lg:grid-cols-2` no `renderPeriodo`; ao renderizar UM bloco standalone (CvCrmPeriodBlock com
> showBlock filtrado), o grid ficava com 1 filho → 50% do grid × 50% da célula do template = 25%. Fix: os
> 2 grids de par no `renderPeriodo` só usam `lg:grid-cols-2` quando AMBOS do par estão visíveis; com um só
> (standalone) viram `grid-cols-1` → o gráfico ocupa 100% da célula. Aba dedicada (ambos visíveis) intacta.

> **🐞 Bloco cvcrm não aparecia em aba fixa (ex.: Captação):** as abas fixas gateiam na integração
> NATIVA delas — `LeadsFunilTab` (Exent Hub) e `TrafegoTab` (GA4) fazem early-return "Nenhum dado..." se a
> nativa não tem dados, e `AquisicaoTab` faz `EmptyState` se `activeProviders==0`. Num projeto SÓ-cvcrm
> (ex.: Vale Verde/Arena, sem Exent Hub) a aba bloqueava e nunca renderizava o template — então o bloco
> cvcrm nem aparecia. **Fix "gate ciente de outros providers"** (decisão do usuário): helper
> `layoutHasForeignData(rows, data)` em `lib/blockGrid.js` (mapa `FOREIGN_BLOCK_PROVIDERS` → `cvcrm:
> data.cvcrm.available`); os 3 gates viraram `!nativaTemDado && !layoutHasForeignData(...)`. Assim a aba
> renderiza o template se a nativa TEM dado OU se o layout tem bloco de outro provider com dado. Card
> "sem dados" continua p/ projeto só-nativo sem dado. Aquisição já preservava blocos cvcrm no filteredLayout
> (não casam com AD_PROVIDERS). Validado no Arena (template "Teste CV" com cvcrm_block_kpis na Captação).
> **2ª camada do mesmo bug — a ABA nem aparecia no menu:** além do conteúdo, a VISIBILIDADE da aba fixa é
> gateada (`DashboardNav` filtra por `features.has(item.feature)` — leads exige `crm`/Exent Hub; e
> `TAB_REQUIREMENTS`/`firstAvailableTab` no `Dashboard.jsx` idem). Num projeto só-cvcrm a Captação nem
> entrava na navegação. Fix: `Dashboard.jsx` computa `foreignTabs` (Set de abas fixas cujo `templateLayout`
> tem bloco `cvcrm_*` E cvcrm `has_data` no prop `integrations` — Dashboard.jsx roda FORA do provider de
> dados, por isso usa `integrations`, não `data.cvcrm`); `TAB_REQUIREMENTS` vira `nativa || foreignTabs.has(tab)`
> e o Set é passado como `forcedTabs` ao `DashboardNav` (filtro: `features.has(feature) || forcedTabs.has(id)`).
> Agora a aba fixa aparece se tiver dado nativo OU um bloco cvcrm com dado no template dela.

> **Etapa 4 do guia — blocos no EDITOR DE TEMPLATES (só Período):** o cvcrm agora aparece no catálogo
> `/templates` como "Construtor de Vendas", expondo **só os 10 blocos de Período** (KPIs[grupo], Funil,
> Evolução, Origem dos Leads, Estoque por Situação, Vendas por Mídia, Ciclo, Motivo de Perda, Corretores,
> Vendas & Reservas). Os 4 do Consolidado (Velocidade, Estoque por Bloco/Faixa/Planta) ficam SÓ na aba
> dedicada. **Abordagem sem duplicação:** `CvCrmTab` exporta `CvCrmPeriodBlock({blockKey})` que reusa o
> `renderPeriodo` com `showBlock` filtrado p/ renderizar UM bloco (lê `data.cvcrm` do contexto, já filtrado
> por data); `blockRegistry` mapeia `cvcrm_block_*` → esse componente. `MetricDefinitions::BLOCKS['cvcrm']`
> ganhou flag `template => true` nos 10; `TabBlockDefinitions::integrationItems` expõe só os `template`
> (e NENHUM KPI card individual — decisão: só o grupo). Labels do cvcrm em TabBlockDefinitions +
> TemplateController + DashboardConfigController. Seeder `DashboardBlockSeeder` rodado → 10 rows em
> `dashboard_blocks` (0 blocos de Consolidado vazaram). Se o projeto não tiver dados cvcrm, o bloco
> renderiza null (graceful). Build/seed/optimize:clear ok.

> **Liga/desliga por BLOCO individual (igual instagram_social):** o CVCRM agora participa do maquinário de
> blocos p/ o dashboard-config (mantendo a aba custom). (1) `MetricDefinitions::BLOCKS['cvcrm']` = **14
> blocos agrupados por conceito** (block_kpis, block_funil, block_evolucao, block_origem_leads,
> block_estoque_situacao, block_vendas_midia, block_ciclo, block_velocidade, block_corretores,
> block_estoque_bloco, block_estoque_faixa, block_estoque_planta, block_motivo_perda, block_reservas) —
> cada key controla o mesmo gráfico nas DUAS sub-abas (Período+Consolidado). A tela dashboard-config
> renderiza os 14 toggles automaticamente (deriva de `blocksForIntegration`). (2) `CvCrmTab` recebe prop
> `blocks` (de `integrationConfigs.cvcrm.blocks` via Dashboard.jsx) e um helper `showBlock(key)` (default
> visível se não há config / key ausente); cada gráfico é gateado por `{showBlock("block_x") && (...)}` nas
> duas views. (3) **Excluído do editor de templates** (`TabBlockDefinitions::integrationItems` faz
> `continue` p/ cvcrm) — os gráficos NÃO são componentes standalone no blockRegistry, então ficariam
> quebrados em /templates. Testado round-trip (ocultar block_motivo_perda → some da aba; funil segue).
> Granularidade agrupada (~14) foi decisão do usuário.
> **KPIs individuais (expandir o bloco KPIs, igual instagram):** `METRICS['cvcrm']` = **9 KPIs**
> (leads, vendas, vgv, ticket_medio, conversao, estoque_total, estoque_vendida, estoque_disponivel,
> estoque_reservada) → o bloco block_kpis no dashboard-config expande e lista os 9 toggles. `CvCrmTab`
> ganhou `showMetric(key)` (prop `metrics`) e cada `<KpiCard>` das duas views é gateado por key (um toggle
> controla o card em ambas). Default section_visible do cvcrm agora é explícito (`DEDICATED_TAB_TYPES`) no
> `show()`, pois a heurística "sem métricas/blocos" deixou de valer. Round-trip testado (ocultar VGV → some).

> **Fechamento do checklist do GUIA_NOVA_INTEGRACAO (2026-07-03):** auditado o cvcrm vs. o guia.
> ✅ Já ok: Etapa 1 (provider/providerFor/enums+CHECK/Form.jsx), 2 (fact_cvcrm_* + grain_id), 3
> (ícone/label/syncPolicy/Requests + toggle ativar/pausar do card do projeto, que é genérico), 5 (aba
> ligada + dados). **Gap corrigido: ligar/desligar a SEÇÃO no dashboard-config não funcionava** (a aba era
> injetada à força ignorando `section_visible`; salvar dava 404 porque cvcrm não estava em
> `MetricDefinitions::validTypes()`; label saía cru). Fix (mesmo padrão das outras integrações):
> (1) `MetricDefinitions::METRICS['cvcrm'] = []` → entra em `validTypes()` (update aceita) mas fica FORA do
> catálogo de blocos/templates (integrationItems não gera item cvcrm); (2) label em
> `DashboardConfigController::INTEGRATION_LABELS`; (3) `Dashboard.jsx` respeita `section_visible` (default
> VISÍVEL só quando não há config salvo, pois tem aba dedicada; OFF explícito oculta); (4) default de
> `section_visible` no `show()` = visível p/ integrações sem métricas/blocos (aba dedicada), alinhado ao
> Dashboard.jsx; (5) nota no card do Config.jsx. Round-trip testado (off→oculta, on→visível). CVCRM **não
> tem blocos individuais** (aba custom CvCrmTab, por design) → o controle é a nível de SEÇÃO.
> 🐞 **Fix (erro ao salvar o toggle de seção):** `DashboardConfigController::update` usava
> `'metrics' => 'required|array'`; o cvcrm manda `metrics: []` e o `required` do Laravel **reprova array
> vazio** → 422. Trocado por `'present|array'` (deve existir, pode ser vazio). Testado: cvcrm salva,
> integração normal e validação de métrica malformada seguem OK. Só backend (sem rebuild).
> ⬜ **Pendente (decisão do usuário: depois):** testes do provider (Etapa 1.6 — `Http::fake`, idempotência).

> **UX aba Consolidado (2 ajustes):** (1) **Filtro de data travado** no Consolidado (que é all-time):
> `DashboardFiltersContext` ganhou `filterLock` (label|null) + `setFilterLock` (setter estável, padrão
> `allowToday`); `DashboardFilters.jsx` faz early-return exibindo um botão desabilitado com o label
> ("Todo o período") quando travado; `CvCrmTab` seta `setFilterLock("Todo o período")` via useEffect
> quando `view==="consolidado"` e limpa no Período/unmount (escopo só desta aba). (2) **View persistida:**
> `view` (periodo/consolidado) agora inicia de `localStorage["cvcrm-view"]` e é gravado a cada mudança —
> sobrevive a refresh (antes voltava sempre p/ Período).

> **Motivo de Perda (Período + Consolidado):** a coluna `motivo`/`idmotivo` mapeada vem NULL nesta conta,
> mas o `raw` dos leads traz `motivo_cancelamento` em ~100% dos "Perdido" (+ `descricao_motivo_cancelamento`
> texto livre e `data_cancelamento`; `submotivo_cancelamento` vazio). `DashboardDataController::
> getCvCrmMotivoPerda($pid, $startTs?, $endTs?)` agrupa leads Perdido por `raw->>'motivo_cancelamento'`
> (groupByRaw); Período filtra por `data_cad` (consistente c/ o funil), Consolidado all-time. Payload
> `motivoPerda`/`consolidado.motivoPerda` = `{total, itens:[{motivo,n,pct}]}`; front = `<MotivoPerdaCard>`
> (RankedBarList vermelho) nas duas abas. Lê direto do raw (sem migration/re-pull). Top motivos: Sem
> Interesse, Não atende, Dados não conferem, Comprou outro produto, Desistência, Renda não compatível.

> **TL;DR do dia 2026-07-03:** **FINANCEIRO REMOVIDO POR COMPLETO (decisão do usuário).** Comissões e
> repasses/financiamento saíram do dashboard, do banco e dos scripts de enriquecimento; o bloco
> **Mix de Pagamento** também foi removido do Consolidado. Escopo: (1) front `CvCrmTab.jsx` - removidos
> `ComissoesDotPlot`, `RepasseEtapasBar`/`ETAPA_COLORS`, `renderFinanceiro`, o card Mix de Pagamento
> (Corretores por VGV virou largura total) + imports `Wallet`/`Landmark`; (2) back
> `DashboardDataController` - removidos `getCvCrmFinanceiro`, `repasseEtapa`, `getCvCrmMixPagamento` e as
> chaves `financeiro`/`mixPagamento` do payload; (3) `CvCrmProvider` - removidos imports dos models,
> pull/persist de comissões e repasses do `syncAccount`, `pullFinanceiroStreams`/`persistComissoes`/
> `persistRepasses`/`markFinanceiroFull` e os cursores `comissoes`/`repasses`; (4) deletados os models
> `FactCvCrmComissao`/`FactCvCrmRepasse` e as migrations `..._000002_...`/`..._000003_...`; (5) **banco:
> dropadas `fact_cvcrm_comissoes` (1651 linhas) e `fact_cvcrm_repasses` (1638) + limpas as 2 entradas
> órfãs de `migrations`, os cursores stale da credencial e o `cvcrm_financeiro_full_at` dos 6 projetos.**
> `npm run build` ok; payload validado no Boa Vista (sem `financeiro`/`mixPagamento`, demais gráficos
> intactos). **`fact_cvcrm_reservas` mantida** (alimenta VGV/vendas; o Mix lia dela mas o gráfico saiu).
> As migrations `..._000002/000003` só haviam rodado no banco de staging (nunca commitadas → prod nunca
> as criou).
>
> **Ofuscação de nomes de pessoas (mesmo dia, LGPD/exposição de dados):** nos 3 blocos com nome de gente
> (Período/Performance por Corretor, Período/Vendas & Reservas [corretor **e cliente**],
> Consolidado/Corretores por VGV) o nome agora é ofuscado. **Estratégia de 2 camadas:** (1) **backend**
> `DashboardDataController::obfuscateName()` reduz a "Primeiro I. I." (ex.: "Leonardo Leme da Silva" →
> "Leonardo L. S."; conectores de/da/do descartados) — aplicado em `getCvCrmCorretores` (por ÚLTIMO, após
> os joins de `extra`/badges que usam o nome completo como chave), `getCvCrmReservasRecentes` (cliente+
> corretor) e `getCvCrmCorretoresVgv`; **o nome completo/sobrenomes NUNCA são serializados p/ o front**
> (é a proteção real). (2) **frontend** `CvCrmTab.jsx`: exibe o valor abreviado como texto normal.
> ⚠️ O efeito de **blur estilo Telegram foi implementado e depois DESCARTADO** (decisão do usuário: exibir
> as iniciais dos sobrenomes em texto puro, sem blur) — a proteção é 100% backend; o front só renderiza o
> que recebe.
>
> **Gráfico "Disponíveis por Planta" (Consolidado):** novo card no Consolidado. A CVDW **não preenche**
> `tipologia`/`tipo`/`andar`/`vagas_garagem` nesta conta (vêm null), mas cada unidade traz
> `raw->plantas->{idplanta,nomePlanta}` (objeto único, não lista). `DashboardDataController::
> getCvCrmEstoquePorPlanta()` agrupa **TODAS** as unidades por `nomePlanta` com breakdown de situação
> (total/vendida/disponivel/reservada/bloqueada via `COUNT FILTER`), **remove o prefixo comum**
> (torre/bloco repetido) via `commonPrefixWords()` p/ o rótulo destacar final/tipologia (ex.: Porto Fino
> → "12-TIPO- STUDIO PLUS", "2 DORMS SUÍTE"; Bella Roma → "TÉRREO E TIPO - FINAL 2"), ordenado por total
> desc. Também traz a **metragem** (`ROUND(AVG(area_privativa),2)` — `area_privativa` é 100% preenchida e
> uniforme por planta): concatenada no `label` (ex.: "FINAL 5 · 41m²") e usada pra **ordenar por área
> crescente** (menor→maior planta; sem área vai pro fim, total como desempate). **Cap "Outras" removido**
> (não faz sentido com ordenação por tamanho — o bucket não tem área única). Payload em
> `consolidado.estoquePorPlanta` (`{planta,label,area,total,vendida,disponivel,reservada,bloqueada}`);
> front = **barra empilhada horizontal** (recharts, cores oficiais CV: vendida verde × disponivel cinza ×
> reservada dourado × bloqueada ardósia; **% do total rotulado dentro de cada segmento** via `barPctLabel`,
> oculto se estreito; `TruncatedTick` ganhou prop `max` p/ não cortar o rótulo com m²). Validado: soma das
> partes == total por planta; ordenação por área confere (Porto Fino: Studio 26m²→...→Cobertura 78m²).
> ⚠️ reservada/bloqueada <2%; `nomePlanta` heterogêneo entre empreendimentos (só Porto Fino traz dorms limpos).
>
> **"Disponíveis por Faixa de Preço" → faixas ADAPTATIVAS:** as faixas fixas de 100k eram largas demais
> (o estoque ocupa banda estreita → Arena/Villa Suíssa viravam 1 barra só). `getCvCrmEstoquePorFaixa`
> agora calcula min/max do estoque e divide em ~10 faixas de largura "redonda" (nice-number: 5k/10k/20k/
> 50k…), conta por bin num único GROUP BY (`FLOOR((valor-start)/step)`), apara faixas vazias das pontas e
> mantém buracos internos (eixo contínuo). Rótulo "270–275k". Front: tick `AngledTick` (-30°) p/ os ~11
> rótulos não sobreporem. Validado: Arena 1→6 faixas, Villa Suíssa→11, Porto Fino→11 (somas batem).
> **Continua sem commit.** Próximo: ROI cross-integration (objetivo principal), commit/PR.



> **TL;DR do dia 2026-07-02:** **DIA DE POLIMENTO DO DASHBOARD (aba Construtor de Vendas).**
> Foco 100% em UI/gráficos + alguns fixes de dado no `DashboardDataController`. Principais:
> (1) **Financeiro fundido no Consolidado** (agora 2 abas: Período / Consolidado; nada de 3ª aba).
> (2) **BUG do KPI "Disponível"** (mostrava 23, real 193 no Bella Roma): estoque classificado só
> pelo log de mudanças `/unidades/situacao`; fallback p/ `situacao_mapa_disponibilidade` do base
> (1=Disp,3=Vend,4=Bloq,5=Reserv) + backfill de 638 unidades (§2). (3) **BUG do Bella Verona**
> (aba Consolidado quebrava): `getCvCrmAbsorcao` fazia `->values()->slice(-24)` → `slice` preserva
> chaves → virava objeto JSON → `.map()` do front crashava; fix `->slice(-24)->values()`. (4)
> **Cores oficiais de estoque do CV** (Disponível=cinza, Vendida=verde). (5) Vários gráficos
> refeitos (Velocidade c/ média móvel, Corretores VGV com dot plot de comissões, Repasses por
> etapa 100% empilhado, etc). (6) **Ciclo de Vendas movido p/ aba Período**, buckets de 15 dias,
> e ganhou o **conceito de CROSS-SELL** (total do gráfico = card Vendas; ver §13 + [[project-cvcrm-crosssell-rule]]).
> **Continua sem commit.** Detalhes na §12/§13. Próximo: ROI cross-integration (objetivo principal), commit/PR.


> **TL;DR do dia 2026-07-01:** **PULL-POR-CONTA (ROADMAP B) IMPLEMENTADO e validado.** A
> CVDW agora é lida por um choke point único (`CvCrmAccountPuller`, cache `database` +
> lock por chave); o recorrente é orquestrado **por credencial** (`RunCvCrmAccountSyncJob` +
> `integrations:dispatch-due-cvcrm`): puxa a conta **1×** e faz fan-out pros N projetos.
> Cursores de estoque movidos p/ `IntegrationCredential.metadata['cvcrm_cursors']`. Os **6
> projetos da conta Lidera** foram bootstrapados e o ciclo recorrente rodou: **1 pull por
> endpoint** (antes 6×). Redis não está disponível no ambiente → store = cache `database`
> (decisão do usuário). Detalhes na §9. Próximo: Fase 5 (financeiro), ROI cross-integration,
> commit/PR.


> Documento de retomada. Se for rodar de outra máquina/sessão, **leia este arquivo primeiro** -
> ele é auto-contido (não depende de memórias locais do Claude nem do plano em `~/.claude/plans`).
> Branch de trabalho: `feat/dashboard-provider-icons`. **Nada foi commitado ainda** (vários arquivos novos).

> **TL;DR do dia 2026-06-30:** ingestão (leads/reservas/unidades) concluída e validada no projeto
> **Residencial Boa Vista** (id 30); corrigidos 2 bugs (filtro multivalor `;` + paginação `fetchAllPages`);
> **estoque agora é incremental** nos 3 streams (base/situacao/precos) por cursor; **dashboard (aba
> Construtor de Vendas) construído e deployado**. Próximo: Fase 5 (financeiro), gráficos cross-integration
> de ROI, e o refactor de **pull-por-conta** (escala confirmada: ~30 projetos / 5-6 contas). Detalhes na §5;
> próximos passos na §6.

---

## 1. Objetivo

Conector próprio para o CRM imobiliário **Construtor de Vendas (CVCRM)**, trazendo fundo de funil
(lead → atendimento → reserva → venda), VGV, estoque de unidades e financeiro para o dashboard.
Fonte = **CVDW** (CV Data Warehouse), a API **analítica** do CVCRM (não a transacional).

> 🎯 **OBJETIVO PRINCIPAL (definido pelo usuário 2026-06-30):** atribuir **vendas a campanhas/mídia de
> internet** - X vendas por Mídia/Campanha, **custo por venda** cruzando com o investimento de anúncios
> (Google/Meta). É o gráfico cross-integration (CVCRM × `fact_marketing_daily`). A espinha de atribuição
> já existe (reservas.midia + idlead → lead.origem/midia). Os gráficos de ROI ficaram para etapa posterior.

## 2. Fatos da API (CVDW) - validados na conta real "Lidera"

- **Base URL por cliente:** `https://{subdominio}.cvcrm.com.br/api/v1/cvdw/...` (prefixo `/api/v1/cvdw`).
- **Auth v1:** headers `email` + `token`. (Provider também suporta `v3` Bearer via `metadata.auth_version`.)
- **Envelope:** `{ pagina, registros, total_de_registros, total_de_paginas, dados:[...] }`.
- **Paginação:** `pagina` + `registros_por_pagina` (máx 500).
- **Incremental:** só filtra por **data** (`a_partir_data_referencia` / `ate_data_referencia`).
  ⚠️ **NÃO filtra por empreendimento no servidor** (testado: `idempreendimento` é ignorado) → puxamos a
  conta inteira e filtramos do nosso lado.
- **Rate limit:** CVDW = **20 req/min** (REST = 200/min). Excedeu → `429` + bloqueio de 1 min.
  Boas práticas (doc): sem requisições paralelas, ~3s entre cada.
- **Endpoints HABILITADOS nesta conta:** `/leads`, `/reservas`, `/reservas/contratos`, `/unidades`,
  `/unidades/situacao`, `/comissoes`, `/comissoes/pagamentos`, `/repasses`, `/atendimentos`,
  `/leads/conversoes`, `/leads/perdas`.
- **DESABILITADOS (405) nesta conta:** `/empreendimentos`, `/contratos`, `/pre-cadastros`
  (quais tabelas estão habilitadas é config por conta no CVDW).
- ⚠️ **A `/leads` é LENTA para janelas grandes** (a página é recomputada por request). Janela de 90d
  estourava 60s; por isso bootstrap usa **chunks de 15 dias** e timeout **90s**.
- ⚠️ **`idempreendimento` vem MULTIVALORADO com `;`** quando o lead toca vários empreendimentos (ex.:
  `"30;14"`; o `empreendimento` tem os nomes paralelos por índice). O filtro PRECISA dar split por `;`
  (senão descarta ~25-30% dos leads). Resolvido por `empreendimentoPairs()`/`resolveEmpreendimentos()`.
- ⚠️ **`/unidades/situacao` reporta `total_de_paginas`/`total_de_registros` BUGADOS** (calculados como se
  rpp=1) e devolve **páginas curtas no MEIO** da sequência → o `fetchAllPages` agora só para na página
  VAZIA (não na incompleta) e só confia no total se for plausível. `MAX_PAGES` = 400.
- ✅ **`a_partir_data_referencia` funciona em `/unidades`, `/unidades/precos` E `/unidades/situacao`**
  (testado: situacao 92k full → 171 em 7d) → estoque incremental por delta (ver §5).
- **Vocabulário de status (`/unidades/situacao.para_situacao`):** Disponível / Vendida / Reservada / Bloqueada.
- 🐞 **BUG DE CLASSIFICAÇÃO CORRIGIDO (2026-07-02):** `/unidades/situacao` é um **log de MUDANÇAS** de status
  → unidades que nasceram Disponíveis e nunca transicionaram **não têm linha lá** → ficavam com
  `situacao_nome=NULL` e sumiam do KPI "Disponível" (Bella Roma mostrava 23, real 193). O `/unidades` base
  carrega `situacao_mapa_disponibilidade` em TODA unidade (**1=Disponível, 3=Vendida, 4=Bloqueada,
  5=Reservada** — validado: bate 100% com a classificação por texto onde ambos existem). `estoqueFlags()`
  agora usa o texto da mudança como primário e o `mapa` do base como fallback. Backfill (638 unidades, do
  `raw` gravado, sem tocar na API) reconciliou os 6 projetos (soma das situações = total, zero NULL).
- **Schema do FINANCEIRO** (`/comissoes`,`/repasses`,`/atendimentos`) já mapeado - ver §6 Passo "Fase 5".

## 3. Decisões de arquitetura (já implementadas)

- **CVCRM não usa "ativos".** A credencial é a CONTA; o vínculo projeto↔empreendimento é **1:1** via
  `ProjectIntegration.external_id` = **um** `idempreendimento` (digitado manualmente no vínculo;
  vazio = todos da conta). `discoverAssets()` retorna `[]`; painel "Ativos descobertos" oculto p/ cvcrm.
- **Tabelas keyed por `project_id`** (consistente com as outras integrações). Sync puxa a conta e grava
  só as linhas do empreendimento do projeto.
- **ROADMAP "B" - pull por conta (decisão do usuário):** toda empresa via CV terá **vários projetos** na
  mesma conta. **Escala confirmada (2026-06-30): ~30 projetos CV em 5-6 contas** → hoje a MESMA conta é
  varrida ~5-6×/dia (uma por projeto), pois todas as entidades são account-wide. Migrar para **pull 1x por
  credencial/conta** + fan-out por `idempreendimento` (tabelas keyed por `credential_id`+`idempreendimento`,
  ou staging por credencial mantendo `project_id`). Resolve a redundância N× de leads/reservas/unidades.
  Fazer **antes de escalar** para vários projetos por conta em produção. (O estoque incremental de hoje -
  §5 - reduz o custo ABSOLUTO, mas não a redundância N×.)

## 4. O que está PRONTO (deployado: build + migrate + queue:restart)

| Camada | Status | Arquivos |
|---|---|---|
| Enum `cvcrm` | ✅ | `database/migrations/2026_06_29_000001_add_cvcrm_to_integration_enums.php` |
| Provider | ✅ | `app/Services/Integrations/Providers/CvCrmProvider.php` (registrado em `IntegrationCredentialService::providerFor`) |
| Sync policy | ✅ | `app/Services/Integrations/SyncPolicy.php` (cvcrm = limites flexíveis; bootstrap chunk 15d) |
| Vínculo 1:1 / forms | ✅ | `ProjectIntegrationFormSheet.jsx`, `Integracoes/Form.jsx`, `ProjectIntegrationService.php` (bind/update `external_id`), `StoreProjectIntegrationRequest`, `UpdateProjectIntegrationRequest` |
| Ícone/label | ✅ | `resources/js/Components/Dashboard/integrationIcons.jsx` |
| **Fase 2 - Funil (leads)** | ✅ ingerido e validado | `fact_cvcrm_leads` (`..._000003_...`), `FactCvCrmLead.php`, `syncLeads()` |
| **Fase 3 - Reservas/VGV** | ✅ ingerido e validado | `fact_cvcrm_reservas` (`..._000004_...`), `FactCvCrmReserva.php`, `syncReservas()` |
| **Fase 4 - Estoque** | ✅ ingerido + INCREMENTAL | `fact_cvcrm_unidades` (`..._000005_...` + `2026_06_30_000001` add `situacao_at`), `FactCvCrmUnidade.php`, `syncUnidades()` (dispatcher full/incremental) |
| Dim empreendimentos | ✅ (derivada dos leads) | `dim_cvcrm_empreendimentos` (`..._000002_...`), `DimCvCrmEmpreendimento.php` |
| **Dashboard (aba CVCRM)** | ✅ construído + deployado | back: `DashboardDataController::getCvCrmData()`; front: `tabs/CvCrmTab.jsx`; wiring: `Pages/Dashboard.jsx` |
| **Fase 5 - Financeiro** | ⬜ schema mapeado, falta construir | `/comissoes`+`/repasses` (ver §6) |
| **Cross-integration ROI** | ⬜ pendente (objetivo principal) | vendas×investimento por mídia/campanha |

`CvCrmProvider::sync()` orquestra **leads + reservas** (janela) + **unidades** (snapshot, só quando
`periodEnd` é recente, p/ não repetir a varredura cheia nos chunks históricos). `money()` normaliza
valores (ponto ou pt-BR `150.000,00`).

## 5. Estado atual / onde paramos

- Credencial **"Construtor de vendas - Lidera"** = `connected`.
  - `credential_id = 57185b5b-dd35-41af-abb3-3add451b1787` · base_url `https://lidera.cvcrm.com.br` · auth v1.
- ⚠️ **TROCA DE PROJETO (2026-06-30):** o "Park Ville" (`external_id=109`) foi **REMOVIDO** - aquele id
  NÃO existe na conta Lidera (os empreendimentos reais têm id 3..48; não há nenhum "Park Ville"). Foi
  substituído pelo projeto **Residencial Boa Vista** = `idempreendimento 30` (BOA VISTA na conta).
  - `project_id = 9dbf9705-e2ca-4bbd-8145-08051eda6fed` · `external_id = 30`.
  - **Lição:** o `external_id` é o `idempreendimento` da CVDW; conferir contra a lista real da conta
    antes de vincular (o probe que lista id→nome está no Passo 3-bis abaixo).
- 🐞 **BUG CORRIGIDO (2026-06-30):** nos leads, `idempreendimento` pode vir **multivalorado** com `;`
  (ex.: `"30;14"` = lead ligado a BOA VISTA **e** PARADISE VILLAGE). O filtro fazia match exato e
  **descartava** esses leads (~25-30% dos leads do empreendimento). Corrigido com `empreendimentoPairs()`
  + `resolveEmpreendimentos()` no `CvCrmProvider` (split por `;`, normaliza id→nome, grava o id do
  projeto). Aplicado a leads, reservas e unidades. **Requer `queue:restart`** (já feito).
- **Bootstrap do Boa Vista CONCLUÍDO (2026-06-30 15:12)** - `bootstrap_days=365` (1 ano, ~24 chunks de
  15d); rodou no código novo (multivalor corrigido). `status=active`, `bootstrap_completed_at` setado,
  incremental habilitado (1x/dia, lookback 7d). Contagens finais:
  - **leads = 3.657** · **reservas = 47** (existem em períodos mais antigos; a janela recente Mar-Jun
    estava zerada, o ano cheio capturou) · **unidades = 192** (snapshot).
  - `idempreendimento` gravado = só `30` em leads e unidades (filtro multivalor validado, zero vazamento).
  - O chunk que deu timeout no 1º disparo (`2026-01-16→30`) foi **recoberto por sucesso** no re-run.
  - Funil: Em Atendimento 1777 · Perdido 1164 · Venda Realizada 127 · Com Reserva 18 · Visita Realizada 128.
- ⚠️ **NOTA DE CARGA:** o bootstrap (24 chunks com /leads lenta) leva ~10-12 min e NÃO degradou a CVDW
  (respeita ~3s entre requests). O que queimou a API foi a saraivada de probes manuais `rpp=1` sem pausa.
  Ao testar manualmente, **espaçar** os requests.
- A CVDW da conta **esfriou** e está saudável (~1.6s no health-check de 2026-06-30).

### Estoque + Dashboard (2026-06-30, tarde)
- **Estoque ingerido** (Fase 4 completa): o `/unidades` base NÃO traz status/preço usáveis. Agora o
  `syncUnidades` enriquece via `fetchLatestSituacaoPerUnit()` (status ATUAL = última mudança em
  `/unidades/situacao`, vocab: Disponível/Vendida/Reservada/Bloqueada) + `fetchCurrentPrecoPerUnit()`
  (`/unidades/precos`, preço vigente). Flags derivadas por `estoqueFlags()`.
- 🐞 **Bug de paginação corrigido no `fetchAllPages`:** parava na 1ª página curta (`count < PAGE_SIZE`),
  mas a CVDW devolve **páginas curtas no MEIO** (`/unidades/situacao` lia só 33 de ~185 páginas). Agora
  só para em página VAZIA; `total_de_paginas`/`total_de_registros` desse endpoint vêm bugados (como se
  rpp=1) → só confia neles se plausíveis. `MAX_PAGES` 200→400.
- ✅ **ESTOQUE INCREMENTAL (2026-06-30, opção "A"):** o `/unidades/situacao` aceita `a_partir_data_referencia`
  (92k full → ~171/7d). Agora `fetchSituacaoChanges()` puxa só os DELTAS desde um **cursor account-wide**
  guardado em `settings.cvcrm_situacao_cursor` (max referencia_data visto em TODAS as linhas, não só as do
  empreendimento — senão um empreendimento esgotado/inativo faria o delta voltar meses). Merge sobre o
  status já gravado; coluna `situacao_at` por unidade (migration `2026_06_30_000001`). 1º sync = full;
  depois = delta. Cursor persistido em chave própria de settings (não colide com bootstrap_marker).
  Os TRÊS streams de estoque (base `/unidades`, `/unidades/situacao`, `/unidades/precos`) são INCREMENTAIS
  por cursor próprio (`cvcrm_base_cursor`/`cvcrm_situacao_cursor`/`cvcrm_precos_cursor` em settings).
  `syncUnidades` é dispatcher: **full** (1ª vez, sem cursor → reconstrói tudo + grava cursors) vs
  **incremental** (deltas dos 3 → atualiza só as unidades afetadas; p/ unidades fora do base-delta reusa
  o `raw` gravado). Totais: base 7.688 (~16pg), precos 9.723 (~20pg), situacao 92.219 (~185pg); deltas 7d
  = 59/83/171. **TESTADO 2026-06-30:** full 5,7min → delta **3,6s**; integridade OK (192/192 vendida,
  R$48,6M, só id 30, `situacao_at` populado). Cursors do Boa Vista JÁ gravados → o incremental diário usa deltas.
  ⚠️ Não rodar vários syncs colados (sem intervalo) num teste: estoura os 20 req/min → `429` (artefato de
  teste; produção é 1×/dia). Falta só a redundância N×-por-conta (ROADMAP "B").
- **Resultado Boa Vista:** estoque = **192/192 Vendida** (R$48,6M). É REAL (esgotado), corroborado pelo
  `situacao_mapa_disponibilidade=3` uniforme no /unidades base. Explica os 1.164 leads "Perdido".
- **Dashboard CONSTRUÍDO e deployado** (`npm run build` ok):
  - Backend: `DashboardDataController::getCvCrmData()` → payload `data.cvcrm` (funil cumulativo,
    KPIs VGV/Vendas/Ticket/Conversão/Estoque, vendasPorMidia, leadsPorMidia, timeline, corretores,
    reservasRecentes, estoque). Funil mapeado por `CVCRM_FUNNEL_RANK` (estágio máx do estado atual -
    sem historico-situacoes; "Perdido" conta só como lead). Funil Boa Vista: 3659→2495→548→453→325→127.
  - Frontend: `resources/js/Components/Dashboard/tabs/CvCrmTab.jsx` (aba auto-contida, estilo
    `LeadsFunilTab`; NÃO usa o maquinário de blocos/MetricDefinitions).
  - Wiring: `Pages/Dashboard.jsx` injeta a seção `cvcrm` em `integrationSections` quando há dados e
    roteia `section === "cvcrm"` para `<CvCrmTab/>` (excluída do `IntegrationTab` genérico).
- **PENDENTE (etapa posterior, decisão do usuário):** gráficos CROSS-INTEGRATION = vendas por
  mídia/campanha × investimento (custo por venda / ROI). Atribuição já existe (reservas.midia + idlead);
  ver [[project-cvcrm-dashboard-goal-attribution]]. CVDW manda `campanha=null` → cruzar via idlead→lead.

## 6. INSTRUÇÕES PARA AMANHÃ (ordem sugerida)

Pré-checagem do ambiente (se for outra máquina): `git status` na branch `feat/dashboard-provider-icons`,
`composer install`/`npm install` se preciso, `.env` apontando pro banco certo. Após editar: `npm run build`,
`php artisan migrate --force`, **`php artisan queue:restart`**.

### Passo 0 - Health-check da CVDW (1 request)
```bash
php artisan tinker --execute="
\$c=App\Models\IntegrationCredential::find('57185b5b-dd35-41af-abb3-3add451b1787');
\$s=app(App\Services\Integrations\IntegrationCredentialService::class)->getDecryptedSecrets(\$c);
\$t=microtime(true);
\$r=Illuminate\Support\Facades\Http::withHeaders(['email'=>\$s['email'],'token'=>\$s['token']])->acceptJson()->timeout(60)->get('https://lidera.cvcrm.com.br/api/v1/cvdw/leads',['pagina'=>1,'registros_por_pagina'=>1]);
echo 'status='.\$r->status().' '.round(microtime(true)-\$t,1).'s total='.\$r->json('total_de_registros').PHP_EOL;"
```
Se voltar **~poucos segundos** → API saudável, segue. Se travar/timeout → ainda degradada, aguardar mais.

### Passo 1 - Mapear endpoints do FINANCEIRO ✅ MAPEADO (2026-06-30)
Bater em `/comissoes`, `/repasses`, `/atendimentos` com `registros_por_pagina=1` (3s entre cada, sem
paralelo) e anotar os campos de `dados[0]`. **Não chutar campos** - foi a lição do projeto.

Schema real (conta Lidera, `dados[0]`):

- **`/comissoes`** (total 4866) - campos: `referencia`, `referencia_data`, `ativo`, `idcomissao`,
  `situacao`/`idsituacao`, `idreserva`, `corretor`, `imobiliaria`, `empreendimento` (NOME), `etapa`,
  `bloco`, `unidade`, `regiao`, `cliente`, `cep_cliente`, `valor_contrato`, `porcentagem_comissao`,
  `valor_comissao`, `valor_comissao_apagar`, `valor_pagamento`, `nota_fiscal`, `data_pagamento`, `data_cad`.
  ⚠️ **NÃO tem `idempreendimento`** - só o NOME `empreendimento`. Para filtrar pelo empreendimento do
  projeto: ou casar por nome, ou cruzar via `idreserva` → `fact_cvcrm_reservas.idempreendimento`.
- **`/repasses`** (total 4856) - tem `idempreendimento` ✅ (filtrável direto). Campos-chave: `idrepasse`,
  `idreserva`, `idempreendimento`, `codigointerno_empreendimento`, `empreendimento`, `situacao`/`idsituacao`,
  `idcliente`, `documento_cliente`, `cliente`, `valor_previsto`, `valor_contrato`, `saldo_devedor`,
  `valor_divida`, `valor_subsidio`, `valor_fgts`, `valor_financiado`, `valor_registro`, `banco`, `agencia`,
  `data_venda`, `data_contrato_contabilizado`, `data_assinatura_de_contrato`, `contrato_quitado`,
  `contrato_liquidado`, `idlead`, `idunidade`, `data_cad`/`data_cadastro`, `ultima_atualizacao`, `referencia_data`.
- **`/atendimentos`** (total 1531) - tem `idempreendimento` ✅ (filtrável direto). Campos-chave:
  `idatendimento`, `protocolo`, `idempreendimento`, `codigointerno_empreendimento`, `empreendimento`,
  `sigla_empreendimento`, `situacao`/`idsituacao`, `idlead`, `idcliente`, `idresponsavel`, `idcorretor`,
  `idimobiliaria`, `prioridade`, `origem`, `idcanal`/`canal`, `assunto`/`subassunto`, `idtipo`/`tipo`,
  `idclassificacao`/`classificacao`, `quantidade_mensagens`, `quantidade_interacoes`, `tempo_resposta`,
  `tempo_finalizado`, `encerrado_primeiro_contato`, `avaliacao`, `data_cad`, `data_modificacao`,
  `data_situacao`, `data_finalizado`, `previsao_conclusao`, `referencia_data`, `unidade`/`idunidade`, `tags`, `times`.

> Passos 3 e 4 do plano antigo (rodar bootstrap + construir dashboard) **JÁ FORAM FEITOS** hoje - ver §5.
> Validar o estado atual do Boa Vista (`project_id = 9dbf9705-e2ca-4bbd-8145-08051eda6fed`):
> ```bash
> php artisan tinker --execute="
> \$pid='9dbf9705-e2ca-4bbd-8145-08051eda6fed';
> echo 'leads='.App\Models\CvCrm\FactCvCrmLead::where('project_id',\$pid)->count().' reservas='.App\Models\CvCrm\FactCvCrmReserva::where('project_id',\$pid)->count().' unidades='.App\Models\CvCrm\FactCvCrmUnidade::where('project_id',\$pid)->count().PHP_EOL;
> echo 'cursors='.json_encode(collect(App\Models\ProjectIntegration::where('project_id',\$pid)->where('integration_type','cvcrm')->first()->settings)->only(['cvcrm_base_cursor','cvcrm_situacao_cursor','cvcrm_precos_cursor']));"
> ```

### Próximo passo A - Estoque incremental ✅ FEITO E TESTADO (2026-06-30)
(Confirmado: delta 3,6s, integridade 192/192 vendida. Nada a fazer; ver §5. Se quiser re-validar, rodar
`prov->sync()` 2× em tinker com `SyncContext(now()->subDay(), now())` - **com pausa entre os dois** p/ não tomar 429.)

### Próximo passo B - Fase 5 (Financeiro)  ⬜
Espelhar reservas: migrations `fact_cvcrm_comissoes` + `fact_cvcrm_repasses`, models em `app/Models/CvCrm/`,
métodos `syncComissoes()`/`syncRepasses()` no provider (janela por data, `money()` nos valores), plugar no
`sync()` + breakdown, e agregar no `DashboardDataController::getCvCrmData()` + render no `CvCrmTab.jsx`.
Schema real já mapeado no Passo 1 acima. **Atenção:** `/comissoes` NÃO tem `idempreendimento` → filtrar por
nome OU cruzar via `idreserva` → `fact_cvcrm_reservas.idempreendimento`.

### Próximo passo C - Gráficos cross-integration ROI (objetivo principal)  ⬜
Vendas por mídia/campanha × investimento (Google/Meta) = **custo por venda**. Cruzar `fact_cvcrm_reservas`
(situacao Vendida, midia, idlead) com `fact_marketing_daily`. `campanha` vem null da CVDW → atribuição via
`reservas.idlead` → `fact_cvcrm_leads` (origem/midia). Ver [[project-cvcrm-dashboard-goal-attribution]].

### Próximo passo D - Pull por conta (ROADMAP "B", antes de escalar)  ✅ FEITO (2026-07-01)
Implementado e validado (ver §9). Deduplica o pull account-wide por credencial (1× por conta + fan-out).

### Próximo passo E - Commit  ⬜
Nada foi commitado ainda. Vários arquivos novos (provider, migrations, models, CvCrmTab, alterações no
controller/Dashboard.jsx). Commit + abrir PR quando o usuário pedir.

## 7. Decisões de frequência / pendências (decidir com o usuário)
- **Incremental** mais frequente + janela curta (ex.: `runs_per_day=4, lookback=2`) - leads mudam o dia todo.
- Estoque incremental agora é barato (deltas), mas base/situacao/precos ainda são **per-project**
  (redundância N× por conta) - resolvido pelo pull-por-conta (§3 ROADMAP B).
- **Bootstrap "pingado"** (1 chunk a cada 30-60 min, ou de madrugada) p/ não degradar a CVDW deles.

## 8. Como acionar amanhã
Abra o Claude Code neste repositório e diga algo como:
> "Retomar a integração CVCRM - leia `docs/cvcrm-handoff.md` e siga as instruções para amanhã."

## 11. Fase 5 - Financeiro (comissões + repasses) - IMPLEMENTADO 2026-07-01

Novas facts `fact_cvcrm_comissoes` + `fact_cvcrm_repasses` (migrations `2026_07_01_000002/000003`),
models em `app/Models/CvCrm/`. No provider, comissões/repasses são **account-wide cursor-based**
(igual estoque, NÃO entram no windowing de leads/reservas - são ~5k linhas): `pullFinanceiroStreams`
(full-then-delta por cursor de credencial `comissoes`/`repasses`) + `persistComissoes`/`persistRepasses`
no fan-out. Cursores em `IntegrationCredential.metadata['cvcrm_cursors']`; marker por projeto
`settings.cvcrm_financeiro_full_at`. Só rodam em janelas recentes (mesmo guard do estoque).

- **Filtro:** repasses têm `idempreendimento` (direto); comissões NÃO → filtradas via
  `idreserva → fact_cvcrm_reservas` do projeto (mapa idreserva→idempreendimento).
- **Dedupe:** repasses vêm com várias linhas por `idrepasse` (parcelas/overlap de paginação) →
  grão = contrato (1 por idrepasse), dedup por grain_id antes do upsert. Idem comissões.
- Dashboard: `DashboardDataController::getCvCrmFinanceiro()` → bloco `data.cvcrm.financeiro`
  (`available` só com dados). Frontend: **3º toggle "Financeiro"** no `CvCrmTab` (KPIs de comissões
  e repasses + comissões por corretor + repasses por situação/banco).
- Backfill feito (full-pull 1× por conta, fan-out aos 6): comissoes=1639, repasses=1626.
  Contagens por projeto batem com vendas (ex.: Boa Vista 203 com/203 rep vs 192 vendas).

⚠️ **Semântica real da conta Lidera (validada, não chutar):** `valor_comissao` vem **0** - o valor
da comissão vive em `valor_comissao_apagar`, e só é preenchido nas **pagas** (com `data_pagamento`);
por isso os KPIs mostram Comissões/Pagas/Valor Pago/Ticket (não "a pagar/total", que seriam 0/enganosos).
`banco` vem vazio (gráfico por banco só aparece se houver dado). `contrato_quitado` = "N" em todos.

## 10. Visão Consolidada + Evolução acumulada - IMPLEMENTADO 2026-07-01

Aba Construtor de Vendas ganhou um **toggle Período / Consolidado**
([CvCrmTab.jsx](resources/js/Components/Dashboard/tabs/CvCrmTab.jsx)):
- **Período** = comportamento antigo (filtra pelo date-range do dashboard).
- **Consolidado** = estado atual do empreendimento inteiro (ignora o date-range): headline de
  estoque (Unidades/Vendidas/Disponível/Reservadas), **evolução acumulada semanal** (LineChart:
  Vendido, Contratos/reservas, Distratos + ReferenceLine no total de unidades), estoque por
  situação, funil total, e **% por mídia** (vendas e leads) sobre todo o empreendimento.
- Backend: `DashboardDataController::getCvCrmConsolidado()` (+ `getCvCrmVendasPorMidiaTotal`,
  `getCvCrmLeadsPorMidiaTotal`, `getCvCrmEvolucao`, helper `buildCvCrmFunnel`). Sem
  migration/sync/fila - só agregações sobre as facts existentes. Deploy = `npm run build`.
- ✅ **Gap de dados RESOLVIDO (2026-07-01) via FULL-HISTORY.** O gap (estoque unit-level completo ×
  reservas parciais pela janela de 365d) foi eliminado puxando leads/reservas desde o lançamento.
  `SyncPolicy::defaultsFor('cvcrm')` agora usa **`bootstrap_days = 2190`** (~6 anos; leads/reservas
  são filtrados por reference_data → janela curta perde histórico não-tocado). Os 6 projetos da
  Lidera foram re-bootstrapados full-history; resultado: **reservas Vendida == estoque Vendida
  EXATAMENTE** em todos (Boa Vista 192=192, Bella Verona 466=466, Vale Verde 308=308, Vila Suissa
  184=184, Porto Fino 128=128, Bella Roma 229=229). A curva de evolução acumulada agora fecha no
  total do empreendimento. Volume: leads 29.896 linhas/59 MB, reservas 1.932/5,7 MB (barato).
  Custo: bootstrap one-time mais longo (~146 chunks de 15d), deduplicado pelo pull-por-conta e
  pinga pela fila serial. Unidades já era snapshot completo (não dependia disto). Novos projetos
  cvcrm já entram full-history por padrão.

## 9. Pull-por-conta (orquestração por credencial) - IMPLEMENTADO 2026-07-01

**Objetivo:** a CVDW não filtra por empreendimento no servidor → cada projeto puxava a conta
inteira (N varreduras/conta). Agora a conta é puxada **1× por credencial** e as escritas são
distribuídas (fan-out) para todos os projetos vinculados. Fatos seguem keyed por `project_id`
(dashboard intacto); a redundância eliminada é a de **pull** (a de escrita seguiu per-project).

**Arquitetura (arquivos):**
- `app/Services/Integrations/Providers/CvCrmAccountPuller.php` - **choke point único** de
  leitura da CVDW. Memoiza cada janela por `(credencial, endpoint, bucket)` no cache
  `Cache::store('database')` (TTL 12h) com lock por chave (evita thundering herd). Contém
  `fetchAllPages/client/authHeaders/resolveBaseUrl` (movidos do provider). Retry em **429 E
  timeout/conexão** (a CVDW é lenta/flaky; sem isso um timeout matava o chunk inteiro).
- `CvCrmProvider::syncAccount(cred, Collection $integrations, ctx)` - pull-once + fan-out.
  `sync()` per-project delega a `syncAccount` com 1 elemento (bootstrap/manual → também
  deduplica pelo cache). Estoque **full vs delta = nível de credencial**: `needFull` = cursor
  ausente OU algum projeto sem `settings.cvcrm_estoque_full_at`. Bucket estoque discrimina
  `full:{Y-m-d}` vs `delta:{since}` (evita colisão bootstrap×incremental).
- Cursores de estoque agora em `IntegrationCredential.metadata['cvcrm_cursors']`
  (base/situacao/precos), avançados 1× por execução. Migration
  `2026_07_01_000001_move_cvcrm_cursors_to_credential.php` (move do Boa Vista + marca
  `cvcrm_estoque_full_at` em quem já tinha estoque).
- `app/Jobs/RunCvCrmAccountSyncJob.php` - job **por credencial** (advisory lock
  `crc32("cvcrm-account:{cred}")`); cria **1 SyncRun por projeto** (audit/readiness/last_sync
  preservados), chama `syncAccount`, finaliza cada run + integração.
- `app/Console/Commands/DispatchDueCvCrmAccountsCommand.php` (`integrations:dispatch-due-cvcrm`)
  - agrupa cvcrm por credencial, due = algum projeto due, despacha 1 job/credencial. O
  `DispatchDueIntegrationsCommand` genérico agora **exclui** cvcrm. Schedule em `routes/console.php`
  (daily 01:05 + watchdog horário + reconciliação).

**Store = cache `database`** (não Redis): o ambiente **não tem phpredis/predis nem
redis-server** (`Class "Redis" not found`). O recorrente é in-memory (1 job/credencial); o
cache serve p/ deduplicar a onda de bootstrap (jobs seriais).

**Validação (2026-07-01, conta Lidera, 6 projetos):**
- 6 projetos bootstrapados: Boa Vista(30) 3680/47/192, Vale Verde(24) 2841/136/340,
  Vila Suissa(42) 3572/72/296, Porto Fino(44) 2933/171/400, Bella Roma(45) 3152/291/429,
  Bella Verona(38) 2279/294/595. **Zero leaks** (cada projeto só com seu idempreendimento).
- Onda de bootstrap: **59 pulls reais na CVDW vs 216 reusos de cache** (~78% de corte).
- Ciclo recorrente (`dispatch-due-cvcrm --force`): 1 job, `projects=6`, **1 MISS por endpoint**
  (5 pulls no total, antes 30), estoque delta, 13,6s, todos os 6 com SyncRun de sucesso.

**Pendências/atenção:**
- `RunCvCrmAccountSyncJob->timeout=600`, mas o worker `sync-incremental` roda `--timeout=300`.
  No recorrente o estoque é delta (rápido, <15s), então OK. Se um projeto NOVO for adicionado
  depois, o 1º recorrente dele força `needFull` (full ~min) e pode estourar 300s no worker
  incremental → nesse caso, bootstrapar o projeto novo (fila `sync-bootstrap`, timeout 600)
  antes de deixar no recorrente. Alternativa futura: rotear o account job p/ sync-bootstrap
  quando `needFull`.
- Ainda **não commitado**. Deploy feito: build + migrate + queue:restart.

## 12. Polimento do Dashboard (aba Construtor de Vendas) - 2026-07-02

Dia inteiro de UI/gráficos na aba, quase tudo em `resources/js/Components/Dashboard/tabs/CvCrmTab.jsx`
(+ agregações no `DashboardDataController.php`). Sem migration, sem sync, sem fila. Deploy = `npm run build`.

**Estrutura de abas:** o toggle **Financeiro foi fundido no Consolidado** - agora são só **2 abas:
Período / Consolidado**. As seções de Comissões/Repasses entram no fim do Consolidado (`renderFinanceiro`
virou fragment retornado dentro de `renderConsolidado`). Período e Consolidado/Financeiro seguem all-time
exceto o Período (date-range) - o Ciclo agora é a exceção que respeita o range no Período (§13).

**Fixes de DADO (importantes):**
- 🐞 **KPI "Disponível" errado** (Bella Roma 23, real 193). O estoque era classificado só pelo
  `/unidades/situacao` (log de MUDANÇAS) → unidades nunca transicionadas ficavam com `situacao_nome=NULL`.
  Fix: `CvCrmProvider::estoqueFlags()` agora usa o `situacao_mapa_disponibilidade` do `/unidades` base
  como fallback (**1=Disponível, 3=Vendida, 4=Bloqueada, 5=Reservada** - validado 100%). Backfill de
  **638 unidades** direto do `raw` (sem API). Detalhe completo já na §2.
- 🐞 **Aba Consolidado do Bella Verona crashava** ("Erro ao carregar as infos" = ErrorBoundary).
  `getCvCrmAbsorcao` fazia `->values()->slice(-24)->all()`; o `slice()` do Laravel **preserva as chaves**
  → array com chaves deslocadas → serializa como **objeto JSON** → `.map()` do front quebrava. Só mordia
  empreendimentos com **>24 meses** de histórico (Bella Verona). Fix: `->slice(-24)->values()->all()`
  + guarda no front (`Array.isArray(...) ? ... : Object.values(...)`).

**Cores de estoque (padrão CV):** `CV_ESTOQUE_COLORS` + helper `estoqueColor()` (normaliza acento/caixa).
Cores oficiais do Construtor de Vendas em toda viz de estoque de unidades. **Disponível=cinza** (def. do
usuário), **Vendida=verde** (o usuário trocou de vermelho→verde), Reservada=dourado, Bloqueada=ardósia.
Mapa granular (Assinado, Permuta, etc) já mapeado p/ o futuro.

**Gráficos do Consolidado (estado final):** KPIs de estoque (com % em Vendida/Disponível/**Reservada**),
Evolução acumulada (linha de ref no total de unidades, com domínio do eixo Y ajustado p/ ela caber),
Funil (BezierFunnel, largura 100%), [Estoque por Situação | Origem dos Leads] (2 donuts),
**Vendas por Mídia (origem do lead)** (barras, só vendas totais, ordenado desc - era um scatter de
conversão, mas o usuário pediu só vendas), Velocidade de Vendas (barras + **média móvel 3m** + linha de
média + mês atual destacado), Mix de Pagamento, **Corretores por VGV** (nomes truncados 1 linha, rótulo
compacto, top 3 destacado), Estoque por Bloco/Faixa.

**Financeiro (fim do Consolidado):** KPIs comissões/repasses; **Comissões por Corretor = DOT PLOT**
(`ComissoesDotPlot`, lollipop ordenado + linha de média, verde acima/laranja abaixo, nomes completos w-48);
**Repasses por Etapa** = barra de composição **100% empilhada** (`RepasseEtapasBar`) agrupando os ~20 status
em **Em andamento / Concluído / Distrato** (helper `repasseEtapa()` por palavra-chave), largura 100%.

**Larguras (decisões do usuário):** Vendas por Mídia = 50% (pareado c/ Vendas por mídia origem);
Repasses por Etapa = 100%.

## 13. Ciclo de Vendas + conceito de CROSS-SELL - 2026-07-02

**Movido do Consolidado para a aba Período** e passou a **respeitar o date-range** (`getCvCrmCicloVendas`
ganhou `$startTs/$endTs` opcionais; no Período filtra por `data_venda`). **Buckets de 15 dias**
(`0-15d…76-90d, 90d+`); linha laranja de acumulado (Pareto) **removida**; ficou histograma + linha da
**mediana** (mais representativa que a média, que a cauda infla).

**CROSS-SELL (novo conceito - ver [[project-cvcrm-crosssell-rule]]):** o gráfico tinha um "buraco" (card
Vendas mostrava 54, ciclo 34). Diagnóstico no Vila Suissa (365d): das 54 vendas, 34 têm lead no mesmo
projeto, **8 têm lead noutro projeto da MESMA credencial** (cross-sell), 10 sem lead + 2 com data
incoerente. Agora o **total do gráfico = card Vendas** (regra: valores coesos). `getCvCrmCicloVendas`
categoriza cada venda e retorna `distribuicao` empilhada por `normal` / `crosssell` / `sem_origem`, +
`amostra`(=total) / `com_ciclo` / `cross_sell` / `sem_origem`.
- **normal** (azul): lead no mesmo projeto, ciclo calculado → bucket de dias.
- **crosssell** (laranja): lead noutro projeto da mesma `credential_id` → bucket de dias.
- **"Venda sem lead"** (cinza): sem lead rastreável OU `data_venda < data_cad` → bucket próprio (sem ciclo).
- **REGRA DE OURO:** cross-sell **só** casa dentro da MESMA credencial CV (`credential_id`). NUNCA cruza
  integrações/contas CV distintas (evita vazamento + colisão de `idlead` entre contas).
- Legenda customizada (`CicloLegend`) com **tooltip por item** (o quadradinho explica a si mesmo).

**Pendência:** o conceito de cross-sell / totais coesos foi implementado só no Ciclo. Se for pedido,
propagar o mesmo padrão (normal/crosssell/venda-sem-lead + total coeso) p/ outros gráficos de venda
(Corretores, Vendas por Mídia, etc). Ainda **não commitado**.

## 14. Cross CV × Exent Hub: Vendas por Anúncio + ingestão de leads - 2026-07-06

Objetivo: saber QUAL anúncio/campanha trouxe cada venda (drill Mídia→Campanha→Anúncio),
cruzando venda do CV com o lead do Hub (que carrega a atribuição nível anúncio).

**Descoberta-chave da API do Hub:** os leads individuais NÃO ficavam no banco (só agregados
`fact_crm_daily`/`fact_leads_daily`); vêm da API ao vivo (`/api/dashboard/projects/{external_id}/leads`).
Pra acessar dados de um projeto é preciso **auth → callLink → então os endpoints** (o `link` registra
o vínculo signal↔hub; sem ele dá 404 "sem vínculo ativo"). O `external_id` do exent_hub é o id do
Signal (UUID), e o `asset_id` é o id do Hub (inteiro, ex.: Vale Verde Arena = 105). A API limita o
range de datas a **90 dias** (422 range_too_large) → ingestão fatia em janelas de 85d.

**Cada lead do Hub traz:** contato (`email`, `ddd`, `phone`) + atribuição nível anúncio em
`origin.details` (`campaign_name/id`, `adset_name/id`, `ad_name/id`, `platform`) + **`output.integrations`
['CV - Leads']['Código Lead CV'] = o id EXATO do lead no CV** (= `reserva.idlead`). Match: cv_lead_code
(exato) → e-mail (fallback). CPF não existe no lado do Hub.

**Implementado:**
- `fact_exent_hub_leads` (migration `2026_07_06_000001`) + `App\Models\ExentHub\FactExentHubLead`.
- Ingestão no `ExentHubProvider::sync` → `syncLeads`/`fetchLeadsWindow` (paginado, chunk 85d, upsert
  por grain_id). Só roda com `settings.leads_module.enabled=true`. **Requer `queue:restart`** (feito).
  ⚠️ bug corrigido: extrair "Código Lead CV" com regex SEM `/u` quebrava no `ó` (2 bytes) → usar
  `/lead\s+cv/i` (ASCII-safe).
- `DashboardDataController::getCvCrmVendasPorAnuncio()` → `data.cvcrm.vendasPorAnuncio` (join venda→hub
  por cv_lead_code/email, agrega media/campanha/anuncio, expõe `cobertura`). Front: bloco
  `VendasPorAnuncioBlock` (toggle Mídia/Campanha/Anúncio) na aba Período. Bloco `block_vendas_anuncio`
  registrado (MetricDefinitions + blockRegistry, On/Off no painel).

**Validação (Vale Verde, único projeto com CV+Hub dos 6):** ingeridos **6.579 leads** (desde dez/2023).
cv_lead_code casa 2.191/2.343 com leads CV (chave certa). Drill real: "Ad03_Planta 48m²" 6 vendas
R$1,58M, etc.

**⚠️ TETO DE COBERTURA (transparente no bloco):** só **~11% das vendas** (all-time) / **6% (365d)** são
rastreadas até o anúncio, porque (1) o Hub só capta leads desde **dez/2023**, (2) só fontes pagas/Facebook
(leads de indicação/passagem/orgânico nunca entram - ~mesmo teto do ROI), (3) ciclo longo (venda de hoje
vem de lead pré-Hub). Tende a subir com o tempo. O bloco mostra "X de Y rastreadas (Z%)".

**Pendências:** só o Vale Verde tem CV+Hub hoje; nos demais o bloco não aparece (available=false).
Bootstrap de leads roda no sync recorrente (janela incremental) - p/ histórico completo de um projeto
novo, rodar `syncLeads` numa janela larga (chunked). Ainda **não commitado**.

### 14.1 Revisão (2026-07-06): bloco → coluna na tabela

O bloco "Vendas por Anúncio" foi **removido** (e seu registro em MetricDefinitions/blockRegistry).
No lugar, a tabela **Vendas & Reservas** ganhou uma **coluna "Exent Hub"**: `getCvCrmReservasRecentes`
agora enriquece cada reserva via `matchReservasToHub` (código exato → e-mail lead → e-mail reserva →
telefone lead, `last11`), e `data.cvcrm.hubComparacao` (bool) liga a coluna no front (`ReservasTable`).
A coluna mostra origem/mídia/campanha/anúncio do Hub por venda, ou "não encontrado".

**Achado importante (corrige a §14):** o teto de ~11% NÃO é falha de cruzamento nem do Hub. Cruzamos
TODAS as vendas. É o MIX de vendas: verificado por `lead.raw.origem_nome` (Vale Verde, 248 vendas):
Painel Imobiliária 113, Painel Corretor 88, Painel Gestor 17 (= 88% painel/relacionamento, o corretor
cadastra e escolhe a mídia na mão) vs Facebook 7, Google 9, Instagram 10, WebSite 3 (mídia digital real,
que casa bem no Hub). ⚠️ a busca por e-mail da API do Hub (`?search=`) está QUEBRADA (retorna 0 p/ e-mail
que existe) → NUNCA usar a busca p/ afirmar ausência; usar `origem_nome` do CV. O match por e-mail/telefone
tem valor: recupera vendas que o corretor recadastrou como "Painel" mas que vieram de anúncio (a coluna
mostra o anúncio real mesmo quando `midia`=Campanha Imobiliária/Facebook Ads).
