From e282f0ca0af8d2e413a5ee452251f7f5b2a7c853 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pedro=20Mar=C3=A7ura?=
Date: Sat, 30 May 2026 13:56:15 -0300
Subject: [PATCH 1/2] feat(security): bot anti-vazamento de segredos em camadas
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Protege a chave da API do Portal da Transparência (e qualquer segredo) de
vazar pro GitHub, em três camadas:
1. .githooks/pre-commit — bloqueia na máquina, antes do push (zero dependência)
2. .gitleaks.toml + job 'secrets' no CI — varre todo PR; regra custom da chave
do Portal + regras padrão (AWS, GitHub, etc); bloqueia o merge se achar
3. (já ativo no repo) GitHub secret scanning + push protection
CONTRIBUTING documenta `git config core.hooksPath .githooks`.
Co-Authored-By: Claude Opus 4.8
---
.githooks/pre-commit | 52 ++++++++++++++++++++++++++++++++++++++++
.github/workflows/ci.yml | 16 +++++++++++++
.gitleaks.toml | 38 +++++++++++++++++++++++++++++
CONTRIBUTING.md | 13 ++++++++++
4 files changed, 119 insertions(+)
create mode 100644 .githooks/pre-commit
create mode 100644 .gitleaks.toml
diff --git a/.githooks/pre-commit b/.githooks/pre-commit
new file mode 100644
index 0000000..790e1a0
--- /dev/null
+++ b/.githooks/pre-commit
@@ -0,0 +1,52 @@
+#!/bin/sh
+# ── Bot anti-vazamento (local) ───────────────────────────────────────────────
+# Roda automaticamente a cada `git commit`. Bloqueia o commit se detectar:
+# 1. arquivo de ambiente (.env / .env.local / .env.*) — exceto .env.example
+# 2. a chave da API do Portal da Transparência no conteúdo
+# 3. padrões genéricos de segredo (api key / token / senha com valor real)
+# Última linha de defesa ANTES de o segredo sair da sua máquina.
+#
+# Ative uma vez com: git config core.hooksPath .githooks
+# Pular num commit específico (use com cuidado): git commit --no-verify
+
+fail=0
+
+# 1) Arquivos de ambiente staged (exceto .example)
+for f in $(git diff --cached --name-only --diff-filter=ACM); do
+ case "$f" in
+ *.example) : ;;
+ *.env|*.env.*|.env|.env.*)
+ echo "🚫 '$f' é um arquivo de ambiente e NÃO deve ser commitado."
+ echo " Mantenha segredos em .env.local (já ignorado pelo git)."
+ fail=1
+ ;;
+ esac
+done
+
+# Conteúdo que está sendo adicionado (só linhas novas)
+added="$(git diff --cached -U0 --diff-filter=ACM | grep '^+' | grep -v '^+++')"
+
+# 2) Chave do Portal da Transparência (PORTAL_TRANSPARENCIA_API_KEY = hex)
+if printf '%s' "$added" | grep -iqE "portal_transparencia_api_key[[:space:]]*[:=][[:space:]]*['\"]?[0-9a-fA-F]{16,}"; then
+ echo "🚫 Detectada a CHAVE DA API DO PORTAL DA TRANSPARÊNCIA no commit."
+ echo " Essa chave é pessoal (sua conta gov.br) — nunca pode ir pro GitHub."
+ fail=1
+fi
+
+# 3) Segredo genérico com valor real (ignora placeholders e process.env)
+if printf '%s' "$added" \
+ | grep -ivE "(your[_-]?|sua[_-]?|seu[_-]?|example|placeholder|dummy|fake|sample|xxx+|<[^>]+>|chave[_-]?aqui|process\.env\.)" \
+ | grep -iqE "\b(api[_-]?key|apikey|secret|token|password|passwd|senha|client[_-]?secret)\b[[:space:]]*[:=][[:space:]]*['\"]?[A-Za-z0-9_./+=-]{12,}"; then
+ echo "⚠️ Possível segredo (api key / token / senha) com valor real no commit."
+ echo " Confira. Se for placeholder, ajuste o texto; se for real, remova."
+ fail=1
+fi
+
+if [ "$fail" -ne 0 ]; then
+ echo ""
+ echo "Commit BLOQUEADO para proteger informação sensível. 🛡️"
+ echo "Se tiver MUITA certeza de que é um falso positivo: git commit --no-verify"
+ exit 1
+fi
+
+exit 0
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ba3e13d..8f77702 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -41,3 +41,19 @@ jobs:
- name: Build
run: pnpm -r build
+
+ secrets:
+ name: secrets
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # histórico completo para o gitleaks varrer todos os commits
+
+ - name: Gitleaks (varredura de segredos)
+ uses: gitleaks/gitleaks-action@v2
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ # Grátis para repositórios públicos e pessoais (não precisa de licença).
+ GITLEAKS_CONFIG: .gitleaks.toml
diff --git a/.gitleaks.toml b/.gitleaks.toml
new file mode 100644
index 0000000..83ec59f
--- /dev/null
+++ b/.gitleaks.toml
@@ -0,0 +1,38 @@
+# Configuração do gitleaks — o "bot" anti-vazamento do DeOlho.
+# Estende as regras padrão (cobrem AWS, GitHub, Google, Stripe, JWT, etc.)
+# e adiciona regras específicas para o nosso contexto (chave do Portal da
+# Transparência e arquivos de ambiente).
+title = "DeOlho — proteção anti-segredo"
+
+[extend]
+useDefault = true
+
+# ── Regras específicas do projeto ────────────────────────────────────────────
+
+[[rules]]
+id = "portal-transparencia-api-key"
+description = "Chave da API do Portal da Transparência (conta gov.br)"
+# Pega PORTAL_TRANSPARENCIA_API_KEY = (formato da chave do portal)
+regex = '''(?i)portal_transparencia_api_key\s*[:=]\s*['"]?[0-9a-f]{16,}['"]?'''
+keywords = ["portal_transparencia_api_key"]
+
+[[rules]]
+id = "generic-env-secret"
+description = "Atribuição de segredo (api key, token, senha) com valor real"
+regex = '''(?i)\b(api[_-]?key|apikey|secret|token|password|passwd|senha|client[_-]?secret)\b\s*[:=]\s*['"]?[a-z0-9_\-./+=]{12,}['"]?'''
+keywords = ["api_key","apikey","secret","token","password","passwd","senha"]
+
+# ── Allowlist: o que NÃO é vazamento (placeholders, exemplos, docs) ───────────
+
+[allowlist]
+description = "Placeholders e arquivos de exemplo — não são segredos reais"
+paths = [
+ '''\.env\.example$''',
+ '''\.gitleaks\.toml$''',
+ '''(^|/)CONTRIBUTING\.md$''',
+ '''(^|/)\.github/''',
+]
+regexes = [
+ '''(?i)(your[_-]?|sua[_-]?|seu[_-]?|change[_-]?me|example|placeholder|dummy|fake|test|sample|xxx+|<[^>]+>|chave[_-]?aqui|cole[_-]?aqui|\.\.\.)''',
+ '''(?i)process\.env\.''',
+]
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 2cb4e9d..3811349 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -26,6 +26,19 @@ pnpm -r typecheck # tipos TypeScript
pnpm -r build # compila tudo
```
+### Proteção anti-vazamento de segredos 🛡️
+
+Ative o hook que bloqueia commits de chaves/API/`.env` **antes** de saírem da
+sua máquina (faça uma vez, ao clonar):
+
+```bash
+git config core.hooksPath .githooks
+```
+
+Nunca commite chaves de API, tokens ou arquivos `.env`. Guarde segredos em
+`.env.local` (já ignorado pelo git). O CI também roda o **gitleaks** em todo PR —
+se um segredo passar, o merge é bloqueado.
+
| Pasta | O que é |
|---|---|
| `apps/web` | Interface (Next.js) — o feed cívico que o cidadão vê |
From 389b4919eef1277798b2d4ff318957d9e6c02a68 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pedro=20Mar=C3=A7ura?=
Date: Sat, 30 May 2026 14:03:29 -0300
Subject: [PATCH 2/2] =?UTF-8?q?feat(civic):=20funda=C3=A7=C3=A3o=20c=C3=AD?=
=?UTF-8?q?vica=20=E2=80=94=20eventos,=20evid=C3=AAncias,=20flurios=20e=20?=
=?UTF-8?q?cobertura?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Modelo canônico de transparência (codex/fundacao-civica-eventos):
- db: civic_events, evidence, entity_relationships, money_flows,
source_coverage + enums; migration 0004; sources inclui camara-americana
- PNCP: transform com type guard compra/contrato; mapper no payload real
(niFornecedor, numeroControlePNCP); 578 contratos → eventos/evidências/fluxos
- TCE-SP: adapter na API oficial atual (despesas/receitas), ingestMany em lote;
124.795 despesas + 4.074 receitas → 128.865 eventos/evidências; PF comum não
vira entidade pública (só CNPJ completo)
- Diário/átomos: segmentação indefinido + extração de território; 273 eventos
- source_coverage: 12 linhas (PNCP, TCE-SP, Diário, Querido Diário,
Transparência, Câmara, CEIS/CNEP, BrasilAPI/QSA, TSE)
- Linguagem pública: sai "família no poder", entra "vínculos documentados"
Validado local com Docker/Postgres: migrate, seed, check, typecheck, lint, build.
Co-Authored-By: Codex
Co-Authored-By: Claude Opus 4.8
---
.gitignore | 4 +
.planning/HANDOFF.md | 189 +-
apps/web/src/app/explorar/page.tsx | 6 +-
apps/web/src/app/familia/[slug]/page.tsx | 3 +-
apps/web/src/app/familias/page.tsx | 20 +-
apps/web/src/app/financas/_dashboard.tsx | 432 +++
apps/web/src/app/financas/page.tsx | 557 +--
apps/web/src/app/pessoa/[slug]/page.tsx | 4 +-
apps/web/src/app/ref/[slug]/page.tsx | 2 +-
apps/web/src/components/deolho/cards.tsx | 3 +-
.../src/components/feed/tipo-explicacao.tsx | 1 +
apps/web/src/lib/atoms.ts | 17 +-
apps/web/src/lib/civic-types.ts | 1 +
apps/web/src/lib/entidades.ts | 2 +-
apps/web/src/lib/periodo.ts | 2 +-
apps/web/src/lib/pncp-transform.ts | 193 +
packages/collectors/package.json | 1 +
.../src/adapters/camara-americana.ts | 31 +-
packages/collectors/src/adapters/ceis-cnep.ts | 130 +-
.../src/adapters/diario-americana.ts | 33 +-
packages/collectors/src/adapters/pncp.ts | 83 +-
.../collectors/src/adapters/querido-diario.ts | 27 +-
packages/collectors/src/adapters/tce-sp.ts | 291 +-
.../src/adapters/transparencia-americana.ts | 27 +-
.../collectors/src/adapters/tse-doacoes.ts | 29 +-
packages/collectors/src/compile/aggregate.ts | 10 +-
packages/collectors/src/compile/document.ts | 1 +
packages/collectors/src/compile/people.ts | 2 +-
packages/collectors/src/compile/sections.ts | 11 +-
packages/collectors/src/compile/titles.ts | 8 +
packages/collectors/src/enrich/cnpj-socios.ts | 32 +-
packages/collectors/src/extract/atoms.ts | 54 +-
.../collectors/src/mappers/diario-atoms.ts | 195 +
.../collectors/src/mappers/diario-mentions.ts | 12 +-
packages/collectors/src/mappers/pncp.ts | 292 +-
packages/collectors/src/mappers/tce-sp.ts | 362 +-
packages/collectors/src/types.ts | 31 +-
packages/collectors/src/utils/civic.ts | 140 +
packages/collectors/src/utils/ingest.ts | 24 +-
packages/collectors/src/utils/territory.ts | 63 +
packages/db/README.md | 9 +-
.../db/migrations/0004_awesome_wallop.sql | 162 +
.../db/migrations/meta/0004_snapshot.json | 3412 +++++++++++++++++
packages/db/migrations/meta/_journal.json | 7 +
packages/db/src/client.ts | 2 +-
packages/db/src/schema/_enums.ts | 62 +
packages/db/src/schema/civic-events.ts | 70 +
.../db/src/schema/entity-relationships.ts | 58 +
packages/db/src/schema/evidence.ts | 50 +
packages/db/src/schema/index.ts | 5 +
packages/db/src/schema/money-flows.ts | 63 +
packages/db/src/schema/source-coverage.ts | 52 +
packages/db/src/seed.ts | 12 +
53 files changed, 6524 insertions(+), 765 deletions(-)
create mode 100644 apps/web/src/app/financas/_dashboard.tsx
create mode 100644 apps/web/src/lib/pncp-transform.ts
create mode 100644 packages/collectors/src/mappers/diario-atoms.ts
create mode 100644 packages/collectors/src/utils/civic.ts
create mode 100644 packages/collectors/src/utils/territory.ts
create mode 100644 packages/db/migrations/0004_awesome_wallop.sql
create mode 100644 packages/db/migrations/meta/0004_snapshot.json
create mode 100644 packages/db/src/schema/civic-events.ts
create mode 100644 packages/db/src/schema/entity-relationships.ts
create mode 100644 packages/db/src/schema/evidence.ts
create mode 100644 packages/db/src/schema/money-flows.ts
create mode 100644 packages/db/src/schema/source-coverage.ts
diff --git a/.gitignore b/.gitignore
index 02b0ea4..5a44a8d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,9 @@
# Agentes / config local (não versionar — ver AGENTS.md)
.claude/
+.claudian/
+
+# Pasta criada por engano na raiz (o app web real é apps/web)
+/web/
# Dependências
node_modules/
diff --git a/.planning/HANDOFF.md b/.planning/HANDOFF.md
index c11312e..6a15182 100644
--- a/.planning/HANDOFF.md
+++ b/.planning/HANDOFF.md
@@ -1,56 +1,143 @@
# Handoff
-Última atualização: 2026-05-24
+Ultima atualizacao: 2026-05-30
## Estado atual
-- Repositório canônico: `https://github.com/pmarcura/DeOlho`
-- Branch base: `main` (agora COM a fundação M0 — reconciliado)
-- Branch de trabalho atual: `claude/issue-22-scaffold-tecnico`
-- Milestone atual: `M0 - Fundação aberta`
-- Issue em andamento: [#22 - Scaffold técnico Next/Postgres/Drizzle](https://github.com/pmarcura/DeOlho/issues/22)
-- Próxima issue de UI: [#24 - Design system shadcn social-cívico](https://github.com/pmarcura/DeOlho/issues/24)
-
-## O que foi entregue nesta sessão (2026-05-24)
-
-### 1. Reconciliação do repositório
-- O branch `codex/issue-7-m0-fundacao` tinha 6 commits locais não enviados (risco de perda) e estava 12 commits à frente do `main`. Tudo foi enviado ao `origin` e o `main` recebeu fast-forward com toda a fundação M0.
-- `main` agora contém: docs OSS, design system, `apps/web` (Next 16 + shadcn + `/financas` mock), `packages/collectors`, `packages/ui`.
-
-### 2. Saneamento do monorepo (commit `chore: unificar monorepo em pnpm`)
-- Package manager unificado em **pnpm** (root tinha npm workspaces + `apps/web` tinha pnpm aninhado — inconsistente).
-- Removidos: `package-lock.json` (raiz e collectors), `apps/web/pnpm-workspace.yaml`, `apps/web/pnpm-lock.yaml`.
-- Criados: `pnpm-workspace.yaml` na raiz, lockfile único.
-- `.gitignore` expandido (`.env`, build outputs, `.obsidian`, editores).
-
-### 3. Fundação de dados — `@deolho/db` (commit `feat(db): fundação de dados`)
-Pacote novo `packages/db` (Drizzle ORM + driver `postgres`) com o núcleo transversal que precisa existir desde o dia 1 (caro de retrofitar — `research/FEATURES.md`):
-- `sources` — catálogo de fontes; `limitacoes` alimenta TRUST-05.
-- `raw_records` — evidência verbatim (JSONB) + proveniência + versionamento append-only por `content_hash` (TRUST-02/03, DATA-04).
-- `entities` + `entity_references` — IDs canônicos + ponte de resolução.
-- `contracts` + `contract_events` — unidade do MVP + linha do tempo (CONT-04).
-- Tipagem de confiança (`trust_type`) embutida (TRUST-01); busca FTS portuguesa (tsvector gerado) + fuzzy `pg_trgm` (CONT-01).
-- Migration `0000` gerada + extensões `pg_trgm`/`unaccent`/`vector`.
-- `docker-compose.yml` (pgvector) na raiz; scripts `seed`/`check`; `.env.example`.
-
-## Verificações executadas
-- `pnpm --filter @deolho/db typecheck` → OK (0 erros).
-- `drizzle-kit generate` → migration `0000_square_talon.sql` gerada corretamente.
-- `pnpm --filter web build` → OK (Next 16.2.6 Turbopack; 3 rotas estáticas).
-
-## Pendências conhecidas (bloqueios)
-- **Migrations ao vivo + seed + check**: precisam de um Postgres acessível.
- Docker **não está instalado** nesta máquina. Caminhos:
- 1. Instalar Docker Desktop → `docker compose up -d` → `pnpm --filter @deolho/db migrate && seed && check`.
- 2. OU usar Postgres gerenciado (Supabase/Neon, free tier — já trazem pg_trgm/unaccent/pgvector): pôr a connection string em `packages/db/.env` e rodar migrate/seed/check.
-- **Conectores**: o registry de MCP está vazio neste ambiente — **GitHub MCP e Supabase/Postgres MCP não estão disponíveis para adicionar**. Alternativas: `gh` CLI (não instalado) para issues/PRs; `DATABASE_URL` direto (não precisa de MCP para migrar). `Apify`, `Claude Preview` e `Claude in Chrome` estão conectados e prontos. Bright Data depende da CLI `bdata` (não instalada — skill `brightdata` guia o install).
-
-## Próximo passo recomendado
-1. Prover um Postgres (Docker ou Supabase/Neon) e rodar `migrate → seed → check` para validar a fundação ao vivo.
-2. **Fatia vertical PNCP**: adaptar `packages/collectors/src/adapters/pncp.ts` para persistir em `@deolho/db` (raw_records → contracts + entities), e criar a primeira página viva de contrato em `apps/web` consumindo o banco (substituindo o mock de Americana). Requer `transpilePackages: ['@deolho/db']` no `next.config.ts` e ler `node_modules/next/dist/docs/` antes (Next 16 tem breaking changes — ver `apps/web/AGENTS.md`).
-3. Consertar o adapter `querido-diario` (recebe HTML em vez de JSON — `totalRegistros: 0`).
-
-## Decisões a registrar (candidatas a ADR)
-- Package manager: **pnpm** como padrão do monorepo.
-- Modelo de dados: padrão **typed-core + raw JSONB** com proveniência por campo, tipagem de confiança e IDs canônicos desde o início.
-- `pg-boss` (jobs) e colunas de embedding pgvector ficam para fases posteriores; a extensão `vector` já é criada.
+- Repositorio local: `D:\DeOlho`
+- Branch atual: `codex/fundacao-civica-eventos`
+- Observacao Git: `git pull --ff-only` nao executa nesta branch porque ela ainda nao tem upstream configurado.
+- Territorio da entrega: Americana/SP primeiro.
+- Escopo trabalhado: fundacao civica com eventos, evidencias, vinculos documentados, fluxos financeiros e cobertura de fontes.
+
+## Entregue nesta sessao
+
+### Banco canonico
+
+- Criadas as tabelas canonicas:
+ - `civic_events`
+ - `evidence`
+ - `entity_relationships`
+ - `money_flows`
+ - `source_coverage`
+- Adicionados enums para categorias civicas, tipos de relacionamento, tipos de fluxo financeiro e status de cobertura.
+- Migration gerada: `packages/db/migrations/0004_awesome_wallop.sql`.
+- `sources` atualizado para incluir `camara-americana`, totalizando 10 fontes no catalogo.
+
+### PNCP
+
+- Corrigido o transform de PNCP no web com type guard seguro para diferenciar compra/contrato.
+- `/financas` agora usa PNCP real quando ha snapshot local e fallback marcado como demonstrativo/sintetico.
+- Mapper PNCP adaptado ao payload real da API:
+ - fornecedor via `niFornecedor`/`nomeRazaoSocialFornecedor`;
+ - numero via `numeroContrato`, `numeroContratoEmpenho` ou `numeroControlePNCP`;
+ - URL publica do contrato PNCP derivada de `numeroControlePNCP`.
+- Validacao local:
+ - 627 contratos retornados na coleta completa;
+ - 1.118 raw records locais acumulados por causa da primeira tentativa interrompida + recoleta completa;
+ - 578 contratos distintos por `numeroControlePNCP`;
+ - 578 eventos;
+ - 578 evidencias;
+ - 578 fluxos `contratado`;
+ - 578 vinculos documentados orgao-fornecedor.
+- Segunda execucao do mapper PNCP manteve contagens estaveis.
+
+### TCE-SP
+
+- Corrigido adapter para a API oficial atual:
+ - `/api/json/despesas/americana/{exercicio}/{mes}`
+ - `/api/json/receitas/americana/{exercicio}/{mes}`
+- Ingestao L0 em lote (`ingestMany`) para suportar volume real.
+- Mapper TCE-SP cria eventos/evidencias/fluxos para:
+ - `empenhado`
+ - `liquidado`
+ - `pago`
+ - `anulado`
+ - `receita_arrecadada`
+- Valores zero e negativos tambem viram fluxo quando publicados pela fonte.
+- Pessoa fisica comum nao vira entidade publica: documento de credor so e usado quando for CNPJ completo.
+- Validacao local:
+ - 124.795 despesas cruas;
+ - 4.074 receitas cruas;
+ - 124.794 eventos de pagamento;
+ - 4.071 eventos de receita;
+ - 128.865 evidencias TCE-SP;
+ - nenhum raw TCE-SP ficou sem evento/evidencia/fluxo esperado;
+ - segunda execucao do mapper TCE-SP retornou 0 pendencias.
+
+### Diario, atomos e evidencias
+
+- Extração de atomos agora suporta segmentacao documental com `indefinido`.
+- Eventos do Diario gerados a partir dos atomos:
+ - 273 eventos/evidencias;
+ - 24 contratos publicados;
+ - 249 atos publicados.
+- Extracao de territorio adicionada para rua, bairro, escola, UBS, praca, secretaria, orgao e equipamento publico quando aparecem no texto.
+- Mapper de mencoes Diario->entidade mantem a regra conservadora: so liga CNPJ encontrado no PDF a empresa ja conhecida.
+
+### Fontes e cobertura
+
+`source_coverage` validado com 12 linhas:
+
+- `pncp/contrato`: `fresh`
+- `pncp/compra`: `partial`
+- `tce-sp/despesa`: `fresh`
+- `tce-sp/receita`: `fresh`
+- `diario-americana/gazeta`: `fresh`
+- `querido-diario/gazeta`: `no_data`
+- `transparencia-americana/execucao-orcamentaria`: `unavailable`
+- `camara-americana/atividade-legislativa`: `no_data`
+- `cgu-transparencia/ceis`: `partial` em rodada limitada
+- `cgu-transparencia/cnep`: `partial` em rodada limitada
+- `receita-cnpj/cnpj-qsa`: `fresh`
+- `tse/doacao-eleitoral`: `pending`
+
+### Produto e linguagem publica
+
+- Removida linguagem publica de "familia no poder".
+- Substituida por "vinculos documentados" e "mencoes publicas".
+- Mantida a regra: nada de parentesco por sobrenome, nada de score de corrupcao, nada de acusacao automatica.
+- Sinais/limitacoes aparecem como limitacao de fonte, nao como fato.
+
+## Verificacoes executadas
+
+- `docker compose up -d db` -> Postgres/pgvector healthy.
+- `pnpm --filter @deolho/db migrate` -> OK.
+- `pnpm --filter @deolho/db seed` -> OK, 10 fontes.
+- `pnpm --filter @deolho/db check` -> OK.
+- `pnpm --filter @deolho/collectors collect:pncp` -> contratos coletados; licitacoes ficaram parciais por exigencia de modalidade do endpoint.
+- `pnpm --filter @deolho/collectors map:pncp` -> OK e idempotente.
+- `pnpm --filter @deolho/collectors collect:tce` -> OK.
+- `pnpm --filter @deolho/collectors map:tce` -> OK e idempotente.
+- `pnpm --filter @deolho/collectors map:diario-atoms` -> OK.
+- `pnpm --filter @deolho/collectors collect:diario` -> OK, `no_data`.
+- `pnpm --filter @deolho/collectors collect:diario-americana` -> OK.
+- `pnpm --filter @deolho/collectors collect:transparencia` -> OK, `unavailable`.
+- `pnpm --filter @deolho/collectors collect:tse` -> OK, `pending`.
+- `pnpm --filter @deolho/collectors collect:camara` -> OK, `no_data`.
+- `pnpm --filter @deolho/collectors collect:ceis -- --max-pages=1` -> OK, `partial`.
+- `pnpm --filter @deolho/collectors exec tsx src/adapters/ceis-cnep.ts cnep --max-pages=1` -> OK, `partial`.
+- `pnpm --filter @deolho/collectors enrich:socios` -> OK, 323 socios gravados como atributo de empresa.
+- `pnpm -r typecheck` -> OK.
+- `pnpm -r lint` -> OK.
+- `pnpm -r build` -> OK.
+- `git diff --check` -> OK; apenas avisos CRLF do Windows.
+
+## Pendencias conhecidas
+
+- PNCP compras/licitacoes ainda precisa iterar modalidades corretamente; cobertura esta marcada como `partial`.
+- Camara Municipal ainda precisa de mapeadores dedicados para projetos, indicacoes, requerimentos, votacoes e sessoes; cobertura inicial esta `no_data` porque o seletor generico nao encontrou itens.
+- Transparencia Americana segue `unavailable` para execucao detalhada enquanto a secao SIAFIC estiver indisponivel no portal.
+- TSE esta `pending`: a fonte e bulk ZIP e exige entrega focada para download, descompactacao e filtro por Americana/pessoas publicas.
+- CEIS/CNEP foi validado com `--max-pages=1`; rodada completa deve ir para job com rate limit.
+- Mapper de mencoes do Diario tentou 2 PDFs recentes e recebeu `fetch failed`; precisa reavaliar download/headers se essa ligacao for prioridade imediata.
+- A branch contem `.claudian/` e `web/` untracked locais; nao foram alterados nem regularizados por esta entrega.
+
+## Proxima issue recomendada
+
+Fazer a fatia de produto "linha do tempo civica":
+
+1. API/read model para `civic_events` + `evidence` + `money_flows` + `source_coverage`.
+2. Tela de evento com fonte, trecho, data de publicacao/coleta, link original e limitacoes.
+3. Filtros por rua/bairro/orgao/fornecedor/tipo de gasto.
+4. Mapeadores dedicados da Camara para indicacoes/requerimentos de zeladoria.
diff --git a/apps/web/src/app/explorar/page.tsx b/apps/web/src/app/explorar/page.tsx
index d1ad0d1..e395f9a 100644
--- a/apps/web/src/app/explorar/page.tsx
+++ b/apps/web/src/app/explorar/page.tsx
@@ -175,11 +175,11 @@ export default async function ExplorarPage() {
)}
- {/* Pessoas no poder — REAL (agentes públicos citados) */}
+ {/* Agentes públicos citados — REAL */}
- quem está no poder
+ agentes públicos citados
{topPessoas.length === 0 ? (
@@ -213,7 +213,7 @@ export default async function ExplorarPage() {
href="/familias"
className="inline-flex items-center justify-center w-full h-10 mt-3 rounded-full bg-foreground/5 text-foreground/80 text-sm font-medium hover:bg-foreground/10"
>
- sobrenomes no poder →
+ sobrenomes em atos oficiais →
>
)}
diff --git a/apps/web/src/app/familia/[slug]/page.tsx b/apps/web/src/app/familia/[slug]/page.tsx
index 7b4c66c..458074c 100644
--- a/apps/web/src/app/familia/[slug]/page.tsx
+++ b/apps/web/src/app/familia/[slug]/page.tsx
@@ -62,7 +62,8 @@ export default async function FamiliaPage({ params }: PageProps) {
Sobrenome igual não comprova parentesco . Esta é uma visão de
- coocorrência em atos oficiais — pra investigar, nunca uma conclusão.
+ coocorrência em atos oficiais — para leitura contextual, nunca uma conclusão.
+ Vínculos familiares só aparecem quando houver evidência documental própria.
diff --git a/apps/web/src/app/familias/page.tsx b/apps/web/src/app/familias/page.tsx
index 96acb5e..03a1f0d 100644
--- a/apps/web/src/app/familias/page.tsx
+++ b/apps/web/src/app/familias/page.tsx
@@ -1,13 +1,13 @@
/**
- * /familias — "quantas famílias estão no poder e há quanto tempo".
+ * /familias — sobrenomes repetidos em atos oficiais.
*
* Tratado com HONESTIDADE e ÉTICA (project-deolho.md):
* - só agentes públicos em função pública (nunca cidadão comum);
* - sobrenome igual NÃO prova parentesco — é coocorrência factual, com fonte;
* - sem juízo, sem "score", sem acusação.
*
- * Duas visões reais: quem mais aparece nos atos (poder de fato) e quais
- * sobrenomes se repetem (sinal a investigar, nunca conclusão).
+ * Duas visões reais: agentes públicos citados em atos e sobrenomes repetidos
+ * como pista fraca, nunca como vínculo familiar documentado.
*/
import type { Metadata } from "next";
import Link from "next/link";
@@ -18,7 +18,7 @@ import { periodoLabel, tempoLabel } from "@/lib/periodo";
export const revalidate = 600;
export const metadata: Metadata = {
- title: "Sobrenomes no poder — DeOlho",
+ title: "Sobrenomes em atos oficiais — DeOlho",
description: "Quem ocupa funções públicas em Americana e quais sobrenomes se repetem nos atos oficiais — coocorrência factual, com fonte.",
};
@@ -38,9 +38,9 @@ export default async function FamiliasPage() {
@@ -50,7 +50,8 @@ export default async function FamiliasPage() {
Sobrenome igual não prova parentesco. Isto é coocorrência factual em
atos oficiais — um ponto de partida pra investigar, nunca uma acusação. O DeOlho só
- registra pessoas no exercício de função pública, sempre com a fonte.
+ registra pessoas no exercício de função pública, sempre com a fonte. Vínculo familiar só
+ será exibido quando houver documento público que o comprove.
@@ -132,9 +133,8 @@ export default async function FamiliasPage() {
)}
- Hoje os repetidos são sobrenomes comuns (Silva, Oliveira…), quase sempre sem relação.
- Conforme entram anos de histórico, sobrenomes raros que se repetem em cargos viram
- sinal de atenção — sempre pra investigar, com a fonte ao lado.
+ Hoje isto é apenas uma visão de sobrenomes repetidos. Vínculos familiares documentados
+ entrarão em uma camada separada, com evidência própria e sem inferência por sobrenome.
diff --git a/apps/web/src/app/financas/_dashboard.tsx b/apps/web/src/app/financas/_dashboard.tsx
new file mode 100644
index 0000000..c8da45a
--- /dev/null
+++ b/apps/web/src/app/financas/_dashboard.tsx
@@ -0,0 +1,432 @@
+"use client";
+
+import {
+ BarChart,
+ Bar,
+ XAxis,
+ YAxis,
+ Tooltip,
+ ResponsiveContainer,
+ PieChart,
+ Pie,
+ Cell,
+ Legend,
+} from "recharts";
+import {
+ Building2,
+ FileText,
+ TrendingUp,
+ Wallet,
+ ArrowLeft,
+ AlertTriangle,
+ MapPin,
+ CheckCircle2,
+} from "lucide-react";
+import Link from "next/link";
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Separator } from "@/components/ui/separator";
+import { cn } from "@/lib/utils";
+import {
+ MUNICIPIO,
+ receitaOrigem,
+ despesaSecretaria,
+ formatBRL,
+ formatBRLFull,
+} from "@/lib/americana-data";
+import type { ContratoMes, EmpresaTop, UltimoContrato } from "@/lib/pncp-transform";
+
+export interface DashboardData {
+ fonte: "pncp" | "sintetico";
+ coletadoEm: string | null;
+ totalContratos: number;
+ totalValor: number;
+ contratosPorMes: ContratoMes[];
+ topEmpresas: EmpresaTop[];
+ ultimosContratos: UltimoContrato[];
+}
+
+const CHART_ORANGE = "#e05a00";
+
+// ── Secretaria color dots ──────────────────────────────────────────────────────
+const SECRETARIA_DOT: Record = {
+ "Saúde": "bg-emerald-500",
+ "Educação": "bg-blue-500",
+ "Infraestrutura": "bg-amber-500",
+ "Administração": "bg-slate-400",
+ "Assistência Social": "bg-violet-500",
+ "Transportes": "bg-orange-500",
+ "Meio Ambiente": "bg-teal-500",
+ "Segurança": "bg-red-500",
+};
+function secretariaDot(s: string) {
+ return SECRETARIA_DOT[s] ?? "bg-muted-foreground/40";
+}
+
+// ── Company avatar ─────────────────────────────────────────────────────────────
+function getInitials(name: string) {
+ return name.split(/\s+/).filter((w) => w.length > 2).slice(0, 2).map((w) => w[0]).join("").toUpperCase();
+}
+const AVATAR_PALETTES = [
+ "bg-blue-50 text-blue-700 ring-blue-200/80",
+ "bg-emerald-50 text-emerald-700 ring-emerald-200/80",
+ "bg-violet-50 text-violet-700 ring-violet-200/80",
+ "bg-amber-50 text-amber-700 ring-amber-200/80",
+ "bg-rose-50 text-rose-700 ring-rose-200/80",
+ "bg-teal-50 text-teal-700 ring-teal-200/80",
+ "bg-indigo-50 text-indigo-700 ring-indigo-200/80",
+ "bg-orange-50 text-orange-700 ring-orange-200/80",
+];
+function avatarPalette(seed: string) {
+ let h = 0;
+ for (let i = 0; i < seed.length; i++) h = seed.charCodeAt(i) + ((h << 5) - h);
+ return AVATAR_PALETTES[Math.abs(h) % AVATAR_PALETTES.length];
+}
+
+// ── Source badge ───────────────────────────────────────────────────────────────
+function FonteBadge({ source }: { source: "pncp" | "portal" }) {
+ const cfg =
+ source === "pncp"
+ ? { label: "PNCP", cls: "bg-blue-50 text-blue-600 ring-blue-200/80" }
+ : { label: "Portal Transparência", cls: "bg-emerald-50 text-emerald-600 ring-emerald-200/80" };
+ return (
+
+
+ {cfg.label}
+
+ );
+}
+
+// ── KPI Card ───────────────────────────────────────────────────────────────────
+function KpiCard({ label, value, sub, icon: Icon, accent }: {
+ label: string; value: string; sub?: string;
+ icon: React.ComponentType<{ className?: string }>; accent?: boolean;
+}) {
+ return (
+
+
+
+
+ {value}
+
+ {sub && {sub}
}
+
+
+ );
+}
+
+// ── Custom Tooltip ─────────────────────────────────────────────────────────────
+function CustomTooltip({ active, payload, label }: {
+ active?: boolean; payload?: { value: number; name: string }[]; label?: string;
+}) {
+ if (!active || !payload?.length) return null;
+ return (
+
+
{label}
+ {payload.map((p, i) => (
+
+ {p.name === "valor" ? formatBRL(p.value) : `${p.value} contratos`}
+
+ ))}
+
+ );
+}
+
+// ── Donut center label ──────────────────────────────────────────────────────────
+function DonutLabel({ cx, cy, total }: { cx: number; cy: number; total: number }) {
+ return (
+ <>
+
+ {formatBRL(total)}
+
+
+ orçamento 2025
+
+ >
+ );
+}
+
+// ── Page range label ───────────────────────────────────────────────────────────
+function periodoLabel(meses: ContratoMes[]): string {
+ if (!meses.length) return "";
+ return `${meses[0].mes} a ${meses[meses.length - 1].mes}`;
+}
+
+// ── Dashboard ──────────────────────────────────────────────────────────────────
+export default function Dashboard({ data }: { data: DashboardData }) {
+ const { fonte, coletadoEm, totalContratos, totalValor, contratosPorMes, topEmpresas, ultimosContratos } = data;
+ const ticketMedio = totalContratos > 0 ? totalValor / totalContratos : 0;
+ const isPncp = fonte === "pncp";
+
+ return (
+
+
+ {/* ── Topbar ─────────────────────────────────────────────────────────────── */}
+
+
+
+
+
+
+
+
+
+ Americana — SP
+
+ {MUNICIPIO.ibge}
+
+
+
+ {isPncp ? (
+
+
+ Dados PNCP reais
+
+ ) : (
+
+
+ Dados sintéticos
+
+ )}
+
+
+
+ {/* ── Contexto do município ───────────────────────────────────────────────── */}
+
+
+
+
+
+
+
+
+
Americana
+
+ São Paulo, Brasil · {MUNICIPIO.populacao.toLocaleString("pt-BR")} hab.
+
+
+
+ IBGE {MUNICIPIO.ibge}
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* ── KPIs ───────────────────────────────────────────────────────────────── */}
+
+
+
+
+
+
+
+ {/* ── Contratos por mês ──────────────────────────────────────────────────── */}
+
+
+ Contratos por mês
+
+
+ Valor total contratado — {periodoLabel(contratosPorMes) || "período monitorado"}
+
+
+
+
+
+
+
+
+ formatBRL(v)} tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 10 }} axisLine={false} tickLine={false} width={68} />
+ } cursor={{ fill: "rgba(0,0,0,0.03)" }} />
+
+
+
+
+
+
+ {/* ── Donut charts ────────────────────────────────────────────────────────── */}
+
+
+
+ Despesa por secretaria
+ Distribuição demonstrativa do orçamento 2025
+
+
+
+
+
+ {despesaSecretaria.map((entry, i) => ( | ))}
+
+
+ ({value} )} />
+ [formatBRLFull(Number(value)), ""]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: 10, fontSize: 12 }} />
+
+
+
+
+
+
+
+ Receita por origem
+ Principais fontes demonstrativas de arrecadação 2025
+
+
+
+
+
+ {receitaOrigem.map((entry, i) => ( | ))}
+ s + r.valor, 0)} />
+
+ ({value} )} />
+ [formatBRLFull(Number(value)), ""]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: 10, fontSize: 12 }} />
+
+
+
+
+
+
+ {/* ── Maiores contratadas ─────────────────────────────────────────────────── */}
+
+
+ Maiores contratadas
+
+ Por valor total de contratos no período
+
+
+
+
+
+ {topEmpresas.map((e, i) => {
+ const pct = (e.valor / topEmpresas[0].valor) * 100;
+ const palette = avatarPalette(e.cnpj);
+ return (
+
+
+
+ {getInitials(e.empresa)}
+
+
+
+ {e.empresa}
+ {formatBRL(e.valor)}
+
+
+ {e.cnpj}
+ {e.contratos} contratos
+
+
+
+
+
+ );
+ })}
+
+
+
+
+ {/* ── Últimos contratos ───────────────────────────────────────────────────── */}
+
+
+ Últimos contratos
+
+ Publicados recentemente no PNCP
+
+
+
+
+
+ {ultimosContratos.map((c, i) => (
+
+
+
+
+
+
{c.objeto}
+
{c.contratada}
+
+ {c.numero}
+ {c.secretaria}
+ {c.modalidade}
+
+
+
+
{formatBRL(c.valor)}
+
+ {new Date(c.data).toLocaleDateString("pt-BR")}
+
+
+
+
+
+ ))}
+
+
+
+
+ {/* ── Rodapé de fonte ─────────────────────────────────────────────────────── */}
+
+ {isPncp ? (
+ <>
+
+
+ Dados reais PNCP — contratos
+ de Americana/SP (IBGE{" "}
+ 3501608).
+ {coletadoEm && (
+ <> Coletado em {new Date(coletadoEm).toLocaleDateString("pt-BR")}.>
+ )}
+ {" "}Receita e despesa por secretaria ainda são estimativas enquanto o TCE-SP é integrado.
+
+ >
+ ) : (
+ <>
+
+
+ Dados sintéticos — estrutura
+ baseada no PNCP ({" "}
+ codigoMunicipio=3501608
+ ) e Portal da Transparência. Valores reais entram após o coletor de Americana entrar em operação.
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/src/app/financas/page.tsx b/apps/web/src/app/financas/page.tsx
index 8b4fa22..57b67b9 100644
--- a/apps/web/src/app/financas/page.tsx
+++ b/apps/web/src/app/financas/page.tsx
@@ -1,553 +1,26 @@
-"use client";
-
-import {
- BarChart,
- Bar,
- XAxis,
- YAxis,
- Tooltip,
- ResponsiveContainer,
- PieChart,
- Pie,
- Cell,
- Legend,
-} from "recharts";
+import Dashboard, { type DashboardData } from "./_dashboard";
import {
- Building2,
- FileText,
- TrendingUp,
- Wallet,
- ArrowLeft,
- AlertTriangle,
- MapPin,
-} from "lucide-react";
-import Link from "next/link";
-import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
-import { Badge } from "@/components/ui/badge";
-import { Separator } from "@/components/ui/separator";
-import { cn } from "@/lib/utils";
-import {
- MUNICIPIO,
- receitaOrigem,
- despesaSecretaria,
contratosPorMes,
topEmpresas,
ultimosContratos,
- formatBRL,
- formatBRLFull,
} from "@/lib/americana-data";
+import { loadPncpDados } from "@/lib/pncp-transform";
-const CHART_ORANGE = "#e05a00";
-const CHART_ORANGE_SOFT = "#f07830";
-
-// ── Secretaria color dots ─────────────────────────────────────────────────────
-const SECRETARIA_DOT: Record = {
- "Saúde": "bg-emerald-500",
- "Educação": "bg-blue-500",
- "Infraestrutura": "bg-amber-500",
- "Administração": "bg-slate-400",
- "Assistência Social": "bg-violet-500",
- "Transportes": "bg-orange-500",
- "Meio Ambiente": "bg-teal-500",
- "Segurança": "bg-red-500",
-};
-
-function secretariaDot(s: string) {
- return SECRETARIA_DOT[s] ?? "bg-muted-foreground/40";
-}
-
-// ── Company avatar helpers ─────────────────────────────────────────────────────
-function getInitials(name: string) {
- return name
- .split(/\s+/)
- .filter((w) => w.length > 2)
- .slice(0, 2)
- .map((w) => w[0])
- .join("")
- .toUpperCase();
-}
-
-const AVATAR_PALETTES = [
- "bg-blue-50 text-blue-700 ring-blue-200/80",
- "bg-emerald-50 text-emerald-700 ring-emerald-200/80",
- "bg-violet-50 text-violet-700 ring-violet-200/80",
- "bg-amber-50 text-amber-700 ring-amber-200/80",
- "bg-rose-50 text-rose-700 ring-rose-200/80",
- "bg-teal-50 text-teal-700 ring-teal-200/80",
- "bg-indigo-50 text-indigo-700 ring-indigo-200/80",
- "bg-orange-50 text-orange-700 ring-orange-200/80",
-];
-
-function avatarPalette(seed: string) {
- let h = 0;
- for (let i = 0; i < seed.length; i++) h = seed.charCodeAt(i) + ((h << 5) - h);
- return AVATAR_PALETTES[Math.abs(h) % AVATAR_PALETTES.length];
-}
-
-// ── Source badge ──────────────────────────────────────────────────────────────
-function FonteBadge({ source }: { source: "pncp" | "portal" }) {
- const cfg =
- source === "pncp"
- ? { label: "PNCP", cls: "bg-blue-50 text-blue-600 ring-blue-200/80" }
- : { label: "Portal Transparência", cls: "bg-emerald-50 text-emerald-600 ring-emerald-200/80" };
- return (
-
-
- {cfg.label}
-
- );
-}
-
-// ── KPI Card ──────────────────────────────────────────────────────────────────
-function KpiCard({
- label,
- value,
- sub,
- icon: Icon,
- accent,
-}: {
- label: string;
- value: string;
- sub?: string;
- icon: React.ComponentType<{ className?: string }>;
- accent?: boolean;
-}) {
- return (
-
-
-
-
- {value}
-
- {sub && {sub}
}
-
-
- );
-}
+function loadDashboardData(): DashboardData {
+ const pncp = loadPncpDados();
+ if (pncp) return pncp;
-// ── Custom Tooltip ─────────────────────────────────────────────────────────────
-function CustomTooltip({
- active,
- payload,
- label,
-}: {
- active?: boolean;
- payload?: { value: number; name: string }[];
- label?: string;
-}) {
- if (!active || !payload?.length) return null;
- return (
-
-
{label}
- {payload.map((p, i) => (
-
- {p.name === "valor" ? formatBRL(p.value) : `${p.value} contratos`}
-
- ))}
-
- );
+ return {
+ fonte: "sintetico",
+ coletadoEm: null,
+ totalContratos: contratosPorMes.reduce((s, m) => s + m.contratos, 0),
+ totalValor: contratosPorMes.reduce((s, m) => s + m.valor, 0),
+ contratosPorMes,
+ topEmpresas,
+ ultimosContratos,
+ };
}
-// ── Donut center label ─────────────────────────────────────────────────────────
-function DonutLabel({ cx, cy, total }: { cx: number; cy: number; total: number }) {
- return (
- <>
-
- {formatBRL(total)}
-
-
- orçamento 2025
-
- >
- );
-}
-
-// ── Page ──────────────────────────────────────────────────────────────────────
export default function FinancasPage() {
- const totalContratos = contratosPorMes.reduce((s, m) => s + m.contratos, 0);
- const totalValorContratos = contratosPorMes.reduce((s, m) => s + m.valor, 0);
- const ticketMedio = totalValorContratos / totalContratos;
-
- return (
-
-
- {/* ── Topbar ───────────────────────────────────────────────────────────── */}
-
-
-
-
-
-
-
-
-
- Americana — SP
-
- {MUNICIPIO.ibge}
-
-
-
-
-
- Dados sintéticos
-
-
-
-
- {/* ── Contexto do município ────────────────────────────────────────────── */}
-
-
-
-
- {/* Slot de imagem — exibe foto da Wikipedia quando disponível */}
-
-
-
-
-
Americana
-
- São Paulo, Brasil · {MUNICIPIO.populacao.toLocaleString("pt-BR")} hab.
-
-
-
- IBGE {MUNICIPIO.ibge}
-
-
-
-
-
-
-
-
-
-
-
-
- {/* ── KPIs ─────────────────────────────────────────────────────────────── */}
-
-
-
-
-
-
-
- {/* ── Contratos por mês ────────────────────────────────────────────────── */}
-
-
- Contratos por mês
-
- Valor total contratado — out/2024 a mai/2026
-
-
-
-
-
-
-
- formatBRL(v)}
- tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 10 }}
- axisLine={false}
- tickLine={false}
- width={68}
- />
- } cursor={{ fill: "rgba(0,0,0,0.03)" }} />
-
-
-
-
-
-
- {/* ── Donut charts ─────────────────────────────────────────────────────── */}
-
-
-
-
- Despesa por secretaria
- Distribuição do orçamento 2025
-
-
-
-
-
- {despesaSecretaria.map((entry, i) => (
- |
- ))}
-
-
- (
- {value}
- )}
- />
- [formatBRLFull(Number(value)), ""]}
- contentStyle={{
- background: "hsl(var(--card))",
- border: "1px solid hsl(var(--border))",
- borderRadius: 10,
- fontSize: 12,
- }}
- />
-
-
-
-
-
-
-
- Receita por origem
- Principais fontes de arrecadação 2025
-
-
-
-
-
- {receitaOrigem.map((entry, i) => (
- |
- ))}
- s + r.valor, 0)}
- />
-
- (
- {value}
- )}
- />
- [formatBRLFull(Number(value)), ""]}
- contentStyle={{
- background: "hsl(var(--card))",
- border: "1px solid hsl(var(--border))",
- borderRadius: 10,
- fontSize: 12,
- }}
- />
-
-
-
-
-
-
- {/* ── Maiores contratadas ──────────────────────────────────────────────── */}
-
-
- Maiores contratadas
-
- Por valor total de contratos no período
-
-
-
-
-
- {topEmpresas.map((e, i) => {
- const pct = (e.valor / topEmpresas[0].valor) * 100;
- const palette = avatarPalette(e.cnpj);
- return (
-
-
- {/* Avatar com iniciais */}
-
- {getInitials(e.empresa)}
-
- {/* Info */}
-
-
- {e.empresa}
-
- {formatBRL(e.valor)}
-
-
-
- {e.cnpj}
-
- {e.contratos} contratos
-
-
-
-
- {/* Barra de progresso */}
-
-
- );
- })}
-
-
-
-
- {/* ── Últimos contratos ────────────────────────────────────────────────── */}
-
-
- Últimos contratos
-
- Publicados recentemente no PNCP
-
-
-
-
-
- {ultimosContratos.map((c, i) => (
-
- {/* Ponto colorido por secretaria */}
-
- {/* Conteúdo */}
-
-
-
-
{c.objeto}
-
{c.contratada}
-
-
- {c.numero}
-
-
- {c.secretaria}
-
-
- {c.modalidade}
-
-
-
-
-
{formatBRL(c.valor)}
-
- {new Date(c.data).toLocaleDateString("pt-BR")}
-
-
-
-
-
- ))}
-
-
-
-
- {/* ── Rodapé de fonte ──────────────────────────────────────────────────── */}
-
-
-
- Dados sintéticos — estrutura
- baseada no PNCP (
-
- codigoMunicipio=3501608
-
- ) e Portal da Transparência. Valores reais entram após o scraper de Americana entrar em operação.
-
-
-
-
- );
+ return ;
}
diff --git a/apps/web/src/app/pessoa/[slug]/page.tsx b/apps/web/src/app/pessoa/[slug]/page.tsx
index c798ee4..dec0f2b 100644
--- a/apps/web/src/app/pessoa/[slug]/page.tsx
+++ b/apps/web/src/app/pessoa/[slug]/page.tsx
@@ -88,7 +88,7 @@ export default async function PessoaPage({ params }: PageProps) {
} valor={pessoa.papeis.map((p) => PAPEL_LABEL[p]).slice(0, 1).join("")} label="papel" />
- {/* Família */}
+ {/* Sobrenome */}
sobrenome
{pessoa.sobrenome}
- ver quem mais tem esse sobrenome →
+ ver outras menções do sobrenome →
diff --git a/apps/web/src/app/ref/[slug]/page.tsx b/apps/web/src/app/ref/[slug]/page.tsx
index 1860ebd..b944da1 100644
--- a/apps/web/src/app/ref/[slug]/page.tsx
+++ b/apps/web/src/app/ref/[slug]/page.tsx
@@ -154,7 +154,7 @@ export default async function RefPage({ params }: PageProps) {
descricao="Talvez essa referência ainda não tenha sido extraída, ou seja externa ao diário de Americana. O coletor segue raspando."
acao={
- voltar pro feed
+ voltar ao radar
}
/>
diff --git a/apps/web/src/components/deolho/cards.tsx b/apps/web/src/components/deolho/cards.tsx
index 5f1fe21..4a60461 100644
--- a/apps/web/src/components/deolho/cards.tsx
+++ b/apps/web/src/components/deolho/cards.tsx
@@ -6,7 +6,7 @@
* Nunca: linguagem acusatória, ranking, like ou comentário livre.
*/
import Link from "next/link";
-import { ArrowUpRight, BookmarkPlus, FileText, ShieldAlert } from "lucide-react";
+import { ArrowUpRight, FileText, ShieldAlert } from "lucide-react";
import { cn } from "@/lib/utils";
import type {
ContratoUI,
@@ -15,7 +15,6 @@ import type {
SinalAtencao,
} from "@/lib/civic-types";
import { Card, CardContent } from "@/components/ui/card";
-import { Button } from "@/components/ui/button";
import { TipoInformacaoBadge, ConfiancaBadge, FonteBadge } from "./badges";
import { EntidadeAvatar } from "./entidade-avatar";
import { SinalAvisoObrigatorio, BlocoLimitacaoDado } from "./blocos";
diff --git a/apps/web/src/components/feed/tipo-explicacao.tsx b/apps/web/src/components/feed/tipo-explicacao.tsx
index 1b3ee5b..fba3e86 100644
--- a/apps/web/src/components/feed/tipo-explicacao.tsx
+++ b/apps/web/src/components/feed/tipo-explicacao.tsx
@@ -25,6 +25,7 @@ const EXPLICACOES: Record = {
concorrencia: "Licitação maior, pra obras e contratos de valor alto, aberta a qualquer empresa.",
convenio: "Acordo entre a prefeitura e outra entidade (ONG, governo, etc.) pra fazer alguma coisa juntos.",
ata_registro: "Catálogo de preços fixados. A prefeitura registra valores combinados pra comprar quando precisar — sem nova licitação a cada vez.",
+ indefinido: "Publicação oficial identificada no Diário, mas ainda sem classificação automática segura. Ela aparece com limitação para evitar conclusão sem evidência suficiente.",
};
export function TipoExplicacao({
diff --git a/apps/web/src/lib/atoms.ts b/apps/web/src/lib/atoms.ts
index 2a78caa..629ce36 100644
--- a/apps/web/src/lib/atoms.ts
+++ b/apps/web/src/lib/atoms.ts
@@ -25,7 +25,8 @@ export type TipoAto =
| "convite"
| "concorrencia"
| "convenio"
- | "ata_registro";
+ | "ata_registro"
+ | "indefinido";
// Campos estruturados extraídos pelo Civic Content Compiler (collectors/compile/).
export interface CamposContrato {
@@ -88,6 +89,12 @@ export interface OrgaoCitado {
sigla: string | null;
}
+export interface TerritorioMencionado {
+ tipo: "logradouro" | "bairro" | "equipamento";
+ nome: string;
+ contexto: string;
+}
+
export interface Atom {
id: string;
edicaoSlug: string;
@@ -114,6 +121,8 @@ export interface Atom {
pessoas?: PessoaCitada[];
/** Órgãos citados (Secretarias, DAE, Guarda Municipal…). */
orgaos?: OrgaoCitado[];
+ /** Ruas, bairros ou equipamentos públicos citados literalmente no trecho. */
+ territorios?: TerritorioMencionado[];
}
interface AtomsFile {
@@ -164,14 +173,14 @@ async function load(): Promise {
return _cache;
}
-/** Categoria visual do feed — agrupamento de tipos por "tema". */
+/** Categoria visual do radar — agrupamento de tipos por tema cívico. */
export type CategoriaFeed = "tudo" | "dinheiro" | "leis" | "atos" | "convenios";
const POR_CATEGORIA: Record = {
tudo: [],
dinheiro: ["contrato", "aditivo", "pregao", "convite", "concorrencia", "ata_registro", "edital"],
leis: ["lei"],
- atos: ["decreto", "portaria", "resolucao"],
+ atos: ["decreto", "portaria", "resolucao", "indefinido"],
convenios: ["convenio"],
};
@@ -285,6 +294,7 @@ export const TIPO_META: Record<
concorrencia: { label: "Concorrência", emoji: "🏷️", cor: "from-rose-100 to-amber-100", cat: "dinheiro" },
convenio: { label: "Convênio", emoji: "🤝", cor: "from-emerald-100 to-sky-100", cat: "convenios" },
ata_registro: { label: "Ata de Preços", emoji: "🪙", cor: "from-emerald-100 to-amber-100", cat: "dinheiro" },
+ indefinido: { label: "Publicação", emoji: "📌", cor: "from-slate-100 to-sky-100", cat: "atos" },
};
export function tipoLabel(t: TipoAto): string {
@@ -307,6 +317,7 @@ const PESO_TIPO: Record = {
decreto: 3,
resolucao: 2,
portaria: 1,
+ indefinido: 1,
};
function parsearValor(s: string | null | undefined): number {
diff --git a/apps/web/src/lib/civic-types.ts b/apps/web/src/lib/civic-types.ts
index 92f448d..96fb6de 100644
--- a/apps/web/src/lib/civic-types.ts
+++ b/apps/web/src/lib/civic-types.ts
@@ -57,6 +57,7 @@ export type TipoEntidade =
// ── Tipos de evento público (unidades do radar) ──────────────────────────────
export type TipoEventoPublico =
+ | "licitacao_publicada"
| "contrato_publicado"
| "contrato_atualizado"
| "pagamento_registrado"
diff --git a/apps/web/src/lib/entidades.ts b/apps/web/src/lib/entidades.ts
index a9b9bd2..8f0427d 100644
--- a/apps/web/src/lib/entidades.ts
+++ b/apps/web/src/lib/entidades.ts
@@ -1,7 +1,7 @@
/**
* Loader do índice de ENTIDADES (entidades.json) gerado pelo compiler.
*
- * Pessoas (agentes públicos), famílias (coocorrência de sobrenome) e órgãos —
+ * Pessoas (agentes públicos), sobrenomes repetidos (coocorrência) e órgãos —
* cada um com os atomIds onde foi citado, pra montar "ver tudo que conecta com
* isso". Defensivo: se o arquivo não existir (checkout sem rodar o coletor),
* devolve índice vazio em vez de quebrar a página.
diff --git a/apps/web/src/lib/periodo.ts b/apps/web/src/lib/periodo.ts
index 5c59b70..c92814f 100644
--- a/apps/web/src/lib/periodo.ts
+++ b/apps/web/src/lib/periodo.ts
@@ -15,7 +15,7 @@ export function periodoLabel(
return a ?? b ?? null;
}
-/** "há X anos" a partir do menor ano observado (proxy de tempo no poder). */
+/** "há X anos" a partir do menor ano observado em atos oficiais. */
export function tempoLabel(anos: number[]): string | null {
if (!anos || anos.length === 0) return null;
const min = Math.min(...anos);
diff --git a/apps/web/src/lib/pncp-transform.ts b/apps/web/src/lib/pncp-transform.ts
new file mode 100644
index 0000000..6e04ddd
--- /dev/null
+++ b/apps/web/src/lib/pncp-transform.ts
@@ -0,0 +1,193 @@
+import "server-only";
+import fs from "node:fs";
+import path from "node:path";
+
+// Tipos inline para não importar do pacote collectors no Next.js
+interface PncpContrato {
+ sequencialContrato: number;
+ anoContrato: number;
+ numeroContrato: string;
+ objetoContrato: string;
+ valorInicial: number;
+ valorGlobal: number;
+ dataVigenciaInicio: string;
+ dataVigenciaFim: string;
+ dataAssinatura: string;
+ situacaoContrato: { codigo: number; nome: string };
+ fornecedor: { cnpjCpf: string; nomeRazaoSocial: string; tipo: string };
+ receita: boolean;
+}
+
+interface ResultadoColeta {
+ fonte: string;
+ coletadoEm: string;
+ municipio: string;
+ ibge: string;
+ totalRegistros: number;
+ dados: T[];
+ erros: string[];
+}
+
+// O Next.js corre com cwd=apps/web; collectors ficam dois níveis acima
+const DATA_ROOT = path.join(process.cwd(), "../../packages/collectors/data");
+
+function readLatest(fonte: string): ResultadoColeta | null {
+ try {
+ const file = path.join(DATA_ROOT, fonte, "latest.json");
+ const raw = fs.readFileSync(file, "utf-8");
+ return JSON.parse(raw) as ResultadoColeta;
+ } catch {
+ return null;
+ }
+}
+
+// ── Tipos que o dashboard consome ─────────────────────────────────────────────
+
+export type ContratoMes = { mes: string; contratos: number; valor: number };
+export type EmpresaTop = { empresa: string; cnpj: string; valor: number; contratos: number };
+export type UltimoContrato = {
+ numero: string;
+ objeto: string;
+ contratada: string;
+ secretaria: string;
+ valor: number;
+ data: string;
+ modalidade: string;
+};
+
+export interface DadosPncp {
+ fonte: "pncp";
+ coletadoEm: string;
+ totalContratos: number;
+ totalValor: number;
+ contratosPorMes: ContratoMes[];
+ topEmpresas: EmpresaTop[];
+ ultimosContratos: UltimoContrato[];
+}
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+
+const MESES = ["Jan","Fev","Mar","Abr","Mai","Jun","Jul","Ago","Set","Out","Nov","Dez"];
+
+function mesLabel(dateStr: string): string {
+ const d = new Date(dateStr);
+ if (isNaN(d.getTime())) return "?";
+ return `${MESES[d.getMonth()]}/${String(d.getFullYear()).slice(2)}`;
+}
+
+function parseLabel(l: string): number {
+ const [m, y] = l.split("/");
+ return Number(y) * 12 + MESES.indexOf(m);
+}
+
+function cnpjFormat(raw: string): string {
+ const d = raw.replace(/\D/g, "");
+ if (d.length === 14) return `${d.slice(0,2)}.${d.slice(2,5)}.${d.slice(5,8)}/${d.slice(8,12)}-${d.slice(12)}`;
+ return raw;
+}
+
+function normalizarColetadoEm(s: string): string {
+ return s.replace(/-contratos$/, "");
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
+
+function isPncpContrato(value: unknown): value is PncpContrato {
+ return isRecord(value) && typeof value.objetoContrato === "string";
+}
+
+// ── Transform ─────────────────────────────────────────────────────────────────
+
+export function loadPncpDados(): DadosPncp | null {
+ // Arquivo com sufixo "-contratos" no coletadoEm
+ // O save.ts usa `coletadoEm + "-contratos"` como nome — não gera latest.json separado
+ // Então o latest.json é sempre o de licitações; para contratos buscamos o timestamped mais novo
+ const resultado = readLatest("pncp");
+ if (!resultado) return null;
+
+ // Se latest.json contém licitações (PncpCompra), os campos são diferentes.
+ // Verificamos pela presença de "objetoContrato" no primeiro registro.
+ const primeiro = resultado.dados[0];
+ if (!isPncpContrato(primeiro)) {
+ // São licitações, não contratos — procurar arquivo de contratos
+ return loadPncpContratosTimestamped();
+ }
+
+ return transformContratos(resultado as ResultadoColeta);
+}
+
+function loadPncpContratosTimestamped(): DadosPncp | null {
+ const dir = path.join(DATA_ROOT, "pncp");
+ try {
+ const files = fs.readdirSync(dir)
+ .filter((f) => f.includes("contratos") && f.endsWith(".json"))
+ .sort()
+ .reverse();
+ if (!files.length) return null;
+ const raw = fs.readFileSync(path.join(dir, files[0]), "utf-8");
+ const resultado = JSON.parse(raw) as ResultadoColeta;
+ return transformContratos(resultado);
+ } catch {
+ return null;
+ }
+}
+
+function transformContratos(resultado: ResultadoColeta): DadosPncp {
+ const contratos = resultado.dados;
+
+ // Contratos por mês
+ const porMesMap = new Map();
+ for (const c of contratos) {
+ const label = mesLabel(c.dataAssinatura);
+ const ex = porMesMap.get(label) ?? { mes: label, contratos: 0, valor: 0 };
+ ex.contratos++;
+ ex.valor += c.valorGlobal || c.valorInicial || 0;
+ porMesMap.set(label, ex);
+ }
+ const contratosPorMes = Array.from(porMesMap.values())
+ .sort((a, b) => parseLabel(a.mes) - parseLabel(b.mes));
+
+ // Top empresas
+ const porEmpresaMap = new Map();
+ for (const c of contratos) {
+ const cnpj = cnpjFormat(c.fornecedor?.cnpjCpf ?? "");
+ const ex = porEmpresaMap.get(cnpj) ?? {
+ empresa: c.fornecedor?.nomeRazaoSocial ?? "Desconhecida",
+ cnpj,
+ valor: 0,
+ contratos: 0,
+ };
+ ex.valor += c.valorGlobal || c.valorInicial || 0;
+ ex.contratos++;
+ porEmpresaMap.set(cnpj, ex);
+ }
+ const topEmpresas = Array.from(porEmpresaMap.values())
+ .sort((a, b) => b.valor - a.valor)
+ .slice(0, 8);
+
+ // Últimos contratos
+ const ultimosContratos = [...contratos]
+ .sort((a, b) => new Date(b.dataAssinatura).getTime() - new Date(a.dataAssinatura).getTime())
+ .slice(0, 10)
+ .map((c) => ({
+ numero: `${c.anoContrato}/${c.numeroContrato}`,
+ objeto: c.objetoContrato ?? "—",
+ contratada: c.fornecedor?.nomeRazaoSocial ?? "—",
+ secretaria: "—",
+ valor: c.valorGlobal || c.valorInicial || 0,
+ data: c.dataAssinatura,
+ modalidade: c.situacaoContrato?.nome ?? "Contrato",
+ }));
+
+ return {
+ fonte: "pncp",
+ coletadoEm: normalizarColetadoEm(resultado.coletadoEm),
+ totalContratos: contratos.length,
+ totalValor: contratos.reduce((s, c) => s + (c.valorGlobal || c.valorInicial || 0), 0),
+ contratosPorMes,
+ topEmpresas,
+ ultimosContratos,
+ };
+}
diff --git a/packages/collectors/package.json b/packages/collectors/package.json
index 79bc411..501bdc6 100644
--- a/packages/collectors/package.json
+++ b/packages/collectors/package.json
@@ -15,6 +15,7 @@
"map:pncp": "tsx src/mappers/pncp.ts",
"map:tce": "tsx src/mappers/tce-sp.ts",
"map:diario": "tsx src/mappers/diario-mentions.ts",
+ "map:diario-atoms": "tsx src/mappers/diario-atoms.ts",
"enrich:socios": "tsx src/enrich/cnpj-socios.ts",
"collect:transparencia": "tsx src/adapters/transparencia-americana.ts",
"reconcile": "tsx src/reconcile/entities.ts",
diff --git a/packages/collectors/src/adapters/camara-americana.ts b/packages/collectors/src/adapters/camara-americana.ts
index 842550d..047a80d 100644
--- a/packages/collectors/src/adapters/camara-americana.ts
+++ b/packages/collectors/src/adapters/camara-americana.ts
@@ -10,9 +10,11 @@
* Se o portal usar JavaScript, trocar por PlaywrightCrawler.
*/
-import { CheerioCrawler, RequestQueue } from "crawlee";
+import { CheerioCrawler } from "crawlee";
import { salvar, salvarLatest } from "../utils/save.js";
import { AMERICANA } from "../config.js";
+import { recordSourceCoverage } from "../utils/civic.js";
+import { closeDb } from "../utils/ingest.js";
import type { ResultadoColeta } from "../types.js";
interface SessaoLegislativa {
@@ -30,11 +32,7 @@ export async function coletarCamaraAmericana(): Promise {
const erros: string[] = [];
const sessoes: SessaoLegislativa[] = [];
- const queue = await RequestQueue.open("camara-americana");
- await queue.addRequest({ url: `${BASE_URL}/sessoes` });
-
const crawler = new CheerioCrawler({
- requestQueue: queue,
maxRequestsPerCrawl: 50,
async requestHandler({ $, request, enqueueLinks }) {
console.log(`[camara-americana] ${request.url}`);
@@ -66,7 +64,7 @@ export async function coletarCamaraAmericana(): Promise {
},
});
- await crawler.run();
+ await crawler.run([{ url: `${BASE_URL}/sessoes` }]);
const resultado: ResultadoColeta = {
fonte: "camara-americana",
@@ -81,11 +79,30 @@ export async function coletarCamaraAmericana(): Promise {
const arq = await salvar(resultado);
await salvarLatest(resultado);
console.log(`[camara-americana] ${sessoes.length} itens salvos em ${arq}`);
+
+ const tentativa = new Date();
+ await recordSourceCoverage({
+ sourceId: "camara-americana",
+ collector: "camara-americana",
+ territoryIbge: AMERICANA.ibge,
+ uf: AMERICANA.uf,
+ recordType: "atividade-legislativa",
+ status: erros.length > 0 ? "partial" : sessoes.length > 0 ? "fresh" : "no_data",
+ lastAttemptAt: tentativa,
+ lastSuccessAt: erros.length > 0 && sessoes.length === 0 ? null : tentativa,
+ totalRecords: sessoes.length,
+ errorMessage: erros.join(" | ") || null,
+ limitations:
+ "Coleta HTML inicial com seletores genéricos; projetos, indicações e votações precisam de mapeamento dedicado do portal legislativo.",
+ metadata: { baseUrl: BASE_URL },
+ });
}
if (
process.argv[1]?.endsWith("camara-americana.ts") ||
process.argv[1]?.endsWith("camara-americana.js")
) {
- coletarCamaraAmericana().catch(console.error);
+ coletarCamaraAmericana()
+ .catch(console.error)
+ .finally(() => closeDb());
}
diff --git a/packages/collectors/src/adapters/ceis-cnep.ts b/packages/collectors/src/adapters/ceis-cnep.ts
index a2ef5e8..a9f3a70 100644
--- a/packages/collectors/src/adapters/ceis-cnep.ts
+++ b/packages/collectors/src/adapters/ceis-cnep.ts
@@ -18,11 +18,12 @@
*/
import "dotenv/config";
import { and, eq } from "drizzle-orm";
-import { getDb, ingestRaw } from "../utils/ingest.js";
-import { CGU_API_BASE } from "../config.js";
+import { closeDb, getDb, ingestRaw } from "../utils/ingest.js";
+import { AMERICANA, CGU_API_BASE } from "../config.js";
import { normalizarDocumento } from "../utils/documento.js";
import { sleep } from "../utils/http.js";
import { entities, sanctions, type DB } from "@deolho/db";
+import { recordSourceCoverage, upsertCivicEvent, upsertEvidence } from "../utils/civic.js";
const KEY = process.env.PORTAL_TRANSPARENCIA_API_KEY;
@@ -63,6 +64,13 @@ function isoData(br?: string): string | null {
return br.length >= 10 ? br.slice(0, 10) : null;
}
+function sancaoKey(cadastro: "ceis" | "cnep", doc: string, it: CeisItem): string {
+ const tipo = it.tipoSancao?.descricaoResumida ?? "sem-tipo";
+ const inicio = isoData(it.dataInicioSancao) ?? "sem-inicio";
+ const orgao = it.orgaoSancionador?.nome ?? it.fonteSancao?.nomeExibicao ?? "sem-orgao";
+ return `${cadastro}-${doc}-${inicio}-${tipo}-${orgao}`.replace(/\s+/g, "-").toLowerCase();
+}
+
export async function coletarSancoes(
cadastro: "ceis" | "cnep" = "ceis",
maxPaginas = 300,
@@ -77,6 +85,20 @@ export async function coletarSancoes(
"[ceis-cnep] defina PORTAL_TRANSPARENCIA_API_KEY (chave gratuita: " +
"https://portaldatransparencia.gov.br/api-de-dados/cadastrar-email).",
);
+ await recordSourceCoverage({
+ sourceId: "cgu-transparencia",
+ collector: `cgu-${cadastro}`,
+ territoryIbge: AMERICANA.ibge,
+ uf: AMERICANA.uf,
+ recordType: cadastro,
+ status: "unavailable",
+ lastAttemptAt: new Date(),
+ lastSuccessAt: null,
+ totalRecords: 0,
+ errorMessage: "PORTAL_TRANSPARENCIA_API_KEY ausente",
+ limitations: "A API da CGU exige chave gratuita; sem chave, CEIS/CNEP não são consultados.",
+ metadata: { cadastro },
+ });
return;
}
@@ -92,17 +114,33 @@ export async function coletarSancoes(
);
if (conhecidos.size === 0) {
console.warn("[ceis-cnep] nenhum fornecedor no banco — rode collect:pncp + map:pncp antes.");
+ await recordSourceCoverage({
+ sourceId: "cgu-transparencia",
+ collector: `cgu-${cadastro}`,
+ territoryIbge: AMERICANA.ibge,
+ uf: AMERICANA.uf,
+ recordType: cadastro,
+ status: "pending",
+ lastAttemptAt: new Date(),
+ lastSuccessAt: null,
+ totalRecords: 0,
+ errorMessage: null,
+ limitations: "A coleta de sanções depende de fornecedores conhecidos no banco.",
+ metadata: { cadastro, fornecedoresConhecidos: 0 },
+ });
return;
}
let varridas = 0;
let casadas = 0;
+ let erroFinal: string | null = null;
for (let p = 1; p <= maxPaginas; p++) {
let itens: unknown[];
try {
itens = await fetchPagina(cadastro, p);
} catch (e) {
- console.warn(`[ceis-cnep] ${e instanceof Error ? e.message : String(e)}`);
+ erroFinal = e instanceof Error ? e.message : String(e);
+ console.warn(`[ceis-cnep] ${erroFinal}`);
break;
}
if (itens.length === 0) break;
@@ -126,6 +164,24 @@ export async function coletarSancoes(
console.log(
`[ceis-cnep] ${cadastro.toUpperCase()}: ${casadas} sanções de fornecedores conhecidos (de ${varridas} varridas)`,
);
+ const tentativa = new Date();
+ const execucaoLimitada = maxPaginas < 300;
+ await recordSourceCoverage({
+ sourceId: "cgu-transparencia",
+ collector: `cgu-${cadastro}`,
+ territoryIbge: AMERICANA.ibge,
+ uf: AMERICANA.uf,
+ recordType: cadastro,
+ status: erroFinal || execucaoLimitada ? "partial" : casadas > 0 ? "fresh" : "no_data",
+ lastAttemptAt: tentativa,
+ lastSuccessAt: erroFinal && casadas === 0 ? null : tentativa,
+ totalRecords: casadas,
+ errorMessage: erroFinal,
+ limitations:
+ "A varredura guarda sanções somente de fornecedores já conhecidos em Americana; não é busca nacional completa." +
+ (execucaoLimitada ? ` Execução limitada a ${maxPaginas} página(s) nesta rodada.` : ""),
+ metadata: { cadastro, varridas, fornecedoresConhecidos: conhecidos.size, maxPaginas },
+ });
}
async function gravarSancao(
@@ -139,6 +195,20 @@ async function gravarSancao(
.from(entities)
.where(and(eq(entities.kind, "empresa"), eq(entities.documento, doc)))
.limit(1);
+ const key = sancaoKey(cadastro, doc, it);
+ const existente = await db
+ .select({ id: sanctions.id })
+ .from(sanctions)
+ .where(
+ and(
+ eq(sanctions.sourceId, "cgu-transparencia"),
+ eq(sanctions.cadastro, cadastro.toUpperCase()),
+ eq(sanctions.documento, doc),
+ eq(sanctions.tipoSancao, it.tipoSancao?.descricaoResumida ?? ""),
+ ),
+ )
+ .limit(1);
+ if (!existente[0]) {
await db.insert(sanctions).values({
entityId: ent[0]?.id ?? null,
sourceId: "cgu-transparencia",
@@ -153,12 +223,58 @@ async function gravarSancao(
sourceUrl: it.linkPublicacao ?? `${CGU_API_BASE}/${cadastro}`,
fetchedAt: new Date(),
});
+ }
+
+ const civicEventId = await upsertCivicEvent(db, {
+ sourceId: "cgu-transparencia",
+ sourceKey: key,
+ tipo: "sancao_registrada",
+ categoria: "sancao",
+ titulo: `Sanção oficial registrada no ${cadastro.toUpperCase()}`,
+ resumo: [
+ it.pessoa?.nome ?? it.pessoa?.razaoSocialReceita ?? "Pessoa sancionada",
+ it.tipoSancao?.descricaoResumida ? `Tipo: ${it.tipoSancao.descricaoResumida}.` : null,
+ "Sinal oficial de sanção; não indica irregularidade nova no município por si só.",
+ ].filter(Boolean).join(" "),
+ dataEvento: isoData(it.dataInicioSancao),
+ municipioIbge: AMERICANA.ibge,
+ uf: AMERICANA.uf,
+ territorio: { municipio: AMERICANA.nome, ibge: AMERICANA.ibge, uf: AMERICANA.uf },
+ entidades: { entityId: ent[0]?.id ?? null, documento: doc },
+ limitacoes: [
+ {
+ campo: "escopo",
+ mensagem:
+ "A sanção é fato oficial da CGU; o vínculo com Americana só existe quando a empresa também aparece como fornecedor conhecido.",
+ },
+ ],
+ sourceUrl: it.linkPublicacao ?? `${CGU_API_BASE}/${cadastro}`,
+ fetchedAt: new Date(),
+ trustType: "fato_oficial",
+ });
+ await upsertEvidence(db, {
+ evidenceKey: `cgu:${key}:sancao`,
+ civicEventId,
+ sourceId: "cgu-transparencia",
+ sourceKey: key,
+ fieldPath: "$",
+ titulo: `Registro oficial ${cadastro.toUpperCase()} da CGU`,
+ sourceUrl: it.linkPublicacao ?? `${CGU_API_BASE}/${cadastro}`,
+ trecho: it.textoPublicacao ?? it.tipoSancao?.descricaoResumida ?? null,
+ metodoExtracao: "api-cgu",
+ fetchedAt: new Date(),
+ trustType: "fato_oficial",
+ });
}
if (process.argv[1]?.endsWith("ceis-cnep.ts") || process.argv[1]?.endsWith("ceis-cnep.js")) {
const cad: "ceis" | "cnep" = process.argv[2] === "cnep" ? "cnep" : "ceis";
- coletarSancoes(cad).catch((e) => {
- console.error(e);
- process.exit(1);
- });
+ const maxArg = process.argv.find((arg) => arg.startsWith("--max-pages="));
+ const maxPaginas = maxArg ? Number.parseInt(maxArg.split("=")[1] ?? "", 10) : 300;
+ coletarSancoes(cad, Number.isFinite(maxPaginas) && maxPaginas > 0 ? maxPaginas : 300)
+ .catch((e) => {
+ console.error(e);
+ process.exitCode = 1;
+ })
+ .finally(() => closeDb());
}
diff --git a/packages/collectors/src/adapters/diario-americana.ts b/packages/collectors/src/adapters/diario-americana.ts
index 236953e..4c5d180 100644
--- a/packages/collectors/src/adapters/diario-americana.ts
+++ b/packages/collectors/src/adapters/diario-americana.ts
@@ -23,7 +23,8 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { CheerioCrawler } from "crawlee";
import { and, eq } from "drizzle-orm";
-import { getDb, ingestRaw } from "../utils/ingest.js";
+import { closeDb, getDb, ingestRaw } from "../utils/ingest.js";
+import { recordSourceCoverage } from "../utils/civic.js";
import { salvar, salvarLatest } from "../utils/save.js";
import { AMERICANA } from "../config.js";
import { gazettes, type DB } from "@deolho/db";
@@ -231,6 +232,26 @@ export async function coletarDiarioAmericana(opts: ColetaOpts = {}): Promise e.date).filter((d): d is string => Boolean(d)).sort();
+ await recordSourceCoverage({
+ sourceId: "diario-americana",
+ collector: "diario-americana",
+ territoryIbge: AMERICANA.ibge,
+ uf: AMERICANA.uf,
+ recordType: "gazeta",
+ status: erros.length > 0 ? "partial" : todas.length > 0 ? "fresh" : "no_data",
+ coverageStart: datas[0] ?? null,
+ coverageEnd: datas[datas.length - 1] ?? null,
+ lastAttemptAt: tentativa,
+ lastSuccessAt: erros.length > 0 && novas.length === 0 ? null : tentativa,
+ totalRecords: todas.length,
+ errorMessage: erros.join(" | ") || null,
+ limitations:
+ "Cobertura baseada no portal oficial do Diário de Americana; datas ausentes em algumas edições são lacuna da própria página.",
+ metadata: { novas: novas.length, backfill, mesesIncremental },
+ });
}
async function upsertGazette(db: DB, sourceKey: string, e: EdicaoDiario): Promise {
@@ -263,8 +284,10 @@ if (
process.argv[1]?.endsWith("diario-americana.js")
) {
const backfill = process.argv.includes("--backfill");
- coletarDiarioAmericana({ backfill }).catch((e) => {
- console.error(e);
- process.exit(1);
- });
+ coletarDiarioAmericana({ backfill })
+ .catch((e) => {
+ console.error(e);
+ process.exitCode = 1;
+ })
+ .finally(() => closeDb());
}
diff --git a/packages/collectors/src/adapters/pncp.ts b/packages/collectors/src/adapters/pncp.ts
index 7e0ea3f..6172ffa 100644
--- a/packages/collectors/src/adapters/pncp.ts
+++ b/packages/collectors/src/adapters/pncp.ts
@@ -13,7 +13,8 @@
import { get, sleep } from "../utils/http.js";
import { salvar, salvarLatest } from "../utils/save.js";
-import { getDb, ingestRaw } from "../utils/ingest.js";
+import { closeDb, getDb, ingestRaw } from "../utils/ingest.js";
+import { recordSourceCoverage } from "../utils/civic.js";
import {
AMERICANA,
DATA_FINAL,
@@ -30,6 +31,38 @@ interface PncpPage {
tamanhoPagina: number;
}
+function dataCompactaParaIso(data: string): string {
+ return `${data.slice(0, 4)}-${data.slice(4, 6)}-${data.slice(6, 8)}`;
+}
+
+function contratoNumeroControleSeguro(ct: PncpContrato): string | null {
+ return ct.numeroControlePNCP
+ ? ct.numeroControlePNCP.replace(/[^A-Za-z0-9.-]/g, "-")
+ : null;
+}
+
+function contratoSourceKey(ct: PncpContrato): string {
+ const numeroControle = contratoNumeroControleSeguro(ct);
+ if (numeroControle) return `contrato-${numeroControle}`;
+
+ const numero = ct.numeroContrato ?? ct.numeroContratoEmpenho ?? "sem-numero";
+ return `contrato-${ct.anoContrato}-${numero}-${ct.sequencialContrato}`;
+}
+
+function contratoSourceUrl(ct: PncpContrato): string | null {
+ const controle = ct.numeroControlePNCP?.match(/^(\d+)-2-(\d+)\/(\d{4})$/);
+ if (controle) {
+ const [, cnpj, sequencial, ano] = controle;
+ return `https://pncp.gov.br/app/contratos/${cnpj}/${ano}/${sequencial}`;
+ }
+
+ if (ct.anoContrato && ct.sequencialContrato) {
+ return `https://pncp.gov.br/app/contratos/${AMERICANA.cnpj}/${ct.anoContrato}/${ct.sequencialContrato}`;
+ }
+
+ return null;
+}
+
async function buscarPagina(
endpoint: string,
params: Record,
@@ -130,10 +163,15 @@ export async function coletarPncp(): Promise {
for (const ct of contratos) {
await ingestRaw(db, {
sourceId: "pncp",
- sourceKey: `contrato-${ct.anoContrato}-${ct.numeroContrato}-${ct.sequencialContrato}`,
+ sourceKey: contratoSourceKey(ct),
recordType: "contrato",
payload: ct,
- publishedAt: ct.dataAssinatura ? new Date(ct.dataAssinatura) : null,
+ sourceUrl: contratoSourceUrl(ct),
+ publishedAt: ct.dataPublicacaoPncp
+ ? new Date(ct.dataPublicacaoPncp)
+ : ct.dataAssinatura
+ ? new Date(ct.dataAssinatura)
+ : null,
});
}
console.log(
@@ -176,8 +214,45 @@ export async function coletarPncp(): Promise {
console.warn(`[pncp] ${erros.length} erros:`);
erros.forEach((e) => console.warn(" ", e));
}
+
+ const tentativa = new Date();
+ await recordSourceCoverage({
+ sourceId: "pncp",
+ collector: "pncp",
+ territoryIbge: AMERICANA.ibge,
+ uf: AMERICANA.uf,
+ recordType: "compra",
+ status: erros.some((e) => e.startsWith("licitações:")) ? "partial" : compras.length > 0 ? "fresh" : "no_data",
+ coverageStart: dataCompactaParaIso(DATA_INICIAL),
+ coverageEnd: dataCompactaParaIso(DATA_FINAL),
+ lastAttemptAt: tentativa,
+ lastSuccessAt: erros.some((e) => e.startsWith("licitações:")) ? null : tentativa,
+ totalRecords: compras.length,
+ errorMessage: erros.find((e) => e.startsWith("licitações:")) ?? null,
+ limitations:
+ "O endpoint de publicações do PNCP exige modalidade; esta coleta ainda usa a consulta disponível para Americana e pode ficar parcial.",
+ metadata: { municipio: AMERICANA.nome, cnpjOrgao: AMERICANA.cnpj },
+ });
+ await recordSourceCoverage({
+ sourceId: "pncp",
+ collector: "pncp",
+ territoryIbge: AMERICANA.ibge,
+ uf: AMERICANA.uf,
+ recordType: "contrato",
+ status: erros.some((e) => e.startsWith("contratos:")) ? "partial" : contratos.length > 0 ? "fresh" : "no_data",
+ coverageStart: dataCompactaParaIso(DATA_INICIAL),
+ coverageEnd: dataCompactaParaIso(DATA_FINAL),
+ lastAttemptAt: tentativa,
+ lastSuccessAt: erros.some((e) => e.startsWith("contratos:")) ? null : tentativa,
+ totalRecords: contratos.length,
+ errorMessage: erros.find((e) => e.startsWith("contratos:")) ?? null,
+ limitations: "Contratos filtrados pelo CNPJ do órgão municipal no PNCP.",
+ metadata: { municipio: AMERICANA.nome, cnpjOrgao: AMERICANA.cnpj },
+ });
}
if (process.argv[1]?.endsWith("pncp.ts") || process.argv[1]?.endsWith("pncp.js")) {
- coletarPncp().catch(console.error);
+ coletarPncp()
+ .catch(console.error)
+ .finally(() => closeDb());
}
diff --git a/packages/collectors/src/adapters/querido-diario.ts b/packages/collectors/src/adapters/querido-diario.ts
index d830d09..5c7f619 100644
--- a/packages/collectors/src/adapters/querido-diario.ts
+++ b/packages/collectors/src/adapters/querido-diario.ts
@@ -12,6 +12,8 @@
import { get } from "../utils/http.js";
import { salvar, salvarLatest } from "../utils/save.js";
+import { recordSourceCoverage } from "../utils/civic.js";
+import { closeDb } from "../utils/ingest.js";
import {
AMERICANA,
DATA_FINAL_ISO,
@@ -72,8 +74,31 @@ export async function coletarQueiridoDiario(): Promise {
const arq = await salvar(resultado);
await salvarLatest(resultado);
console.log(`[querido-diario] ${edicoes.length} edições salvas em ${arq}`);
+
+ const tentativa = new Date();
+ await recordSourceCoverage({
+ sourceId: "querido-diario",
+ collector: "querido-diario",
+ territoryIbge: AMERICANA.ibge,
+ uf: AMERICANA.uf,
+ recordType: "gazeta",
+ status: erros.length > 0 ? "error" : edicoes.length > 0 ? "fresh" : "no_data",
+ coverageStart: DATA_INICIAL_ISO,
+ coverageEnd: DATA_FINAL_ISO,
+ lastAttemptAt: tentativa,
+ lastSuccessAt: erros.length > 0 ? null : tentativa,
+ totalRecords: edicoes.length,
+ errorMessage: erros.join(" | ") || null,
+ limitations:
+ edicoes.length === 0
+ ? "Americana aparece no Querido Diário, mas a API não retornou edições no período consultado."
+ : null,
+ metadata: { municipio: AMERICANA.nome },
+ });
}
if (process.argv[1]?.endsWith("querido-diario.ts") || process.argv[1]?.endsWith("querido-diario.js")) {
- coletarQueiridoDiario().catch(console.error);
+ coletarQueiridoDiario()
+ .catch(console.error)
+ .finally(() => closeDb());
}
diff --git a/packages/collectors/src/adapters/tce-sp.ts b/packages/collectors/src/adapters/tce-sp.ts
index 113ee5c..aaba703 100644
--- a/packages/collectors/src/adapters/tce-sp.ts
+++ b/packages/collectors/src/adapters/tce-sp.ts
@@ -3,58 +3,186 @@
* Transparência Municipal — despesas e receitas de Americana
*
* API base: https://transparencia.tce.sp.gov.br
- * Endpoints documentados no portal (verificar em produção):
- * GET /despesas.json?municipio=3501608&exercicio=2025
- * GET /receitas.json?municipio=3501608&exercicio=2025
+ * Endpoints documentados no portal:
+ * GET /api/json/despesas/{municipio}/{exercicio}/{mes}
+ * GET /api/json/receitas/{municipio}/{exercicio}/{mes}
*
- * ATENÇÃO: Os endpoints exatos precisam de verificação na documentação oficial.
- * Se retornarem 404, inspecionar https://transparencia.tce.sp.gov.br com DevTools
- * para encontrar os endpoints reais usados pela interface.
- *
- * VERIFICADO 2026-05-25: `/despesas.json` retorna 404 e `api.tce.sp.gov.br` não
- * resolve — os endpoints atuais ainda são desconhecidos (maior risco do eixo do
- * dinheiro). Enquanto isso, coletarDespesas() retorna [] graciosamente; assim que
- * o endpoint real for descoberto, a ingestão e o mapper (mappers/tce-sp.ts → payments)
- * já estão prontos. Cuidado: o TCE-SP usa código próprio de município, não o IBGE.
+ * Cuidado: o TCE-SP usa slug próprio de município ("americana"), não o IBGE.
*/
import { get } from "../utils/http.js";
import { salvar, salvarLatest } from "../utils/save.js";
-import { getDb, ingestRaw } from "../utils/ingest.js";
+import { closeDb, getDb, ingestMany, type IngestInput } from "../utils/ingest.js";
+import { recordSourceCoverage } from "../utils/civic.js";
import { AMERICANA, TCE_SP_BASE } from "../config.js";
import type { ResultadoColeta, TceSpDespesa, TceSpReceita } from "../types.js";
// exercícios a coletar (ano atual + anterior)
const anoAtual = new Date().getFullYear();
const EXERCICIOS = [anoAtual - 1, anoAtual];
+const TCE_MUNICIPIO = "americana";
+
+interface TceDespesaApi {
+ orgao: string;
+ mes: string;
+ evento: string;
+ nr_empenho: string;
+ id_fornecedor: string;
+ nm_fornecedor: string;
+ dt_emissao_despesa: string;
+ vl_despesa: string;
+}
+
+interface TceReceitaApi {
+ orgao: string;
+ mes: string;
+ ds_fonte_recurso: string;
+ ds_cd_aplicacao_fixo: string;
+ ds_alinea: string;
+ ds_subalinea: string;
+ vl_arrecadacao: string;
+}
+
+const MESES: Record = {
+ janeiro: 1,
+ fevereiro: 2,
+ marco: 3,
+ março: 3,
+ abril: 4,
+ maio: 5,
+ junho: 6,
+ julho: 7,
+ agosto: 8,
+ setembro: 9,
+ outubro: 10,
+ novembro: 11,
+ dezembro: 12,
+};
+
+function mesesDoExercicio(exercicio: number): number[] {
+ if (exercicio === anoAtual) {
+ return Array.from({ length: new Date().getMonth() + 1 }, (_, i) => i + 1);
+ }
+ return Array.from({ length: 12 }, (_, i) => i + 1);
+}
-async function coletarDespesas(exercicio: number): Promise {
+function parseValorBr(valor: string | number | null | undefined): number {
+ if (typeof valor === "number") return valor;
+ if (!valor) return 0;
+ const normalizado = valor.replace(/\./g, "").replace(",", ".");
+ const parsed = Number.parseFloat(normalizado);
+ return Number.isFinite(parsed) ? parsed : 0;
+}
+
+function mesNumero(valor: string | number, fallback: number): number {
+ if (typeof valor === "number") return valor;
+ return MESES[valor.trim().toLowerCase()] ?? fallback;
+}
+
+function documentoPublicavel(idFornecedor: string | null | undefined): string | null {
+ if (!idFornecedor) return null;
+ const digits = idFornecedor.replace(/\D/g, "");
+ return digits.length === 14 ? digits : null;
+}
+
+function chaveSegura(valor: string | number | null | undefined): string {
+ return String(valor ?? "sem-valor")
+ .normalize("NFD")
+ .replace(/[\u0300-\u036f]/g, "")
+ .replace(/[^A-Za-z0-9.-]/g, "-")
+ .replace(/-+/g, "-")
+ .replace(/^-|-$/g, "")
+ .slice(0, 120);
+}
+
+function despesaSourceUrl(exercicio: number, mes: number): string {
+ return `${TCE_SP_BASE}/api/json/despesas/${TCE_MUNICIPIO}/${exercicio}/${mes}`;
+}
+
+function receitaSourceUrl(exercicio: number, mes: number): string {
+ return `${TCE_SP_BASE}/api/json/receitas/${TCE_MUNICIPIO}/${exercicio}/${mes}`;
+}
+
+function normalizarDespesa(row: TceDespesaApi, exercicio: number, mesConsulta: number): TceSpDespesa {
+ const mes = mesNumero(row.mes, mesConsulta);
+ const valor = parseValorBr(row.vl_despesa);
+ const evento = row.evento?.trim() || null;
+
+ return {
+ exercicio,
+ mes,
+ orgao: row.orgao,
+ nomeOrgao: row.orgao,
+ funcao: null,
+ subfuncao: null,
+ programa: null,
+ acao: evento ? `${evento} - empenho ${row.nr_empenho}` : `Empenho ${row.nr_empenho}`,
+ elemento: null,
+ modalidade: null,
+ credor: row.id_fornecedor,
+ nomeCredor: row.nm_fornecedor,
+ cpfCnpjCredor: documentoPublicavel(row.id_fornecedor),
+ empenho: row.nr_empenho,
+ valorEmpenhado: evento?.toLowerCase() === "empenhado" ? valor : 0,
+ valorLiquidado: evento?.toLowerCase() === "liquidado" ? valor : 0,
+ valorPago: evento?.toLowerCase() === "pago" ? valor : 0,
+ eventoDespesa: evento,
+ valorDespesa: valor,
+ dataEmissaoDespesa: row.dt_emissao_despesa,
+ };
+}
+
+function normalizarReceita(row: TceReceitaApi, exercicio: number, mesConsulta: number): TceSpReceita {
+ return {
+ exercicio,
+ mes: mesNumero(row.mes, mesConsulta),
+ orgao: row.orgao,
+ categoria: row.ds_fonte_recurso,
+ origem: row.ds_cd_aplicacao_fixo,
+ especie: row.ds_alinea,
+ rubrica: row.ds_subalinea,
+ valorPrevisto: 0,
+ valorArrecadado: parseValorBr(row.vl_arrecadacao),
+ fonteRecurso: row.ds_fonte_recurso,
+ codigoAplicacao: row.ds_cd_aplicacao_fixo,
+ };
+}
+
+async function coletarDespesasMes(
+ exercicio: number,
+ mes: number,
+): Promise<{ dados: TceSpDespesa[]; erro?: string }> {
try {
- const res = await get(
- `${TCE_SP_BASE}/despesas.json`,
- { municipio: AMERICANA.ibge, exercicio },
- 800
+ const res = await get(
+ despesaSourceUrl(exercicio, mes),
+ {},
+ 800,
);
- return Array.isArray(res) ? res : (res as { data: TceSpDespesa[] }).data ?? [];
+ const rows = Array.isArray(res) ? res : (res as { data: TceDespesaApi[] }).data ?? [];
+ return { dados: rows.map((row) => normalizarDespesa(row, exercicio, mes)) };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
- console.warn(`[tce-sp] despesas ${exercicio}: ${msg}`);
- return [];
+ console.warn(`[tce-sp] despesas ${exercicio}/${mes}: ${msg}`);
+ return { dados: [], erro: `despesas ${exercicio}/${mes}: ${msg}` };
}
}
-async function coletarReceitas(exercicio: number): Promise {
+async function coletarReceitasMes(
+ exercicio: number,
+ mes: number,
+): Promise<{ dados: TceSpReceita[]; erro?: string }> {
try {
- const res = await get(
- `${TCE_SP_BASE}/receitas.json`,
- { municipio: AMERICANA.ibge, exercicio },
- 800
+ const res = await get(
+ receitaSourceUrl(exercicio, mes),
+ {},
+ 800,
);
- return Array.isArray(res) ? res : (res as { data: TceSpReceita[] }).data ?? [];
+ const rows = Array.isArray(res) ? res : (res as { data: TceReceitaApi[] }).data ?? [];
+ return { dados: rows.map((row) => normalizarReceita(row, exercicio, mes)) };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
- console.warn(`[tce-sp] receitas ${exercicio}: ${msg}`);
- return [];
+ console.warn(`[tce-sp] receitas ${exercicio}/${mes}: ${msg}`);
+ return { dados: [], erro: `receitas ${exercicio}/${mes}: ${msg}` };
}
}
@@ -66,29 +194,72 @@ export async function coletarTceSp(): Promise {
const receitas: TceSpReceita[] = [];
for (const exercicio of EXERCICIOS) {
- const d = await coletarDespesas(exercicio);
- const r = await coletarReceitas(exercicio);
- despesas.push(...d);
- receitas.push(...r);
- console.log(`[tce-sp] ${exercicio}: ${d.length} despesas, ${r.length} receitas`);
+ let totalDespesasAno = 0;
+ let totalReceitasAno = 0;
+ for (const mes of mesesDoExercicio(exercicio)) {
+ const d = await coletarDespesasMes(exercicio, mes);
+ const r = await coletarReceitasMes(exercicio, mes);
+ despesas.push(...d.dados);
+ receitas.push(...r.dados);
+ totalDespesasAno += d.dados.length;
+ totalReceitasAno += r.dados.length;
+ if (d.erro) erros.push(d.erro);
+ if (r.erro) erros.push(r.erro);
+ console.log(`[tce-sp] ${exercicio}/${mes}: ${d.dados.length} despesas, ${r.dados.length} receitas`);
+ }
+ console.log(`[tce-sp] ${exercicio}: ${totalDespesasAno} despesas, ${totalReceitasAno} receitas`);
}
// Ingestão L0 — despesas como raw_records (eixo do dinheiro, lado da saída).
const db = getDb();
if (db) {
+ const inputs: IngestInput[] = [];
for (const d of despesas) {
const mes = String(d.mes ?? 1).padStart(2, "0");
- await ingestRaw(db, {
+ inputs.push({
sourceId: "tce-sp",
- sourceKey: `despesa-${d.exercicio}-${d.empenho}`,
+ sourceKey: [
+ "despesa",
+ d.exercicio,
+ mes,
+ chaveSegura(d.empenho),
+ chaveSegura(d.eventoDespesa),
+ chaveSegura(d.dataEmissaoDespesa),
+ chaveSegura(d.valorDespesa),
+ ].join("-"),
recordType: "despesa",
payload: d,
+ sourceUrl: despesaSourceUrl(d.exercicio, d.mes),
publishedAt: d.exercicio ? new Date(`${d.exercicio}-${mes}-01`) : null,
});
}
+ for (const r of receitas) {
+ const mes = String(r.mes ?? 1).padStart(2, "0");
+ inputs.push({
+ sourceId: "tce-sp",
+ sourceKey: [
+ "receita",
+ r.exercicio,
+ mes,
+ chaveSegura(r.orgao),
+ chaveSegura(r.categoria),
+ chaveSegura(r.especie),
+ chaveSegura(r.rubrica),
+ chaveSegura(r.valorArrecadado),
+ ].join("-"),
+ recordType: "receita",
+ payload: r,
+ sourceUrl: receitaSourceUrl(r.exercicio, r.mes),
+ publishedAt: r.exercicio ? new Date(`${r.exercicio}-${mes}-01`) : null,
+ });
+ }
+ await ingestMany(db, inputs, 500);
if (despesas.length) {
console.log(`[tce-sp] ingeridas ${despesas.length} despesas em raw_records`);
}
+ if (receitas.length) {
+ console.log(`[tce-sp] ingeridas ${receitas.length} receitas em raw_records`);
+ }
}
const agora = new Date().toISOString();
@@ -121,8 +292,54 @@ export async function coletarTceSp(): Promise {
const arqR = await salvar(resReceitas);
console.log(`[tce-sp] ${receitas.length} receitas salvas em ${arqR}`);
}
+
+ const tentativa = new Date();
+ const status = erros.length > 0 && despesas.length === 0 && receitas.length === 0
+ ? "unavailable"
+ : erros.length > 0
+ ? "partial"
+ : despesas.length + receitas.length > 0
+ ? "fresh"
+ : "no_data";
+
+ await recordSourceCoverage({
+ sourceId: "tce-sp",
+ collector: "tce-sp",
+ territoryIbge: AMERICANA.ibge,
+ uf: AMERICANA.uf,
+ recordType: "despesa",
+ status,
+ coverageStart: `${EXERCICIOS[0]}-01-01`,
+ coverageEnd: `${EXERCICIOS[EXERCICIOS.length - 1]}-12-31`,
+ lastAttemptAt: tentativa,
+ lastSuccessAt: status === "fresh" || status === "partial" ? tentativa : null,
+ totalRecords: despesas.length,
+ errorMessage: erros.filter((e) => e.startsWith("despesas")).join(" | ") || null,
+ limitations:
+ "API oficial do TCE-SP publica despesas por mês e por slug municipal; documentos de pessoa física aparecem apenas parcialmente e não viram entidade pública.",
+ metadata: { exercicios: EXERCICIOS, municipioApi: TCE_MUNICIPIO },
+ });
+ await recordSourceCoverage({
+ sourceId: "tce-sp",
+ collector: "tce-sp",
+ territoryIbge: AMERICANA.ibge,
+ uf: AMERICANA.uf,
+ recordType: "receita",
+ status,
+ coverageStart: `${EXERCICIOS[0]}-01-01`,
+ coverageEnd: `${EXERCICIOS[EXERCICIOS.length - 1]}-12-31`,
+ lastAttemptAt: tentativa,
+ lastSuccessAt: status === "fresh" || status === "partial" ? tentativa : null,
+ totalRecords: receitas.length,
+ errorMessage: erros.filter((e) => e.startsWith("receitas")).join(" | ") || null,
+ limitations:
+ "API oficial do TCE-SP publica receitas por mês e por slug municipal.",
+ metadata: { exercicios: EXERCICIOS, municipioApi: TCE_MUNICIPIO },
+ });
}
if (process.argv[1]?.endsWith("tce-sp.ts") || process.argv[1]?.endsWith("tce-sp.js")) {
- coletarTceSp().catch(console.error);
+ coletarTceSp()
+ .catch(console.error)
+ .finally(() => closeDb());
}
diff --git a/packages/collectors/src/adapters/transparencia-americana.ts b/packages/collectors/src/adapters/transparencia-americana.ts
index 1416dd8..d444c43 100644
--- a/packages/collectors/src/adapters/transparencia-americana.ts
+++ b/packages/collectors/src/adapters/transparencia-americana.ts
@@ -13,6 +13,8 @@
*/
import "dotenv/config";
import { AMERICANA, TRANSPARENCIA_AMERICANA_BASE } from "../config.js";
+import { recordSourceCoverage } from "../utils/civic.js";
+import { closeDb } from "../utils/ingest.js";
export async function coletarTransparenciaAmericana(): Promise {
console.log(
@@ -23,14 +25,31 @@ export async function coletarTransparenciaAmericana(): Promise {
"no portal — sem execução detalhada a coletar. Reavaliar periodicamente; o mapper para " +
"payments já está previsto (mesmo padrão do TCE-SP).",
);
+ await recordSourceCoverage({
+ sourceId: "transparencia-americana",
+ collector: "transparencia-americana",
+ territoryIbge: AMERICANA.ibge,
+ uf: AMERICANA.uf,
+ recordType: "execucao-orcamentaria",
+ status: "unavailable",
+ lastAttemptAt: new Date(),
+ lastSuccessAt: null,
+ totalRecords: 0,
+ errorMessage: null,
+ limitations:
+ "A seção SIAFIC do portal municipal estava marcada como 'em breve'; despesas com credor ainda não estão disponíveis para coleta estruturada.",
+ metadata: { baseUrl: TRANSPARENCIA_AMERICANA_BASE },
+ });
}
if (
process.argv[1]?.endsWith("transparencia-americana.ts") ||
process.argv[1]?.endsWith("transparencia-americana.js")
) {
- coletarTransparenciaAmericana().catch((e) => {
- console.error(e);
- process.exit(1);
- });
+ coletarTransparenciaAmericana()
+ .catch((e) => {
+ console.error(e);
+ process.exitCode = 1;
+ })
+ .finally(() => closeDb());
}
diff --git a/packages/collectors/src/adapters/tse-doacoes.ts b/packages/collectors/src/adapters/tse-doacoes.ts
index dbb8712..0c11f51 100644
--- a/packages/collectors/src/adapters/tse-doacoes.ts
+++ b/packages/collectors/src/adapters/tse-doacoes.ts
@@ -13,7 +13,9 @@
* o caminho; a ingestão completa é um passo focado (não roda neste sandbox).
*/
import "dotenv/config";
-import { TSE_CDN_BASE } from "../config.js";
+import { AMERICANA, TSE_CDN_BASE } from "../config.js";
+import { recordSourceCoverage } from "../utils/civic.js";
+import { closeDb } from "../utils/ingest.js";
export async function coletarTseDoacoes(): Promise {
console.log(`[tse] Doações de campanha — fonte: ${TSE_CDN_BASE}/prestacao_contas`);
@@ -22,11 +24,28 @@ export async function coletarTseDoacoes(): Promise {
"(grande), descompactação, filtragem por SP/Americana e gravação no banco. " +
"Esqueleto e fonte prontos — próximo passo focado.",
);
+ await recordSourceCoverage({
+ sourceId: "tse",
+ collector: "tse-doacoes",
+ territoryIbge: AMERICANA.ibge,
+ uf: AMERICANA.uf,
+ recordType: "doacao-eleitoral",
+ status: "pending",
+ lastAttemptAt: new Date(),
+ lastSuccessAt: null,
+ totalRecords: 0,
+ errorMessage: null,
+ limitations:
+ "Dados do TSE são bulk ZIP; a fonte está identificada, mas a ingestão filtrada para Americana ainda não foi implementada.",
+ metadata: { baseUrl: TSE_CDN_BASE },
+ });
}
if (process.argv[1]?.endsWith("tse-doacoes.ts") || process.argv[1]?.endsWith("tse-doacoes.js")) {
- coletarTseDoacoes().catch((e) => {
- console.error(e);
- process.exit(1);
- });
+ coletarTseDoacoes()
+ .catch((e) => {
+ console.error(e);
+ process.exitCode = 1;
+ })
+ .finally(() => closeDb());
}
diff --git a/packages/collectors/src/compile/aggregate.ts b/packages/collectors/src/compile/aggregate.ts
index 20fab36..b634253 100644
--- a/packages/collectors/src/compile/aggregate.ts
+++ b/packages/collectors/src/compile/aggregate.ts
@@ -1,10 +1,10 @@
/**
* Agregador de ENTIDADES — transforma os átomos compilados num índice de
- * pessoas (agentes públicos), famílias (coocorrência de sobrenome) e órgãos.
+ * pessoas (agentes públicos), sobrenomes repetidos (coocorrência) e órgãos.
*
* Responde, de forma factual e com fonte, à pergunta do produto:
- * "quantas famílias estão no poder e há quanto tempo?" — sempre como
- * COOCORRÊNCIA em atos oficiais, NUNCA como prova de parentesco ou acusação.
+ * "quais sobrenomes se repetem nos atos oficiais?" — sempre como COOCORRÊNCIA,
+ * NUNCA como prova de parentesco, família documentada ou acusação.
*
* Tudo determinístico, zero LLM, zero custo.
*/
@@ -110,7 +110,7 @@ export function agregarEntidades(atoms: CompiledAtom[]): EntidadesIndex {
for (const a of atoms) {
// Ano: data da edição (publicação) ou, na falta, o ano do próprio ato
// (ex.: contrato 04/2022 sem data de edição → 2022). Dá timeline ao
- // "há quanto tempo no poder".
+ // período observado nos atos oficiais.
const anoAto = a.ano && /^\d{4}$/.test(a.ano) ? Number(a.ano) : null;
const ano = anoDe(a.edicaoDate) ?? anoAto;
const valor = parsearValor(a.valorMencionado ?? "");
@@ -201,7 +201,7 @@ export function agregarEntidades(atoms: CompiledAtom[]): EntidadesIndex {
}
pessoas.sort((a, b) => b.mencoes - a.mencoes);
- // Famílias: agrupa pessoas por sobrenome.
+ // Sobrenomes repetidos: agrupa pessoas por sobrenome sem afirmar parentesco.
const fMap = new Map();
for (const p of pessoas) {
const fslug = slugify(p.sobrenome);
diff --git a/packages/collectors/src/compile/document.ts b/packages/collectors/src/compile/document.ts
index 8a36c01..4bed397 100644
--- a/packages/collectors/src/compile/document.ts
+++ b/packages/collectors/src/compile/document.ts
@@ -29,6 +29,7 @@ const ACT_LABEL: Record = {
concorrencia: "CONCORR[ÊE]NCIA",
convenio: "CONV[ÊE]NIO",
ata_registro: "ATA\\s+DE\\s+REGISTRO\\s+DE\\s+PRE[ÇC]OS?",
+ indefinido: "(?:EXTRATO|AVISO|COMUNICADO|CONVOCA[ÇC][ÃA]O|AUDI[ÊE]NCIA|ORDEM\\s+DE\\s+SERVI[ÇC]O|PROCESSO\\s+ADMINISTRATIVO|TERMO)",
};
// Cabeçalhos de ato numerado que marcam claramente um NOVO ato (pra corte).
diff --git a/packages/collectors/src/compile/people.ts b/packages/collectors/src/compile/people.ts
index 74ba9f3..541d764 100644
--- a/packages/collectors/src/compile/people.ts
+++ b/packages/collectors/src/compile/people.ts
@@ -9,7 +9,7 @@
* capturamos cidadão comum. Todos os registros aqui são `pessoa_publica` no
* exercício/investidura de função pública — exatamente o que o Diário publica.
*
- * O agrupamento por SOBRENOME ("famílias no poder") é coocorrência factual em
+ * O agrupamento por SOBRENOME é coocorrência factual em
* atos oficiais, com fonte — NÃO é prova de parentesco nem acusação. A UI
* carrega esse disclaimer.
*/
diff --git a/packages/collectors/src/compile/sections.ts b/packages/collectors/src/compile/sections.ts
index 8a9ae42..e01e2e0 100644
--- a/packages/collectors/src/compile/sections.ts
+++ b/packages/collectors/src/compile/sections.ts
@@ -44,11 +44,16 @@ export interface CamposPregao {
estado?: "aberto" | "suspenso" | "anulado" | "homologado" | "deserto" | "indefinido";
}
+export interface CamposIndefinido {
+ assunto?: string;
+}
+
export type Campos =
| { tipo: "contrato" | "aditivo"; dados: CamposContrato }
| { tipo: "lei"; dados: CamposLei }
| { tipo: "portaria" | "decreto" | "resolucao"; dados: CamposPortaria }
- | { tipo: "edital" | "pregao" | "concorrencia" | "convite" | "ata_registro" | "convenio"; dados: CamposPregao };
+ | { tipo: "edital" | "pregao" | "concorrencia" | "convite" | "ata_registro" | "convenio"; dados: CamposPregao }
+ | { tipo: "indefinido"; dados: CamposIndefinido };
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -215,6 +220,10 @@ export function parsearCampos(tipo: TipoAto, texto: string): Campos {
case "ata_registro":
case "convenio":
return { tipo, dados: parsearPregao(t) };
+ case "indefinido": {
+ const assunto = t.split(/[.;]/)[0]?.slice(0, 160).trim();
+ return { tipo, dados: assunto ? { assunto } : {} };
+ }
}
}
diff --git a/packages/collectors/src/compile/titles.ts b/packages/collectors/src/compile/titles.ts
index 6c69d39..9ad72a1 100644
--- a/packages/collectors/src/compile/titles.ts
+++ b/packages/collectors/src/compile/titles.ts
@@ -148,6 +148,12 @@ export function gerarTituloHumano(campos: Campos, titulo: string): string | null
}
return null;
}
+
+ case "indefinido": {
+ const assunto = campos.dados.assunto;
+ if (!assunto) return null;
+ return primeiraFrase(assunto).split(/\s+/).slice(0, 12).join(" ");
+ }
}
}
@@ -193,5 +199,7 @@ export function gerarSubtitulo(campos: Campos): string | null {
const partes = [p.modalidade, p.estado && p.estado !== "indefinido" ? p.estado : null].filter(Boolean);
return partes.length > 0 ? partes.join(" · ") : null;
}
+ case "indefinido":
+ return "Publicação oficial sem classificação automática";
}
}
diff --git a/packages/collectors/src/enrich/cnpj-socios.ts b/packages/collectors/src/enrich/cnpj-socios.ts
index e5d5b1b..b1dc180 100644
--- a/packages/collectors/src/enrich/cnpj-socios.ts
+++ b/packages/collectors/src/enrich/cnpj-socios.ts
@@ -13,10 +13,11 @@
import "dotenv/config";
import { and, eq, isNotNull } from "drizzle-orm";
import { get } from "../utils/http.js";
-import { getDb, ingestRaw } from "../utils/ingest.js";
-import { BRASILAPI_CNPJ_BASE } from "../config.js";
+import { closeDb, getDb, ingestRaw } from "../utils/ingest.js";
+import { AMERICANA, BRASILAPI_CNPJ_BASE } from "../config.js";
import { normalizarDocumento } from "../utils/documento.js";
import { entities, companyPartners, type DB } from "@deolho/db";
+import { recordSourceCoverage } from "../utils/civic.js";
interface BrasilApiSocio {
nome_socio: string;
@@ -69,6 +70,7 @@ export async function enrichSocios(maxEmpresas = 100): Promise {
console.log(`[enrich:socios] ${empresas.length} empresas com CNPJ a enriquecer`);
let vinculos = 0;
+ let erros = 0;
for (const e of empresas) {
const doc = normalizarDocumento(e.documento);
if (!doc || doc.length !== 14) continue; // só CNPJ
@@ -76,6 +78,7 @@ export async function enrichSocios(maxEmpresas = 100): Promise {
try {
data = await fetchCnpj(doc);
} catch (err) {
+ erros++;
console.warn(`[enrich:socios] ${doc}: ${err instanceof Error ? err.message : String(err)}`);
continue;
}
@@ -91,6 +94,21 @@ export async function enrichSocios(maxEmpresas = 100): Promise {
}
}
console.log(`[enrich:socios] ${vinculos} vínculos de sócios gravados em company_partners`);
+ await recordSourceCoverage({
+ sourceId: "receita-cnpj",
+ collector: "brasilapi-cnpj-socios",
+ territoryIbge: AMERICANA.ibge,
+ uf: AMERICANA.uf,
+ recordType: "cnpj-qsa",
+ status: erros > 0 && vinculos === 0 ? "partial" : empresas.length > 0 ? "fresh" : "pending",
+ lastAttemptAt: new Date(),
+ lastSuccessAt: empresas.length > 0 && erros < empresas.length ? new Date() : null,
+ totalRecords: vinculos,
+ errorMessage: erros > 0 ? `${erros} CNPJs falharam no enriquecimento` : null,
+ limitations:
+ "QSA é guardado como atributo da empresa; só vira vínculo público quando houver entidade pública documentada e regra explícita.",
+ metadata: { empresasConsultadas: empresas.length, erros },
+ });
}
if (
@@ -114,9 +132,11 @@ if (
process.exit(1);
});
} else {
- enrichSocios().catch((e) => {
- console.error(e);
- process.exit(1);
- });
+ enrichSocios()
+ .catch((e) => {
+ console.error(e);
+ process.exitCode = 1;
+ })
+ .finally(() => closeDb());
}
}
diff --git a/packages/collectors/src/extract/atoms.ts b/packages/collectors/src/extract/atoms.ts
index 0aa0711..d4b9b48 100644
--- a/packages/collectors/src/extract/atoms.ts
+++ b/packages/collectors/src/extract/atoms.ts
@@ -20,6 +20,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { baixarPdf, extrairTextoPdf } from "../utils/pdf.js";
import { extrairCnpjs } from "../utils/documento.js";
+import { extrairTerritorios, type TerritorioMention } from "../utils/territory.js";
import { compilarLote } from "../compile/index.js";
import { agregarEntidades } from "../compile/aggregate.js";
@@ -48,7 +49,8 @@ export type TipoAto =
| "convite"
| "concorrencia"
| "convenio"
- | "ata_registro";
+ | "ata_registro"
+ | "indefinido";
export interface Atom {
/** Estável e único: ---. */
@@ -68,6 +70,8 @@ export interface Atom {
cnpjsMencionados: string[];
/** "R$ 1.240.000,00" — só pra tipos que tipicamente trazem valor. */
valorMencionado: string | null;
+ /** Territórios citados literalmente no trecho: rua, bairro ou equipamento público. */
+ territorios: TerritorioMention[];
}
const TIPOS: Array<{ tipo: TipoAto; padroes: RegExp[] }> = [
@@ -153,6 +157,7 @@ function labelTipo(tipo: TipoAto): string {
concorrencia: "Concorrência",
convenio: "Convênio",
ata_registro: "Ata de Registro de Preços",
+ indefinido: "Publicação",
}[tipo];
}
@@ -181,6 +186,50 @@ function valorMaisProximo(texto: string, posicao: number): string | null {
return m ? `R$ ${m[1]}` : null;
}
+const SEGMENTO_RE =
+ /\b(?:EXTRATO\s+DE\s+(?:CONTRATO|TERMO\s+ADITIVO|ATA)|AVISO\s+DE\s+(?:LICITA[ÇC][ÃA]O|SUSPENS[ÃA]O|HOMOLOGA[ÇC][ÃA]O)|COMUNICADO|CONVOCA[ÇC][ÃA]O|AUDI[ÊE]NCIA\s+P[ÚU]BLICA|ORDEM\s+DE\s+SERVI[ÇC]O|PROCESSO\s+ADMINISTRATIVO|TERMO\s+DE\s+(?:HOMOLOGA[ÇC][ÃA]O|RATIFICA[ÇC][ÃA]O)|CONSELHO\s+MUNICIPAL)\b/g;
+
+function segmentoJaCoberto(posicao: number, conhecidos: Atom[]): boolean {
+ return conhecidos.some((a) => Math.abs(a.posicao - posicao) < 240);
+}
+
+function extrairSegmentosIndefinidos(
+ texto: string,
+ edicaoSlug: string,
+ edicaoDate: string | null,
+ conhecidos: Atom[],
+): Atom[] {
+ const out: Atom[] = [];
+ let m: RegExpExecArray | null;
+ let seq = 1;
+ SEGMENTO_RE.lastIndex = 0;
+ while ((m = SEGMENTO_RE.exec(texto))) {
+ if (segmentoJaCoberto(m.index, conhecidos)) continue;
+ const resumo = trechoEmTornoDe(texto, m.index, 40, 1300);
+ const territorios = extrairTerritorios(resumo);
+ const cnpjs = extrairCnpjs(resumo);
+ const valorMencionado = valorMaisProximo(texto, m.index);
+ if (territorios.length === 0 && cnpjs.length === 0 && !valorMencionado) continue;
+
+ const numero = String(seq++).padStart(3, "0");
+ out.push({
+ id: `${edicaoSlug}-publicacao-${numero}`.replace(/[^\w-]/g, "-").toLowerCase(),
+ edicaoSlug,
+ edicaoDate,
+ tipo: "indefinido",
+ numero,
+ ano: edicaoDate?.slice(0, 4) ?? null,
+ titulo: `Publicação ${numero}`,
+ resumo,
+ posicao: m.index,
+ cnpjsMencionados: cnpjs,
+ valorMencionado,
+ territorios,
+ });
+ }
+ return out;
+}
+
function extrairAtomosDoTexto(
texto: string,
edicaoSlug: string,
@@ -225,11 +274,12 @@ function extrairAtomosDoTexto(
posicao,
cnpjsMencionados: cnpjs,
valorMencionado,
+ territorios: extrairTerritorios(resumo),
});
}
}
}
- return out;
+ return [...out, ...extrairSegmentosIndefinidos(texto, edicaoSlug, edicaoDate, out)];
}
/** Baixa + extrai uma edição. Retorna null em falha (pra NÃO cachear e retentar depois). */
diff --git a/packages/collectors/src/mappers/diario-atoms.ts b/packages/collectors/src/mappers/diario-atoms.ts
new file mode 100644
index 0000000..5689f88
--- /dev/null
+++ b/packages/collectors/src/mappers/diario-atoms.ts
@@ -0,0 +1,195 @@
+import "dotenv/config";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { closeDb, getDb } from "../utils/ingest.js";
+import { AMERICANA } from "../config.js";
+import { temSinalZeladoria, type TerritorioMention } from "../utils/territory.js";
+import { upsertCivicEvent, upsertEvidence } from "../utils/civic.js";
+
+type TipoAto =
+ | "lei"
+ | "decreto"
+ | "portaria"
+ | "resolucao"
+ | "contrato"
+ | "aditivo"
+ | "edital"
+ | "pregao"
+ | "convite"
+ | "concorrencia"
+ | "convenio"
+ | "ata_registro"
+ | "indefinido";
+
+interface AtomPessoa {
+ nome: string;
+ slug: string;
+ papel: string;
+ cargo: string | null;
+}
+
+interface AtomOrgao {
+ nome: string;
+ slug: string;
+ sigla: string | null;
+}
+
+interface Atom {
+ id: string;
+ edicaoSlug: string;
+ edicaoDate: string | null;
+ tipo: TipoAto;
+ numero: string;
+ ano: string | null;
+ titulo: string;
+ resumo: string;
+ posicao: number;
+ cnpjsMencionados: string[];
+ valorMencionado: string | null;
+ territorios?: TerritorioMention[];
+ tituloHumano?: string | null;
+ subtitulo?: string | null;
+ resumoLimpo?: string;
+ textoDocumento?: string;
+ pessoas?: AtomPessoa[];
+ orgaos?: AtomOrgao[];
+}
+
+interface AtomsFile {
+ atomos: Atom[];
+}
+
+const ATOMS_JSON = path.resolve(process.cwd(), "data/diario-americana/atoms.json");
+
+function sourceUrl(atom: Atom): string {
+ return `https://www.americana.sp.gov.br/download/diarioOficial/${atom.edicaoSlug}.pdf`;
+}
+
+function valorNumero(valor: string | null): number {
+ if (!valor) return 0;
+ const m = valor.match(/R\$\s*([\d.]+,\d{2})/);
+ if (!m) return 0;
+ return Number.parseFloat(m[1]!.replace(/\./g, "").replace(",", "."));
+}
+
+function categoria(atom: Atom):
+ | "contratacao"
+ | "obra_zeladoria"
+ | "ato_normativo"
+ | "nomeacao_exoneracao"
+ | "audiencia_conselho"
+ | "limitacao_fonte" {
+ const texto = `${atom.titulo} ${atom.resumo}`;
+ if (["contrato", "aditivo", "edital", "pregao", "convite", "concorrencia", "ata_registro", "convenio"].includes(atom.tipo)) {
+ return temSinalZeladoria(texto) ? "obra_zeladoria" : "contratacao";
+ }
+ if (atom.tipo === "portaria" && atom.pessoas?.some((p) => ["nomeado", "exonerado", "designado"].includes(p.papel))) {
+ return "nomeacao_exoneracao";
+ }
+ if (/\b(?:audi[êe]ncia|conselho)\b/i.test(texto)) return "audiencia_conselho";
+ if (temSinalZeladoria(texto)) return "obra_zeladoria";
+ if (atom.tipo === "indefinido") return "limitacao_fonte";
+ return "ato_normativo";
+}
+
+function tipoEvento(atom: Atom): "ato_publicado" | "contrato_publicado" | "contrato_atualizado" | "limitacao_detectada" {
+ if (atom.tipo === "contrato" || atom.tipo === "pregao" || atom.tipo === "edital" || atom.tipo === "ata_registro") {
+ return "contrato_publicado";
+ }
+ if (atom.tipo === "aditivo") return "contrato_atualizado";
+ if (atom.tipo === "indefinido") return "limitacao_detectada";
+ return "ato_publicado";
+}
+
+function titulo(atom: Atom): string {
+ if (atom.tituloHumano) return atom.tituloHumano;
+ if (atom.tipo === "indefinido") return "Publicação oficial sem classificação automática";
+ return atom.titulo;
+}
+
+function resumo(atom: Atom): string {
+ return atom.subtitulo ?? atom.textoDocumento ?? atom.resumoLimpo ?? atom.resumo;
+}
+
+export async function mapearAtomosDiario(): Promise {
+ const db = getDb();
+ if (!db) {
+ console.error("[map:diario-atoms] DATABASE_URL não definida.");
+ return;
+ }
+
+ let parsed: AtomsFile;
+ try {
+ parsed = JSON.parse(await fs.readFile(ATOMS_JSON, "utf8")) as AtomsFile;
+ } catch (e) {
+ console.error(`[map:diario-atoms] não foi possível ler ${ATOMS_JSON}: ${e instanceof Error ? e.message : String(e)}`);
+ return;
+ }
+
+ let n = 0;
+ for (const atom of parsed.atomos) {
+ const valor = valorNumero(atom.valorMencionado);
+ const url = sourceUrl(atom);
+ const civicEventId = await upsertCivicEvent(db, {
+ sourceId: "diario-americana",
+ sourceKey: atom.id,
+ tipo: tipoEvento(atom),
+ categoria: categoria(atom),
+ titulo: titulo(atom),
+ resumo: resumo(atom).slice(0, 1200),
+ dataEvento: atom.edicaoDate,
+ valor: valor > 0 ? String(valor) : null,
+ municipioIbge: AMERICANA.ibge,
+ uf: AMERICANA.uf,
+ territorio: {
+ municipio: AMERICANA.nome,
+ ibge: AMERICANA.ibge,
+ uf: AMERICANA.uf,
+ mencoes: atom.territorios ?? [],
+ },
+ entidades: {
+ cnpjs: atom.cnpjsMencionados,
+ pessoas: atom.pessoas ?? [],
+ orgaos: atom.orgaos ?? [],
+ },
+ limitacoes: atom.tipo === "indefinido"
+ ? [{ campo: "tipo", mensagem: "A publicação foi segmentada, mas ainda não foi classificada com segurança." }]
+ : null,
+ sourceUrl: url,
+ publishedAt: atom.edicaoDate ? new Date(atom.edicaoDate) : null,
+ fetchedAt: null,
+ trustType: atom.tipo === "indefinido" ? "sinal_atencao" : "fato_oficial",
+ });
+
+ await upsertEvidence(db, {
+ evidenceKey: `diario-americana:${atom.id}:trecho`,
+ civicEventId,
+ sourceId: "diario-americana",
+ sourceKey: atom.id,
+ fieldPath: "textoDocumento",
+ titulo: "Trecho do Diário Oficial de Americana",
+ sourceUrl: url,
+ trecho: atom.textoDocumento ?? atom.resumo,
+ posicaoInicio: atom.posicao,
+ posicaoFim: atom.posicao + atom.resumo.length,
+ metodoExtracao: atom.tipo === "indefinido" ? "pdf-segmentacao" : "pdf-regex",
+ publishedAt: atom.edicaoDate ? new Date(atom.edicaoDate) : null,
+ trustType: "fato_oficial",
+ });
+ n++;
+ }
+
+ console.log(`[map:diario-atoms] ${n} eventos/evidências gerados a partir dos átomos do Diário`);
+}
+
+if (
+ process.argv[1]?.endsWith("diario-atoms.ts") ||
+ process.argv[1]?.endsWith("diario-atoms.js")
+) {
+ mapearAtomosDiario()
+ .catch((e) => {
+ console.error(e);
+ process.exitCode = 1;
+ })
+ .finally(() => closeDb());
+}
diff --git a/packages/collectors/src/mappers/diario-mentions.ts b/packages/collectors/src/mappers/diario-mentions.ts
index 0b3f4dd..6f5e154 100644
--- a/packages/collectors/src/mappers/diario-mentions.ts
+++ b/packages/collectors/src/mappers/diario-mentions.ts
@@ -14,7 +14,7 @@
*/
import "dotenv/config";
import { and, eq } from "drizzle-orm";
-import { getDb } from "../utils/ingest.js";
+import { closeDb, getDb } from "../utils/ingest.js";
import { baixarPdf, extrairTextoPdf } from "../utils/pdf.js";
import { extrairCnpjs } from "../utils/documento.js";
import { gazettes, gazetteMentions, entities } from "@deolho/db";
@@ -86,9 +86,11 @@ if (
process.exit(1);
});
} else {
- mapearMencoesDiario().catch((e) => {
- console.error(e);
- process.exit(1);
- });
+ mapearMencoesDiario()
+ .catch((e) => {
+ console.error(e);
+ process.exitCode = 1;
+ })
+ .finally(() => closeDb());
}
}
diff --git a/packages/collectors/src/mappers/pncp.ts b/packages/collectors/src/mappers/pncp.ts
index af63b7e..42db249 100644
--- a/packages/collectors/src/mappers/pncp.ts
+++ b/packages/collectors/src/mappers/pncp.ts
@@ -9,14 +9,68 @@
*/
import "dotenv/config";
import { and, eq } from "drizzle-orm";
-import { getDb } from "../utils/ingest.js";
+import { closeDb, getDb } from "../utils/ingest.js";
import { upsertEntity, addEntityReference } from "../reconcile/entities.js";
import { AMERICANA } from "../config.js";
import { rawRecords, contracts, contractEvents } from "@deolho/db";
-import type { PncpContrato } from "../types.js";
+import type { PncpCompra, PncpContrato } from "../types.js";
+import {
+ upsertCivicEvent,
+ upsertEntityRelationship,
+ upsertEvidence,
+ upsertMoneyFlow,
+} from "../utils/civic.js";
const ORGAO_NOME = "Município de Americana";
+function dataIso(s: string | null | undefined): string | null {
+ return s ? s.slice(0, 10) : null;
+}
+
+function valorContrato(c: PncpContrato): number {
+ return c.valorGlobal || c.valorInicial || 0;
+}
+
+function numeroContrato(c: PncpContrato): string | null {
+ return c.numeroContrato ?? c.numeroContratoEmpenho ?? c.numeroControlePNCP ?? null;
+}
+
+function fornecedorDocumento(c: PncpContrato): string | null {
+ return c.fornecedor?.cnpjCpf ?? c.niFornecedor ?? null;
+}
+
+function fornecedorNome(c: PncpContrato): string | null {
+ return c.fornecedor?.nomeRazaoSocial ?? c.nomeRazaoSocialFornecedor ?? null;
+}
+
+function contratoSourceUrl(c: PncpContrato): string | null {
+ const controle = c.numeroControlePNCP?.match(/^(\d+)-2-(\d+)\/(\d{4})$/);
+ if (controle) {
+ const [, cnpj, sequencial, ano] = controle;
+ return `https://pncp.gov.br/app/contratos/${cnpj}/${ano}/${sequencial}`;
+ }
+
+ if (c.anoContrato && c.sequencialContrato) {
+ return `https://pncp.gov.br/app/contratos/${AMERICANA.cnpj}/${c.anoContrato}/${c.sequencialContrato}`;
+ }
+
+ return null;
+}
+
+function contratoNumeroControleSeguro(c: PncpContrato): string | null {
+ return c.numeroControlePNCP
+ ? c.numeroControlePNCP.replace(/[^A-Za-z0-9.-]/g, "-")
+ : null;
+}
+
+function contratoSourceKey(c: PncpContrato): string {
+ const numeroControle = contratoNumeroControleSeguro(c);
+ if (numeroControle) return `contrato-${numeroControle}`;
+
+ const numero = c.numeroContrato ?? c.numeroContratoEmpenho ?? "sem-numero";
+ return `contrato-${c.anoContrato}-${numero}-${c.sequencialContrato}`;
+}
+
export async function mapearPncp(): Promise {
const db = getDb();
if (!db) {
@@ -30,9 +84,109 @@ export async function mapearPncp(): Promise {
.where(and(eq(rawRecords.sourceId, "pncp"), eq(rawRecords.recordType, "contrato")));
console.log(`[map:pncp] ${raws.length} contratos crus a mapear`);
+ const comprasRaw = await db
+ .select()
+ .from(rawRecords)
+ .where(and(eq(rawRecords.sourceId, "pncp"), eq(rawRecords.recordType, "compra")));
+ console.log(`[map:pncp] ${comprasRaw.length} licitações cruas a mapear`);
+
+ let comprasMapeadas = 0;
+ for (const raw of comprasRaw) {
+ const compra = raw.payload as PncpCompra;
+ const orgaoNome = compra.orgaoEntidade?.razaoSocial || ORGAO_NOME;
+ const orgaoDoc = compra.orgaoEntidade?.cnpj || AMERICANA.cnpj;
+ const orgaoId = await upsertEntity(db, {
+ kind: "orgao",
+ nome: orgaoNome,
+ documento: orgaoDoc,
+ });
+ await addEntityReference(db, {
+ entityId: orgaoId,
+ sourceId: "pncp",
+ sourceKey: raw.sourceKey,
+ rawNome: orgaoNome,
+ rawDocumento: orgaoDoc,
+ });
+
+ const valorEstimado = compra.valorTotalEstimado || 0;
+ const civicEventId = await upsertCivicEvent(db, {
+ sourceId: "pncp",
+ sourceKey: raw.sourceKey,
+ rawRecordId: raw.id,
+ tipo: "licitacao_publicada",
+ categoria: "contratacao",
+ titulo: compra.numeroCompra
+ ? `Licitação ${compra.numeroCompra}/${compra.anoCompra} publicada no PNCP`
+ : "Licitação publicada no PNCP",
+ resumo: compra.objetoCompra || null,
+ dataEvento: dataIso(compra.dataPublicacaoPncp),
+ valor: valorEstimado > 0 ? String(valorEstimado) : null,
+ municipioIbge: AMERICANA.ibge,
+ uf: AMERICANA.uf,
+ territorio: { municipio: AMERICANA.nome, ibge: AMERICANA.ibge, uf: AMERICANA.uf },
+ entidades: {
+ orgaoEntityId: orgaoId,
+ modalidade: compra.modalidadeNome,
+ situacao: compra.situacaoCompraNome,
+ },
+ limitacoes: compra.valorTotalHomologado == null
+ ? [{ campo: "valorTotalHomologado", mensagem: "A fonte ainda não informa valor homologado para esta licitação." }]
+ : null,
+ sourceUrl: raw.sourceUrl,
+ publishedAt: raw.publishedAt,
+ fetchedAt: raw.fetchedAt,
+ trustType: "fato_oficial",
+ });
+
+ await upsertEvidence(db, {
+ evidenceKey: `pncp:${raw.sourceKey}:compra`,
+ civicEventId,
+ rawRecordId: raw.id,
+ sourceId: "pncp",
+ sourceKey: raw.sourceKey,
+ fieldPath: "$",
+ titulo: "Registro oficial da licitação no PNCP",
+ sourceUrl: raw.sourceUrl,
+ trecho: compra.objetoCompra ?? null,
+ metodoExtracao: "api-pncp",
+ publishedAt: raw.publishedAt,
+ fetchedAt: raw.fetchedAt,
+ trustType: "fato_oficial",
+ });
+
+ if (valorEstimado > 0) {
+ await upsertMoneyFlow(db, {
+ flowKey: `pncp:${raw.sourceKey}:previsto`,
+ sourceId: "pncp",
+ sourceKey: raw.sourceKey,
+ rawRecordId: raw.id,
+ civicEventId,
+ orgaoEntityId: orgaoId,
+ tipo: "previsto",
+ valor: String(valorEstimado),
+ dataCompetencia: dataIso(compra.dataPublicacaoPncp),
+ dataMovimento: dataIso(compra.dataPublicacaoPncp),
+ exercicio: compra.anoCompra ?? null,
+ municipioIbge: AMERICANA.ibge,
+ uf: AMERICANA.uf,
+ descricao: compra.objetoCompra || `Licitação ${compra.numeroCompra ?? raw.sourceKey}`,
+ sourceUrl: raw.sourceUrl,
+ publishedAt: raw.publishedAt,
+ fetchedAt: raw.fetchedAt,
+ trustType: "fato_oficial",
+ });
+ }
+ comprasMapeadas++;
+ }
+
let n = 0;
for (const raw of raws) {
const c = raw.payload as PncpContrato;
+ const fornecedorDoc = fornecedorDocumento(c);
+ const fornecedorRazaoSocial = fornecedorNome(c);
+ const contratoNumero = numeroContrato(c);
+ const sourceKey = contratoSourceKey(c);
+ const sourceUrl = raw.sourceUrl ?? contratoSourceUrl(c);
// Órgão — coletamos contratos de Americana, logo o órgão é a Prefeitura.
const orgaoId = await upsertEntity(db, {
@@ -43,33 +197,33 @@ export async function mapearPncp(): Promise {
await addEntityReference(db, {
entityId: orgaoId,
sourceId: "pncp",
- sourceKey: raw.sourceKey,
+ sourceKey,
rawNome: ORGAO_NOME,
rawDocumento: AMERICANA.cnpj,
});
// Fornecedor — a ponta que cruza com pagamentos (TCE) e sanções (CEIS).
let fornecedorId: string | null = null;
- if (c.fornecedor?.cnpjCpf || c.fornecedor?.nomeRazaoSocial) {
+ if (fornecedorDoc || fornecedorRazaoSocial) {
fornecedorId = await upsertEntity(db, {
kind: "empresa",
- nome: c.fornecedor.nomeRazaoSocial,
- documento: c.fornecedor.cnpjCpf,
+ nome: fornecedorRazaoSocial ?? fornecedorDoc ?? "Fornecedor PNCP sem nome",
+ documento: fornecedorDoc,
});
await addEntityReference(db, {
entityId: fornecedorId,
sourceId: "pncp",
- sourceKey: raw.sourceKey,
- rawNome: c.fornecedor.nomeRazaoSocial,
- rawDocumento: c.fornecedor.cnpjCpf,
+ sourceKey,
+ rawNome: fornecedorRazaoSocial ?? fornecedorDoc ?? "Fornecedor PNCP sem nome",
+ rawDocumento: fornecedorDoc,
});
}
const valores = {
sourceId: "pncp",
- sourceKey: raw.sourceKey,
+ sourceKey,
rawRecordId: raw.id,
- numero: c.numeroContrato ?? null,
+ numero: contratoNumero,
ano: c.anoContrato ?? null,
objeto: c.objetoContrato || "(sem objeto)",
valorInicial: c.valorInicial != null ? String(c.valorInicial) : null,
@@ -82,7 +236,7 @@ export async function mapearPncp(): Promise {
fornecedorEntityId: fornecedorId,
municipioIbge: AMERICANA.ibge,
uf: AMERICANA.uf,
- sourceUrl: raw.sourceUrl,
+ sourceUrl,
publishedAt: raw.publishedAt,
fetchedAt: raw.fetchedAt,
};
@@ -90,7 +244,7 @@ export async function mapearPncp(): Promise {
const existing = await db
.select({ id: contracts.id })
.from(contracts)
- .where(and(eq(contracts.sourceId, "pncp"), eq(contracts.sourceKey, raw.sourceKey)))
+ .where(and(eq(contracts.sourceId, "pncp"), eq(contracts.sourceKey, sourceKey)))
.limit(1);
let contractId: string;
@@ -118,20 +272,118 @@ export async function mapearPncp(): Promise {
contractId,
tipo: "assinatura",
data: valores.dataAssinatura,
- descricao: `Assinatura do contrato ${c.numeroContrato ?? ""}`.trim(),
+ descricao: `Assinatura do contrato ${contratoNumero ?? ""}`.trim(),
rawRecordId: raw.id,
- sourceUrl: raw.sourceUrl,
+ sourceUrl,
});
}
}
+
+ const valor = valorContrato(c);
+ const titulo = contratoNumero
+ ? `Contrato ${contratoNumero}/${c.anoContrato ?? ""} publicado no PNCP`
+ : "Contrato publicado no PNCP";
+ const resumo = [
+ c.objetoContrato,
+ fornecedorRazaoSocial ? `Fornecedor: ${fornecedorRazaoSocial}.` : null,
+ valor > 0 ? `Valor registrado: R$ ${valor.toLocaleString("pt-BR", { minimumFractionDigits: 2 })}.` : null,
+ ].filter(Boolean).join(" ");
+
+ const civicEventId = await upsertCivicEvent(db, {
+ sourceId: "pncp",
+ sourceKey,
+ rawRecordId: raw.id,
+ tipo: "contrato_publicado",
+ categoria: "contratacao",
+ titulo,
+ resumo: resumo || c.objetoContrato || null,
+ dataEvento: dataIso(c.dataAssinatura),
+ valor: valor > 0 ? String(valor) : null,
+ municipioIbge: AMERICANA.ibge,
+ uf: AMERICANA.uf,
+ territorio: { municipio: AMERICANA.nome, ibge: AMERICANA.ibge, uf: AMERICANA.uf },
+ entidades: {
+ orgaoEntityId: orgaoId,
+ fornecedorEntityId: fornecedorId,
+ fornecedorDocumento: fornecedorDoc,
+ },
+ limitacoes: fornecedorId
+ ? null
+ : [{ campo: "fornecedor", mensagem: "A fonte não informou fornecedor com CNPJ neste registro." }],
+ sourceUrl,
+ publishedAt: raw.publishedAt,
+ fetchedAt: raw.fetchedAt,
+ trustType: "fato_oficial",
+ });
+
+ await upsertEvidence(db, {
+ evidenceKey: `pncp:${sourceKey}:contrato`,
+ civicEventId,
+ rawRecordId: raw.id,
+ sourceId: "pncp",
+ sourceKey,
+ fieldPath: "$",
+ titulo: "Registro oficial do contrato no PNCP",
+ sourceUrl,
+ trecho: c.objetoContrato ?? null,
+ metodoExtracao: "api-pncp",
+ publishedAt: raw.publishedAt,
+ fetchedAt: raw.fetchedAt,
+ trustType: "fato_oficial",
+ });
+
+ if (valor > 0) {
+ await upsertMoneyFlow(db, {
+ flowKey: `pncp:${sourceKey}:contratado`,
+ sourceId: "pncp",
+ sourceKey,
+ rawRecordId: raw.id,
+ civicEventId,
+ contractId,
+ orgaoEntityId: orgaoId,
+ counterpartyEntityId: fornecedorId,
+ tipo: "contratado",
+ valor: String(valor),
+ dataCompetencia: dataIso(c.dataAssinatura),
+ dataMovimento: dataIso(c.dataAssinatura),
+ exercicio: c.anoContrato ?? null,
+ municipioIbge: AMERICANA.ibge,
+ uf: AMERICANA.uf,
+ descricao: c.objetoContrato || `Contrato ${contratoNumero ?? sourceKey}`,
+ sourceUrl,
+ publishedAt: raw.publishedAt,
+ fetchedAt: raw.fetchedAt,
+ trustType: "fato_oficial",
+ });
+ }
+
+ if (fornecedorId) {
+ await upsertEntityRelationship(db, {
+ relationshipKey: `pncp:${sourceKey}:orgao-fornecedor`,
+ fromEntityId: orgaoId,
+ toEntityId: fornecedorId,
+ tipo: "fornecedor",
+ sourceId: "pncp",
+ sourceKey,
+ rawRecordId: raw.id,
+ civicEventId,
+ descricao: `Fornecedor vinculado ao contrato ${contratoNumero ?? sourceKey}.`,
+ confianca: "1.000",
+ dataInicio: dataIso(c.dataAssinatura),
+ metadata: { numeroContrato: contratoNumero, anoContrato: c.anoContrato },
+ trustType: "fato_oficial",
+ });
+ }
n++;
}
- console.log(`[map:pncp] ${n} contratos no typed-core + entidades resolvidas`);
+ console.log(`[map:pncp] ${comprasMapeadas} licitações + ${n} contratos mapeados para a camada canônica`);
}
if (process.argv[1]?.endsWith("pncp.ts") || process.argv[1]?.endsWith("pncp.js")) {
- mapearPncp().catch((e) => {
- console.error(e);
- process.exit(1);
- });
+ mapearPncp()
+ .catch((e) => {
+ console.error(e);
+ process.exitCode = 1;
+ })
+ .finally(() => closeDb());
}
diff --git a/packages/collectors/src/mappers/tce-sp.ts b/packages/collectors/src/mappers/tce-sp.ts
index 24fdaac..687b364 100644
--- a/packages/collectors/src/mappers/tce-sp.ts
+++ b/packages/collectors/src/mappers/tce-sp.ts
@@ -4,17 +4,54 @@
* Deriva o typed-core "pagamento" das despesas do TCE-SP, resolvendo o credor em
* entidade canônica (o CNPJ que cruza com os contratos do PNCP) e o órgão. Liga
* cada pagamento à sua evidência (raw_record_id). Idempotente por (source, key).
- *
- * Roda vazio enquanto o endpoint real do TCE-SP não for resolvido (ver
- * adapters/tce-sp.ts) — fica pronto para quando houver despesas cruas no banco.
*/
import "dotenv/config";
-import { and, eq } from "drizzle-orm";
-import { getDb } from "../utils/ingest.js";
+import { and, eq, isNull, or } from "drizzle-orm";
+import { closeDb, getDb } from "../utils/ingest.js";
import { upsertEntity, addEntityReference } from "../reconcile/entities.js";
import { AMERICANA } from "../config.js";
-import { rawRecords, payments } from "@deolho/db";
-import type { TceSpDespesa } from "../types.js";
+import { civicEvents, evidence as evidenceTable, moneyFlows, rawRecords, payments } from "@deolho/db";
+import type { TceSpDespesa, TceSpReceita } from "../types.js";
+import { upsertCivicEvent, upsertEvidence, upsertMoneyFlow } from "../utils/civic.js";
+
+function competencia(exercicio: number | null | undefined, mes: number | null | undefined): string | null {
+ if (!exercicio || !mes || mes < 1 || mes > 12) return null;
+ return `${exercicio}-${String(mes).padStart(2, "0")}-01`;
+}
+
+function formatarValor(valor: number): string {
+ return `R$ ${valor.toLocaleString("pt-BR", { minimumFractionDigits: 2 })}`;
+}
+
+function dataBrParaIso(data: string | null | undefined): string | null {
+ if (!data) return null;
+ const partes = data.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
+ if (!partes) return null;
+ const [, dia, mes, ano] = partes;
+ return `${ano}-${mes}-${dia}`;
+}
+
+function documentoCnpj(documento: string | null | undefined): string | null {
+ const digits = documento?.replace(/\D/g, "") ?? "";
+ return digits.length === 14 ? digits : null;
+}
+
+function tipoFluxoDespesa(evento: string | null | undefined): "empenhado" | "liquidado" | "pago" | "reforcado" | "anulado" | null {
+ const normalizado = evento
+ ?.normalize("NFD")
+ .replace(/[\u0300-\u036f]/g, "")
+ .toLowerCase();
+ if (normalizado === "empenhado") return "empenhado";
+ if (normalizado === "liquidado" || normalizado === "valor liquidado") return "liquidado";
+ if (normalizado === "pago" || normalizado === "valor pago") return "pago";
+ if (normalizado === "reforco" || normalizado === "reforcado") return "reforcado";
+ if (normalizado === "anulacao" || normalizado === "anulado") return "anulado";
+ return null;
+}
+
+function entityCacheKey(kind: string, nome: string, documento: string | null | undefined): string {
+ return `${kind}:${documento ?? nome}`;
+}
export async function mapearTceSp(): Promise {
const db = getDb();
@@ -22,34 +59,108 @@ export async function mapearTceSp(): Promise {
console.error("[map:tce] DATABASE_URL não definida — nada a mapear.");
return;
}
+ const database = db;
- const raws = await db
- .select()
+ const despesaRows = await db
+ .select({ raw: rawRecords })
.from(rawRecords)
- .where(and(eq(rawRecords.sourceId, "tce-sp"), eq(rawRecords.recordType, "despesa")));
+ .leftJoin(
+ civicEvents,
+ and(
+ eq(civicEvents.sourceId, rawRecords.sourceId),
+ eq(civicEvents.sourceKey, rawRecords.sourceKey),
+ eq(civicEvents.tipo, "pagamento_registrado"),
+ ),
+ )
+ .leftJoin(
+ evidenceTable,
+ and(
+ eq(evidenceTable.sourceId, rawRecords.sourceId),
+ eq(evidenceTable.sourceKey, rawRecords.sourceKey),
+ ),
+ )
+ .leftJoin(
+ moneyFlows,
+ and(eq(moneyFlows.sourceId, rawRecords.sourceId), eq(moneyFlows.sourceKey, rawRecords.sourceKey)),
+ )
+ .where(
+ and(
+ eq(rawRecords.sourceId, "tce-sp"),
+ eq(rawRecords.recordType, "despesa"),
+ or(isNull(civicEvents.id), isNull(evidenceTable.id), isNull(moneyFlows.id)),
+ ),
+ );
+ const raws = despesaRows.map((row) => row.raw);
console.log(`[map:tce] ${raws.length} despesas cruas a mapear`);
+ const receitaRows = await db
+ .select({ raw: rawRecords })
+ .from(rawRecords)
+ .leftJoin(
+ civicEvents,
+ and(
+ eq(civicEvents.sourceId, rawRecords.sourceId),
+ eq(civicEvents.sourceKey, rawRecords.sourceKey),
+ eq(civicEvents.tipo, "receita_registrada"),
+ ),
+ )
+ .leftJoin(
+ evidenceTable,
+ and(
+ eq(evidenceTable.sourceId, rawRecords.sourceId),
+ eq(evidenceTable.sourceKey, rawRecords.sourceKey),
+ ),
+ )
+ .leftJoin(
+ moneyFlows,
+ and(eq(moneyFlows.sourceId, rawRecords.sourceId), eq(moneyFlows.sourceKey, rawRecords.sourceKey)),
+ )
+ .where(
+ and(
+ eq(rawRecords.sourceId, "tce-sp"),
+ eq(rawRecords.recordType, "receita"),
+ or(isNull(civicEvents.id), isNull(evidenceTable.id), isNull(moneyFlows.id)),
+ ),
+ );
+ const receitasRaw = receitaRows.map((row) => row.raw);
+ console.log(`[map:tce] ${receitasRaw.length} receitas cruas a mapear`);
+
+ const entidadeCache = new Map();
+ async function upsertEntityCached(args: {
+ kind: "empresa" | "orgao";
+ nome: string;
+ documento?: string | null;
+ }): Promise {
+ const key = entityCacheKey(args.kind, args.nome, args.documento);
+ const cached = entidadeCache.get(key);
+ if (cached) return cached;
+ const id = await upsertEntity(database, args);
+ entidadeCache.set(key, id);
+ return id;
+ }
+
let n = 0;
- for (const raw of raws) {
+ for (const [index, raw] of raws.entries()) {
const d = raw.payload as TceSpDespesa;
+ const credorDocumento = documentoCnpj(d.cpfCnpjCredor);
let credorId: string | null = null;
- if (d.cpfCnpjCredor || d.nomeCredor) {
- credorId = await upsertEntity(db, {
+ if (credorDocumento) {
+ credorId = await upsertEntityCached({
kind: "empresa",
- nome: d.nomeCredor,
- documento: d.cpfCnpjCredor,
+ nome: d.nomeCredor || credorDocumento,
+ documento: credorDocumento,
});
await addEntityReference(db, {
entityId: credorId,
sourceId: "tce-sp",
sourceKey: raw.sourceKey,
- rawNome: d.nomeCredor,
- rawDocumento: d.cpfCnpjCredor,
+ rawNome: d.nomeCredor || credorDocumento,
+ rawDocumento: credorDocumento,
});
}
- const orgaoId = await upsertEntity(db, {
+ const orgaoId = await upsertEntityCached({
kind: "orgao",
nome: d.nomeOrgao || "Município de Americana",
documento: AMERICANA.cnpj,
@@ -68,6 +179,8 @@ export async function mapearTceSp(): Promise {
valorPago: d.valorPago != null ? String(d.valorPago) : null,
municipioIbge: AMERICANA.ibge,
uf: AMERICANA.uf,
+ data: dataBrParaIso(d.dataEmissaoDespesa) ?? competencia(d.exercicio, d.mes),
+ descricao: d.acao ?? d.eventoDespesa ?? null,
sourceUrl: raw.sourceUrl,
publishedAt: raw.publishedAt,
fetchedAt: raw.fetchedAt,
@@ -87,14 +200,217 @@ export async function mapearTceSp(): Promise {
} else {
await db.insert(payments).values(valores);
}
+
+ const maiorValor = Math.max(d.valorPago ?? 0, d.valorLiquidado ?? 0, d.valorEmpenhado ?? 0);
+ const dataCompetencia = competencia(d.exercicio, d.mes);
+ const dataMovimento = dataBrParaIso(d.dataEmissaoDespesa) ?? dataCompetencia;
+ const valorEvento = d.valorDespesa ?? maiorValor;
+ const valorRepresentativo = valorEvento !== 0 ? valorEvento : maiorValor;
+ const civicEventId = await upsertCivicEvent(db, {
+ sourceId: "tce-sp",
+ sourceKey: raw.sourceKey,
+ rawRecordId: raw.id,
+ tipo: "pagamento_registrado",
+ categoria: "pagamento",
+ titulo: d.nomeCredor
+ ? `Despesa registrada para ${d.nomeCredor}`
+ : "Despesa registrada pelo TCE-SP",
+ resumo: [
+ d.nomeOrgao ? `Órgão: ${d.nomeOrgao}.` : null,
+ d.nomeCredor ? `Credor: ${d.nomeCredor}.` : null,
+ valorRepresentativo !== 0 ? `Valor da fase informada: ${formatarValor(valorRepresentativo)}.` : null,
+ d.acao ? `Ação orçamentária: ${d.acao}.` : null,
+ ].filter(Boolean).join(" "),
+ dataEvento: dataCompetencia,
+ valor: valorRepresentativo !== 0 ? String(valorRepresentativo) : null,
+ municipioIbge: AMERICANA.ibge,
+ uf: AMERICANA.uf,
+ territorio: { municipio: AMERICANA.nome, ibge: AMERICANA.ibge, uf: AMERICANA.uf },
+ entidades: {
+ orgaoEntityId: orgaoId,
+ credorEntityId: credorId,
+ credorDocumento,
+ evento: d.eventoDespesa ?? null,
+ },
+ limitacoes: credorId
+ ? null
+ : [{ campo: "credor", mensagem: "A fonte não informou CNPJ completo do credor neste registro; pessoa física comum não vira entidade pública." }],
+ sourceUrl: raw.sourceUrl,
+ publishedAt: raw.publishedAt,
+ fetchedAt: raw.fetchedAt,
+ trustType: "fato_oficial",
+ });
+
+ await upsertEvidence(db, {
+ evidenceKey: `tce-sp:${raw.sourceKey}:despesa`,
+ civicEventId,
+ rawRecordId: raw.id,
+ sourceId: "tce-sp",
+ sourceKey: raw.sourceKey,
+ fieldPath: "$",
+ titulo: "Registro oficial de despesa no TCE-SP",
+ sourceUrl: raw.sourceUrl,
+ trecho: d.acao || d.nomeCredor || null,
+ metodoExtracao: "api-tce-sp",
+ publishedAt: raw.publishedAt,
+ fetchedAt: raw.fetchedAt,
+ trustType: "fato_oficial",
+ });
+
+ const tipoPrincipal = tipoFluxoDespesa(d.eventoDespesa);
+ if (tipoPrincipal && Number.isFinite(valorEvento)) {
+ await upsertMoneyFlow(db, {
+ flowKey: `tce-sp:${raw.sourceKey}:${tipoPrincipal}`,
+ sourceId: "tce-sp",
+ sourceKey: raw.sourceKey,
+ rawRecordId: raw.id,
+ civicEventId,
+ orgaoEntityId: orgaoId,
+ counterpartyEntityId: credorId,
+ tipo: tipoPrincipal,
+ valor: String(valorEvento),
+ dataCompetencia,
+ dataMovimento,
+ exercicio: d.exercicio ?? null,
+ municipioIbge: AMERICANA.ibge,
+ uf: AMERICANA.uf,
+ descricao: d.acao || d.nomeCredor || `Despesa ${raw.sourceKey}`,
+ sourceUrl: raw.sourceUrl,
+ publishedAt: raw.publishedAt,
+ fetchedAt: raw.fetchedAt,
+ trustType: "fato_oficial",
+ });
+ n++;
+ continue;
+ }
+
+ const fases = [
+ ["empenhado", d.valorEmpenhado],
+ ["liquidado", d.valorLiquidado],
+ ["pago", d.valorPago],
+ ] as const;
+ for (const [tipo, valor] of fases) {
+ if (valor == null || !Number.isFinite(valor)) continue;
+ await upsertMoneyFlow(db, {
+ flowKey: `tce-sp:${raw.sourceKey}:${tipo}`,
+ sourceId: "tce-sp",
+ sourceKey: raw.sourceKey,
+ rawRecordId: raw.id,
+ civicEventId,
+ orgaoEntityId: orgaoId,
+ counterpartyEntityId: credorId,
+ tipo,
+ valor: String(valor),
+ dataCompetencia,
+ dataMovimento,
+ exercicio: d.exercicio ?? null,
+ municipioIbge: AMERICANA.ibge,
+ uf: AMERICANA.uf,
+ descricao: d.acao || d.nomeCredor || `Despesa ${raw.sourceKey}`,
+ sourceUrl: raw.sourceUrl,
+ publishedAt: raw.publishedAt,
+ fetchedAt: raw.fetchedAt,
+ trustType: "fato_oficial",
+ });
+ }
n++;
+ if ((index + 1) % 5000 === 0) {
+ console.log(`[map:tce] ${index + 1}/${raws.length} despesas processadas`);
+ }
+ }
+
+ let receitas = 0;
+ for (const [index, raw] of receitasRaw.entries()) {
+ const r = raw.payload as TceSpReceita;
+ const orgaoId = await upsertEntityCached({
+ kind: "orgao",
+ nome: r.orgao || "Município de Americana",
+ documento: AMERICANA.cnpj,
+ });
+
+ const dataCompetencia = competencia(r.exercicio, r.mes);
+ const civicEventId = await upsertCivicEvent(db, {
+ sourceId: "tce-sp",
+ sourceKey: raw.sourceKey,
+ rawRecordId: raw.id,
+ tipo: "receita_registrada",
+ categoria: "receita",
+ titulo: r.especie
+ ? `Receita arrecadada: ${r.especie}`
+ : "Receita arrecadada registrada pelo TCE-SP",
+ resumo: [
+ r.orgao ? `Órgão: ${r.orgao}.` : null,
+ r.categoria ? `Fonte: ${r.categoria}.` : null,
+ Number.isFinite(r.valorArrecadado) ? `Valor arrecadado: ${formatarValor(r.valorArrecadado)}.` : null,
+ ].filter(Boolean).join(" "),
+ dataEvento: dataCompetencia,
+ valor: Number.isFinite(r.valorArrecadado) ? String(r.valorArrecadado) : null,
+ municipioIbge: AMERICANA.ibge,
+ uf: AMERICANA.uf,
+ territorio: { municipio: AMERICANA.nome, ibge: AMERICANA.ibge, uf: AMERICANA.uf },
+ entidades: {
+ orgaoEntityId: orgaoId,
+ fonteRecurso: r.fonteRecurso ?? r.categoria,
+ codigoAplicacao: r.codigoAplicacao ?? r.origem,
+ },
+ sourceUrl: raw.sourceUrl,
+ publishedAt: raw.publishedAt,
+ fetchedAt: raw.fetchedAt,
+ trustType: "fato_oficial",
+ });
+
+ await upsertEvidence(db, {
+ evidenceKey: `tce-sp:${raw.sourceKey}:receita`,
+ civicEventId,
+ rawRecordId: raw.id,
+ sourceId: "tce-sp",
+ sourceKey: raw.sourceKey,
+ fieldPath: "$",
+ titulo: "Registro oficial de receita no TCE-SP",
+ sourceUrl: raw.sourceUrl,
+ trecho: [r.categoria, r.especie, r.rubrica].filter(Boolean).join(" | ") || null,
+ metodoExtracao: "api-tce-sp",
+ publishedAt: raw.publishedAt,
+ fetchedAt: raw.fetchedAt,
+ trustType: "fato_oficial",
+ });
+
+ if (Number.isFinite(r.valorArrecadado)) {
+ await upsertMoneyFlow(db, {
+ flowKey: `tce-sp:${raw.sourceKey}:receita_arrecadada`,
+ sourceId: "tce-sp",
+ sourceKey: raw.sourceKey,
+ rawRecordId: raw.id,
+ civicEventId,
+ orgaoEntityId: orgaoId,
+ tipo: "receita_arrecadada",
+ valor: String(r.valorArrecadado),
+ dataCompetencia,
+ dataMovimento: dataCompetencia,
+ exercicio: r.exercicio ?? null,
+ municipioIbge: AMERICANA.ibge,
+ uf: AMERICANA.uf,
+ descricao: r.especie || r.categoria || `Receita ${raw.sourceKey}`,
+ sourceUrl: raw.sourceUrl,
+ publishedAt: raw.publishedAt,
+ fetchedAt: raw.fetchedAt,
+ trustType: "fato_oficial",
+ });
+ }
+ receitas++;
+ if ((index + 1) % 1000 === 0) {
+ console.log(`[map:tce] ${index + 1}/${receitasRaw.length} receitas processadas`);
+ }
}
- console.log(`[map:tce] ${n} pagamentos no typed-core (payments)`);
+
+ console.log(`[map:tce] ${n} despesas + ${receitas} receitas mapeadas para eventos/evidências/fluxos canônicos`);
}
if (process.argv[1]?.endsWith("tce-sp.ts") || process.argv[1]?.endsWith("tce-sp.js")) {
- mapearTceSp().catch((e) => {
- console.error(e);
- process.exit(1);
- });
+ mapearTceSp()
+ .catch((e) => {
+ console.error(e);
+ process.exitCode = 1;
+ })
+ .finally(() => closeDb());
}
diff --git a/packages/collectors/src/types.ts b/packages/collectors/src/types.ts
index c5384d4..af45145 100644
--- a/packages/collectors/src/types.ts
+++ b/packages/collectors/src/types.ts
@@ -52,15 +52,20 @@ export interface PncpCompra {
export interface PncpContrato {
sequencialContrato: number;
anoContrato: number;
- numeroContrato: string;
+ numeroControlePNCP?: string | null;
+ numeroContrato?: string | null;
+ numeroContratoEmpenho?: string | null;
objetoContrato: string;
valorInicial: number;
valorGlobal: number;
dataVigenciaInicio: string;
dataVigenciaFim: string;
dataAssinatura: string;
+ dataPublicacaoPncp?: string | null;
situacaoContrato: { codigo: number; nome: string };
- fornecedor: {
+ niFornecedor?: string | null;
+ nomeRazaoSocialFornecedor?: string | null;
+ fornecedor?: {
cnpjCpf: string;
nomeRazaoSocial: string;
tipo: string;
@@ -76,30 +81,36 @@ export interface TceSpDespesa {
mes: number;
orgao: string;
nomeOrgao: string;
- funcao: string;
- subfuncao: string;
- programa: string;
- acao: string;
- elemento: string;
- modalidade: string;
- credor: string;
+ funcao?: string | null;
+ subfuncao?: string | null;
+ programa?: string | null;
+ acao?: string | null;
+ elemento?: string | null;
+ modalidade?: string | null;
+ credor?: string | null;
nomeCredor: string;
- cpfCnpjCredor: string;
+ cpfCnpjCredor: string | null;
empenho: string;
valorEmpenhado: number;
valorLiquidado: number;
valorPago: number;
+ eventoDespesa?: string | null;
+ valorDespesa?: number | null;
+ dataEmissaoDespesa?: string | null;
}
export interface TceSpReceita {
exercicio: number;
mes: number;
+ orgao: string;
categoria: string;
origem: string;
especie: string;
rubrica: string;
valorPrevisto: number;
valorArrecadado: number;
+ fonteRecurso?: string | null;
+ codigoAplicacao?: string | null;
}
// --- Querido Diário ---
diff --git a/packages/collectors/src/utils/civic.ts b/packages/collectors/src/utils/civic.ts
new file mode 100644
index 0000000..e307eb8
--- /dev/null
+++ b/packages/collectors/src/utils/civic.ts
@@ -0,0 +1,140 @@
+import { and, eq } from "drizzle-orm";
+import {
+ civicEvents,
+ evidence,
+ entityRelationships,
+ moneyFlows,
+ sourceCoverage,
+ type DB,
+ type NewCivicEvent,
+ type NewEvidence,
+ type NewEntityRelationship,
+ type NewMoneyFlow,
+ type NewSourceCoverage,
+} from "@deolho/db";
+import { AMERICANA } from "../config.js";
+import { getDb } from "./ingest.js";
+
+export type CivicEventInput = Omit;
+export type EvidenceInput = Omit;
+export type MoneyFlowInput = Omit;
+export type EntityRelationshipInput = Omit;
+export type SourceCoverageInput = Omit;
+
+export async function upsertCivicEvent(
+ db: DB,
+ input: CivicEventInput,
+): Promise {
+ const existing = await db
+ .select({ id: civicEvents.id })
+ .from(civicEvents)
+ .where(
+ and(
+ eq(civicEvents.sourceId, input.sourceId),
+ eq(civicEvents.sourceKey, input.sourceKey),
+ eq(civicEvents.tipo, input.tipo),
+ ),
+ )
+ .limit(1);
+
+ if (existing[0]) {
+ await db
+ .update(civicEvents)
+ .set({ ...input, atualizadoEm: new Date() })
+ .where(eq(civicEvents.id, existing[0].id));
+ return existing[0].id;
+ }
+
+ const inserted = await db.insert(civicEvents).values(input).returning({ id: civicEvents.id });
+ if (!inserted[0]) throw new Error("[civic] insert de evento sem retorno");
+ return inserted[0].id;
+}
+
+export async function upsertEvidence(db: DB, input: EvidenceInput): Promise {
+ const existing = await db
+ .select({ id: evidence.id })
+ .from(evidence)
+ .where(eq(evidence.evidenceKey, input.evidenceKey))
+ .limit(1);
+
+ if (existing[0]) return existing[0].id;
+ const inserted = await db.insert(evidence).values(input).returning({ id: evidence.id });
+ if (!inserted[0]) throw new Error("[civic] insert de evidência sem retorno");
+ return inserted[0].id;
+}
+
+export async function upsertMoneyFlow(db: DB, input: MoneyFlowInput): Promise {
+ const existing = await db
+ .select({ id: moneyFlows.id })
+ .from(moneyFlows)
+ .where(eq(moneyFlows.flowKey, input.flowKey))
+ .limit(1);
+
+ if (existing[0]) {
+ await db
+ .update(moneyFlows)
+ .set({ ...input, atualizadoEm: new Date() })
+ .where(eq(moneyFlows.id, existing[0].id));
+ return existing[0].id;
+ }
+
+ const inserted = await db.insert(moneyFlows).values(input).returning({ id: moneyFlows.id });
+ if (!inserted[0]) throw new Error("[civic] insert de fluxo financeiro sem retorno");
+ return inserted[0].id;
+}
+
+export async function upsertEntityRelationship(
+ db: DB,
+ input: EntityRelationshipInput,
+): Promise {
+ const existing = await db
+ .select({ id: entityRelationships.id })
+ .from(entityRelationships)
+ .where(eq(entityRelationships.relationshipKey, input.relationshipKey))
+ .limit(1);
+
+ if (existing[0]) return existing[0].id;
+ const inserted = await db
+ .insert(entityRelationships)
+ .values(input)
+ .returning({ id: entityRelationships.id });
+ if (!inserted[0]) throw new Error("[civic] insert de vínculo sem retorno");
+ return inserted[0].id;
+}
+
+export async function upsertSourceCoverage(db: DB, input: SourceCoverageInput): Promise {
+ const territoryIbge = input.territoryIbge ?? AMERICANA.ibge;
+ const existing = await db
+ .select({ id: sourceCoverage.id })
+ .from(sourceCoverage)
+ .where(
+ and(
+ eq(sourceCoverage.sourceId, input.sourceId),
+ eq(sourceCoverage.collector, input.collector),
+ eq(sourceCoverage.territoryIbge, territoryIbge),
+ eq(sourceCoverage.recordType, input.recordType),
+ ),
+ )
+ .limit(1);
+
+ const row = {
+ ...input,
+ territoryIbge,
+ uf: input.uf ?? AMERICANA.uf,
+ atualizadoEm: new Date(),
+ };
+
+ if (existing[0]) {
+ await db.update(sourceCoverage).set(row).where(eq(sourceCoverage.id, existing[0].id));
+ return;
+ }
+
+ await db.insert(sourceCoverage).values(row);
+}
+
+export async function recordSourceCoverage(input: SourceCoverageInput): Promise {
+ const db = getDb();
+ if (!db) return;
+ await upsertSourceCoverage(db, input);
+}
+
diff --git a/packages/collectors/src/utils/ingest.ts b/packages/collectors/src/utils/ingest.ts
index 17db746..83ff267 100644
--- a/packages/collectors/src/utils/ingest.ts
+++ b/packages/collectors/src/utils/ingest.ts
@@ -31,6 +31,12 @@ export function getDb(): DB | null {
return _db;
}
+export async function closeDb(): Promise {
+ if (!_db) return;
+ await _db.$client.end({ timeout: 5 });
+ _db = undefined;
+}
+
/** sha256 do payload serializado — dedup + detecção de mudança (DATA-04). */
export function contentHash(payload: unknown): string {
return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
@@ -66,7 +72,21 @@ export async function ingestRaw(db: DB, input: IngestInput): Promise {
}
/** Ingestão em lote. Retorna quantos registros foram processados. */
-export async function ingestMany(db: DB, inputs: IngestInput[]): Promise {
- for (const input of inputs) await ingestRaw(db, input);
+export async function ingestMany(db: DB, inputs: IngestInput[], chunkSize = 500): Promise {
+ for (let i = 0; i < inputs.length; i += chunkSize) {
+ const chunk = inputs.slice(i, i + chunkSize).map((input): NewRawRecord => ({
+ sourceId: input.sourceId,
+ sourceKey: input.sourceKey,
+ recordType: input.recordType,
+ payload: input.payload,
+ contentHash: contentHash(input.payload),
+ sourceUrl: input.sourceUrl ?? null,
+ publishedAt: input.publishedAt ?? null,
+ fetchedAt: input.fetchedAt ?? new Date(),
+ }));
+ if (chunk.length > 0) {
+ await db.insert(rawRecords).values(chunk).onConflictDoNothing();
+ }
+ }
return inputs.length;
}
diff --git a/packages/collectors/src/utils/territory.ts b/packages/collectors/src/utils/territory.ts
new file mode 100644
index 0000000..577d07a
--- /dev/null
+++ b/packages/collectors/src/utils/territory.ts
@@ -0,0 +1,63 @@
+export type TerritorioTipo = "logradouro" | "bairro" | "equipamento";
+
+export interface TerritorioMention {
+ tipo: TerritorioTipo;
+ nome: string;
+ contexto: string;
+}
+
+function janela(texto: string, index: number, tamanho = 140): string {
+ const start = Math.max(0, index - 40);
+ const end = Math.min(texto.length, index + tamanho);
+ return texto.slice(start, end).replace(/\s+/g, " ").trim();
+}
+
+function normalizarNome(s: string): string {
+ return s
+ .replace(/\s+/g, " ")
+ .replace(/[;:,.)]+$/g, "")
+ .trim();
+}
+
+function adicionar(
+ out: Map,
+ tipo: TerritorioTipo,
+ nome: string,
+ contexto: string,
+): void {
+ const limpo = normalizarNome(nome);
+ if (limpo.length < 4 || limpo.length > 90) return;
+ const key = `${tipo}:${limpo.toLowerCase()}`;
+ if (!out.has(key)) out.set(key, { tipo, nome: limpo, contexto });
+}
+
+export function extrairTerritorios(texto: string): TerritorioMention[] {
+ const out = new Map();
+ const t = texto.replace(/\s+/g, " ");
+
+ const logradouroRe =
+ /\b(?:Rua|Avenida|Av\.|Travessa|Estrada|Alameda|Pra[çc]a|Viaduto)\s+[A-ZÀ-Ý0-9][^.;\n]{3,80}/g;
+ let m: RegExpExecArray | null;
+ while ((m = logradouroRe.exec(t))) {
+ adicionar(out, "logradouro", m[0], janela(t, m.index));
+ }
+
+ const bairroRe =
+ /\b(?:bairro\s+(?:do\s+|da\s+|de\s+)?|Jardim|Vila|Parque|Residencial|Cidade\s+Jardim|Ant[oô]nio\s+Zanaga)\s+[A-ZÀ-Ý][A-Za-zÀ-ÿ0-9\s-]{2,55}/g;
+ while ((m = bairroRe.exec(t))) {
+ adicionar(out, "bairro", m[0].replace(/^bairro\s+/i, ""), janela(t, m.index));
+ }
+
+ const equipamentoRe =
+ /\b(?:UBS|UPA|EMEF|CIEP|CRAS|CREAS|Escola\s+Municipal|Creche|Hospital|Pronto\s+Socorro|Pra[çc]a|Parque\s+Ecol[oó]gico)\s+[A-ZÀ-Ý][A-Za-zÀ-ÿ0-9\s-]{2,70}/g;
+ while ((m = equipamentoRe.exec(t))) {
+ adicionar(out, "equipamento", m[0], janela(t, m.index));
+ }
+
+ return Array.from(out.values()).slice(0, 12);
+}
+
+export function temSinalZeladoria(texto: string): boolean {
+ return /\b(?:buraco|tapa-?buraco|recape|asfalto|pavimenta[çc][ãa]o|ilumina[çc][ãa]o|poda|sarjeta|cal[çc]ada|drenagem|galeria|limpeza\s+urbana)\b/i.test(texto);
+}
+
diff --git a/packages/db/README.md b/packages/db/README.md
index b89e26c..28d5923 100644
--- a/packages/db/README.md
+++ b/packages/db/README.md
@@ -22,6 +22,11 @@ dia 1** (caríssimos de retrofitar, conforme `.planning/research/FEATURES.md`):
| `entities` / `entity_references` | IDs canônicos + ponte de resolução |
| `contracts` | Unidade do MVP (página viva de contrato) + busca FTS/trgm |
| `contract_events` | Linha do tempo do contrato (CONT-04) |
+| `civic_events` | Unidade pública principal: ato, contrato, pagamento, sanção, limitação |
+| `evidence` | Evidência granular por evento/campo, com trecho, URL e método de extração |
+| `entity_relationships` | Vínculos documentados entre entidades; não usa sobrenome como prova |
+| `money_flows` | Fluxos financeiros normalizados: previsto, contratado, empenhado, pago etc. |
+| `source_coverage` | Estado de cobertura de cada fonte/coletor e suas limitações |
## Uso
@@ -52,5 +57,5 @@ extensão já fica disponível.
## Ainda não modelado (próximas fases, sobre esta fundação)
-`signals` (sinais de atenção), `ai_outputs` (cache de resumos da IA),
-`corrections` (correções da comunidade) e as colunas de embedding pgvector.
+`ai_outputs` (cache de resumos da IA), `corrections` (correções da comunidade),
+jobs `pg-boss` e as colunas de embedding pgvector.
diff --git a/packages/db/migrations/0004_awesome_wallop.sql b/packages/db/migrations/0004_awesome_wallop.sql
new file mode 100644
index 0000000..a47519b
--- /dev/null
+++ b/packages/db/migrations/0004_awesome_wallop.sql
@@ -0,0 +1,162 @@
+CREATE TYPE "public"."civic_event_category" AS ENUM('contratacao', 'pagamento', 'receita', 'obra_zeladoria', 'ato_normativo', 'nomeacao_exoneracao', 'proposta_legislativa', 'indicacao_requerimento', 'sancao', 'audiencia_conselho', 'limitacao_fonte', 'relacionamento');--> statement-breakpoint
+CREATE TYPE "public"."civic_event_type" AS ENUM('licitacao_publicada', 'contrato_publicado', 'contrato_atualizado', 'pagamento_registrado', 'receita_registrada', 'ato_publicado', 'proposta_legislativa', 'indicacao_zeladoria', 'sancao_registrada', 'relacionamento_documentado', 'limitacao_detectada', 'revisao_aplicada');--> statement-breakpoint
+CREATE TYPE "public"."money_flow_type" AS ENUM('previsto', 'contratado', 'empenhado', 'liquidado', 'pago', 'anulado', 'reforcado', 'aditado', 'receita_arrecadada');--> statement-breakpoint
+CREATE TYPE "public"."relationship_type" AS ENUM('socio_oficial', 'administrador_oficial', 'fornecedor', 'orgao_responsavel', 'doacao_eleitoral_oficial', 'sancao_oficial', 'mencao_documentada', 'representante_legal');--> statement-breakpoint
+CREATE TYPE "public"."source_coverage_status" AS ENUM('fresh', 'partial', 'no_data', 'unavailable', 'error', 'pending');--> statement-breakpoint
+CREATE TABLE "civic_events" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "source_id" text NOT NULL,
+ "source_key" text NOT NULL,
+ "raw_record_id" uuid,
+ "tipo" "civic_event_type" NOT NULL,
+ "categoria" "civic_event_category" NOT NULL,
+ "titulo" text NOT NULL,
+ "resumo" text,
+ "data_evento" date,
+ "valor" numeric(18, 2),
+ "moeda" text DEFAULT 'BRL' NOT NULL,
+ "municipio_ibge" text,
+ "uf" text,
+ "territorio" jsonb,
+ "entidades" jsonb,
+ "limitacoes" jsonb,
+ "source_url" text,
+ "published_at" timestamp with time zone,
+ "fetched_at" timestamp with time zone,
+ "trust_type" "trust_type" DEFAULT 'fato_oficial' NOT NULL,
+ "search_document" "tsvector" GENERATED ALWAYS AS (to_tsvector('portuguese', coalesce(titulo, '') || ' ' || coalesce(resumo, ''))) STORED,
+ "criado_em" timestamp with time zone DEFAULT now() NOT NULL,
+ "atualizado_em" timestamp with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "evidence" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "evidence_key" text NOT NULL,
+ "civic_event_id" uuid,
+ "raw_record_id" uuid,
+ "source_id" text NOT NULL,
+ "source_key" text,
+ "field_path" text,
+ "titulo" text NOT NULL,
+ "source_url" text,
+ "trecho" text,
+ "pagina" integer,
+ "posicao_inicio" integer,
+ "posicao_fim" integer,
+ "metodo_extracao" text NOT NULL,
+ "published_at" timestamp with time zone,
+ "fetched_at" timestamp with time zone,
+ "trust_type" "trust_type" DEFAULT 'fato_oficial' NOT NULL,
+ "criado_em" timestamp with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "entity_relationships" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "relationship_key" text NOT NULL,
+ "from_entity_id" uuid NOT NULL,
+ "to_entity_id" uuid NOT NULL,
+ "tipo" "relationship_type" NOT NULL,
+ "source_id" text NOT NULL,
+ "source_key" text,
+ "raw_record_id" uuid,
+ "civic_event_id" uuid,
+ "evidence_id" uuid,
+ "descricao" text,
+ "confianca" numeric(4, 3) DEFAULT '1.000' NOT NULL,
+ "data_inicio" date,
+ "data_fim" date,
+ "metadata" jsonb,
+ "trust_type" "trust_type" DEFAULT 'fato_oficial' NOT NULL,
+ "criado_em" timestamp with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "money_flows" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "flow_key" text NOT NULL,
+ "source_id" text NOT NULL,
+ "source_key" text NOT NULL,
+ "raw_record_id" uuid,
+ "civic_event_id" uuid,
+ "contract_id" uuid,
+ "orgao_entity_id" uuid,
+ "counterparty_entity_id" uuid,
+ "tipo" "money_flow_type" NOT NULL,
+ "valor" numeric(18, 2) NOT NULL,
+ "moeda" text DEFAULT 'BRL' NOT NULL,
+ "data_competencia" date,
+ "data_movimento" date,
+ "exercicio" integer,
+ "municipio_ibge" text,
+ "uf" text,
+ "descricao" text,
+ "source_url" text,
+ "published_at" timestamp with time zone,
+ "fetched_at" timestamp with time zone,
+ "trust_type" "trust_type" DEFAULT 'fato_oficial' NOT NULL,
+ "criado_em" timestamp with time zone DEFAULT now() NOT NULL,
+ "atualizado_em" timestamp with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "source_coverage" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "source_id" text NOT NULL,
+ "collector" text NOT NULL,
+ "territory_ibge" text,
+ "uf" text,
+ "record_type" text NOT NULL,
+ "status" "source_coverage_status" NOT NULL,
+ "coverage_start" date,
+ "coverage_end" date,
+ "last_attempt_at" timestamp with time zone NOT NULL,
+ "last_success_at" timestamp with time zone,
+ "total_records" integer,
+ "error_message" text,
+ "limitations" text,
+ "metadata" jsonb,
+ "atualizado_em" timestamp with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+ALTER TABLE "civic_events" ADD CONSTRAINT "civic_events_source_id_sources_id_fk" FOREIGN KEY ("source_id") REFERENCES "public"."sources"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "civic_events" ADD CONSTRAINT "civic_events_raw_record_id_raw_records_id_fk" FOREIGN KEY ("raw_record_id") REFERENCES "public"."raw_records"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "evidence" ADD CONSTRAINT "evidence_civic_event_id_civic_events_id_fk" FOREIGN KEY ("civic_event_id") REFERENCES "public"."civic_events"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "evidence" ADD CONSTRAINT "evidence_raw_record_id_raw_records_id_fk" FOREIGN KEY ("raw_record_id") REFERENCES "public"."raw_records"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "evidence" ADD CONSTRAINT "evidence_source_id_sources_id_fk" FOREIGN KEY ("source_id") REFERENCES "public"."sources"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "entity_relationships" ADD CONSTRAINT "entity_relationships_from_entity_id_entities_id_fk" FOREIGN KEY ("from_entity_id") REFERENCES "public"."entities"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "entity_relationships" ADD CONSTRAINT "entity_relationships_to_entity_id_entities_id_fk" FOREIGN KEY ("to_entity_id") REFERENCES "public"."entities"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "entity_relationships" ADD CONSTRAINT "entity_relationships_source_id_sources_id_fk" FOREIGN KEY ("source_id") REFERENCES "public"."sources"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "entity_relationships" ADD CONSTRAINT "entity_relationships_raw_record_id_raw_records_id_fk" FOREIGN KEY ("raw_record_id") REFERENCES "public"."raw_records"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "entity_relationships" ADD CONSTRAINT "entity_relationships_civic_event_id_civic_events_id_fk" FOREIGN KEY ("civic_event_id") REFERENCES "public"."civic_events"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "entity_relationships" ADD CONSTRAINT "entity_relationships_evidence_id_evidence_id_fk" FOREIGN KEY ("evidence_id") REFERENCES "public"."evidence"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "money_flows" ADD CONSTRAINT "money_flows_source_id_sources_id_fk" FOREIGN KEY ("source_id") REFERENCES "public"."sources"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "money_flows" ADD CONSTRAINT "money_flows_raw_record_id_raw_records_id_fk" FOREIGN KEY ("raw_record_id") REFERENCES "public"."raw_records"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "money_flows" ADD CONSTRAINT "money_flows_civic_event_id_civic_events_id_fk" FOREIGN KEY ("civic_event_id") REFERENCES "public"."civic_events"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "money_flows" ADD CONSTRAINT "money_flows_contract_id_contracts_id_fk" FOREIGN KEY ("contract_id") REFERENCES "public"."contracts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "money_flows" ADD CONSTRAINT "money_flows_orgao_entity_id_entities_id_fk" FOREIGN KEY ("orgao_entity_id") REFERENCES "public"."entities"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "money_flows" ADD CONSTRAINT "money_flows_counterparty_entity_id_entities_id_fk" FOREIGN KEY ("counterparty_entity_id") REFERENCES "public"."entities"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "source_coverage" ADD CONSTRAINT "source_coverage_source_id_sources_id_fk" FOREIGN KEY ("source_id") REFERENCES "public"."sources"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+CREATE UNIQUE INDEX "civic_events_source_tipo_idx" ON "civic_events" USING btree ("source_id","source_key","tipo");--> statement-breakpoint
+CREATE INDEX "civic_events_category_idx" ON "civic_events" USING btree ("categoria");--> statement-breakpoint
+CREATE INDEX "civic_events_source_idx" ON "civic_events" USING btree ("source_id","source_key");--> statement-breakpoint
+CREATE INDEX "civic_events_municipio_idx" ON "civic_events" USING btree ("municipio_ibge");--> statement-breakpoint
+CREATE INDEX "civic_events_data_idx" ON "civic_events" USING btree ("data_evento");--> statement-breakpoint
+CREATE INDEX "civic_events_search_idx" ON "civic_events" USING gin ("search_document");--> statement-breakpoint
+CREATE INDEX "civic_events_titulo_trgm_idx" ON "civic_events" USING gin ("titulo" gin_trgm_ops);--> statement-breakpoint
+CREATE UNIQUE INDEX "evidence_key_idx" ON "evidence" USING btree ("evidence_key");--> statement-breakpoint
+CREATE INDEX "evidence_event_idx" ON "evidence" USING btree ("civic_event_id");--> statement-breakpoint
+CREATE INDEX "evidence_raw_idx" ON "evidence" USING btree ("raw_record_id");--> statement-breakpoint
+CREATE INDEX "evidence_source_idx" ON "evidence" USING btree ("source_id","source_key");--> statement-breakpoint
+CREATE UNIQUE INDEX "entity_relationships_key_idx" ON "entity_relationships" USING btree ("relationship_key");--> statement-breakpoint
+CREATE INDEX "entity_relationships_from_idx" ON "entity_relationships" USING btree ("from_entity_id");--> statement-breakpoint
+CREATE INDEX "entity_relationships_to_idx" ON "entity_relationships" USING btree ("to_entity_id");--> statement-breakpoint
+CREATE INDEX "entity_relationships_source_idx" ON "entity_relationships" USING btree ("source_id","source_key");--> statement-breakpoint
+CREATE INDEX "entity_relationships_type_idx" ON "entity_relationships" USING btree ("tipo");--> statement-breakpoint
+CREATE UNIQUE INDEX "money_flows_key_idx" ON "money_flows" USING btree ("flow_key");--> statement-breakpoint
+CREATE INDEX "money_flows_source_idx" ON "money_flows" USING btree ("source_id","source_key");--> statement-breakpoint
+CREATE INDEX "money_flows_type_idx" ON "money_flows" USING btree ("tipo");--> statement-breakpoint
+CREATE INDEX "money_flows_contract_idx" ON "money_flows" USING btree ("contract_id");--> statement-breakpoint
+CREATE INDEX "money_flows_orgao_idx" ON "money_flows" USING btree ("orgao_entity_id");--> statement-breakpoint
+CREATE INDEX "money_flows_counterparty_idx" ON "money_flows" USING btree ("counterparty_entity_id");--> statement-breakpoint
+CREATE INDEX "money_flows_municipio_idx" ON "money_flows" USING btree ("municipio_ibge");--> statement-breakpoint
+CREATE UNIQUE INDEX "source_coverage_unique_idx" ON "source_coverage" USING btree ("source_id","collector","territory_ibge","record_type");--> statement-breakpoint
+CREATE INDEX "source_coverage_source_idx" ON "source_coverage" USING btree ("source_id","status");--> statement-breakpoint
+CREATE INDEX "source_coverage_territory_idx" ON "source_coverage" USING btree ("territory_ibge");
\ No newline at end of file
diff --git a/packages/db/migrations/meta/0004_snapshot.json b/packages/db/migrations/meta/0004_snapshot.json
new file mode 100644
index 0000000..07613a1
--- /dev/null
+++ b/packages/db/migrations/meta/0004_snapshot.json
@@ -0,0 +1,3412 @@
+{
+ "id": "4286fded-ffb7-4bfb-ab1e-2b238854b0a1",
+ "prevId": "af4a4d65-37b9-4f4b-abeb-933ac24a404a",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.sources": {
+ "name": "sources",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "nome": {
+ "name": "nome",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "descricao": {
+ "name": "descricao",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "base_url": {
+ "name": "base_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "licenca": {
+ "name": "licenca",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "termos_url": {
+ "name": "termos_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cobertura": {
+ "name": "cobertura",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "limitacoes": {
+ "name": "limitacoes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "default_trust_type": {
+ "name": "default_trust_type",
+ "type": "trust_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'fato_oficial'"
+ },
+ "criado_em": {
+ "name": "criado_em",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.raw_records": {
+ "name": "raw_records",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_key": {
+ "name": "source_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "record_type": {
+ "name": "record_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_url": {
+ "name": "source_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "fetched_at": {
+ "name": "fetched_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_version": {
+ "name": "source_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "criado_em": {
+ "name": "criado_em",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "raw_records_dedup_idx": {
+ "name": "raw_records_dedup_idx",
+ "columns": [
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "content_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "raw_records_history_idx": {
+ "name": "raw_records_history_idx",
+ "columns": [
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "raw_records_type_idx": {
+ "name": "raw_records_type_idx",
+ "columns": [
+ {
+ "expression": "record_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "raw_records_source_id_sources_id_fk": {
+ "name": "raw_records_source_id_sources_id_fk",
+ "tableFrom": "raw_records",
+ "tableTo": "sources",
+ "columnsFrom": [
+ "source_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.entities": {
+ "name": "entities",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "kind": {
+ "name": "kind",
+ "type": "entity_kind",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "nome": {
+ "name": "nome",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "documento": {
+ "name": "documento",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "documento_kind": {
+ "name": "documento_kind",
+ "type": "document_kind",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "uf": {
+ "name": "uf",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "municipio_ibge": {
+ "name": "municipio_ibge",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "criado_em": {
+ "name": "criado_em",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "atualizado_em": {
+ "name": "atualizado_em",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "entities_documento_idx": {
+ "name": "entities_documento_idx",
+ "columns": [
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "documento",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"entities\".\"documento\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "entities_nome_trgm_idx": {
+ "name": "entities_nome_trgm_idx",
+ "columns": [
+ {
+ "expression": "\"nome\" gin_trgm_ops",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.entity_references": {
+ "name": "entity_references",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_key": {
+ "name": "source_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "raw_nome": {
+ "name": "raw_nome",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "raw_documento": {
+ "name": "raw_documento",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "confianca": {
+ "name": "confianca",
+ "type": "numeric(4, 3)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "criado_em": {
+ "name": "criado_em",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "entity_references_entity_idx": {
+ "name": "entity_references_entity_idx",
+ "columns": [
+ {
+ "expression": "entity_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "entity_references_source_idx": {
+ "name": "entity_references_source_idx",
+ "columns": [
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "entity_references_entity_id_entities_id_fk": {
+ "name": "entity_references_entity_id_entities_id_fk",
+ "tableFrom": "entity_references",
+ "tableTo": "entities",
+ "columnsFrom": [
+ "entity_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "entity_references_source_id_sources_id_fk": {
+ "name": "entity_references_source_id_sources_id_fk",
+ "tableFrom": "entity_references",
+ "tableTo": "sources",
+ "columnsFrom": [
+ "source_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.contract_events": {
+ "name": "contract_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "contract_id": {
+ "name": "contract_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tipo": {
+ "name": "tipo",
+ "type": "contract_event_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "data": {
+ "name": "data",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "descricao": {
+ "name": "descricao",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "valor_delta": {
+ "name": "valor_delta",
+ "type": "numeric(18, 2)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "raw_record_id": {
+ "name": "raw_record_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_url": {
+ "name": "source_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "trust_type": {
+ "name": "trust_type",
+ "type": "trust_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'fato_oficial'"
+ },
+ "criado_em": {
+ "name": "criado_em",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "contract_events_contract_idx": {
+ "name": "contract_events_contract_idx",
+ "columns": [
+ {
+ "expression": "contract_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "data",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "contract_events_contract_id_contracts_id_fk": {
+ "name": "contract_events_contract_id_contracts_id_fk",
+ "tableFrom": "contract_events",
+ "tableTo": "contracts",
+ "columnsFrom": [
+ "contract_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "contract_events_raw_record_id_raw_records_id_fk": {
+ "name": "contract_events_raw_record_id_raw_records_id_fk",
+ "tableFrom": "contract_events",
+ "tableTo": "raw_records",
+ "columnsFrom": [
+ "raw_record_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.contracts": {
+ "name": "contracts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_key": {
+ "name": "source_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "raw_record_id": {
+ "name": "raw_record_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "numero": {
+ "name": "numero",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ano": {
+ "name": "ano",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "objeto": {
+ "name": "objeto",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "valor_inicial": {
+ "name": "valor_inicial",
+ "type": "numeric(18, 2)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "valor_global": {
+ "name": "valor_global",
+ "type": "numeric(18, 2)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "moeda": {
+ "name": "moeda",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'BRL'"
+ },
+ "data_assinatura": {
+ "name": "data_assinatura",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "data_vigencia_inicio": {
+ "name": "data_vigencia_inicio",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "data_vigencia_fim": {
+ "name": "data_vigencia_fim",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "situacao": {
+ "name": "situacao",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "orgao_entity_id": {
+ "name": "orgao_entity_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "fornecedor_entity_id": {
+ "name": "fornecedor_entity_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "municipio_ibge": {
+ "name": "municipio_ibge",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "uf": {
+ "name": "uf",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_url": {
+ "name": "source_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "fetched_at": {
+ "name": "fetched_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "trust_type": {
+ "name": "trust_type",
+ "type": "trust_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'fato_oficial'"
+ },
+ "search_document": {
+ "name": "search_document",
+ "type": "tsvector",
+ "primaryKey": false,
+ "notNull": false,
+ "generated": {
+ "as": "to_tsvector('portuguese', coalesce(objeto, '') || ' ' || coalesce(numero, ''))",
+ "type": "stored"
+ }
+ },
+ "criado_em": {
+ "name": "criado_em",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "atualizado_em": {
+ "name": "atualizado_em",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "contracts_source_idx": {
+ "name": "contracts_source_idx",
+ "columns": [
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "contracts_orgao_idx": {
+ "name": "contracts_orgao_idx",
+ "columns": [
+ {
+ "expression": "orgao_entity_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "contracts_fornecedor_idx": {
+ "name": "contracts_fornecedor_idx",
+ "columns": [
+ {
+ "expression": "fornecedor_entity_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "contracts_municipio_idx": {
+ "name": "contracts_municipio_idx",
+ "columns": [
+ {
+ "expression": "municipio_ibge",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "contracts_search_idx": {
+ "name": "contracts_search_idx",
+ "columns": [
+ {
+ "expression": "search_document",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "contracts_objeto_trgm_idx": {
+ "name": "contracts_objeto_trgm_idx",
+ "columns": [
+ {
+ "expression": "\"objeto\" gin_trgm_ops",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "contracts_source_id_sources_id_fk": {
+ "name": "contracts_source_id_sources_id_fk",
+ "tableFrom": "contracts",
+ "tableTo": "sources",
+ "columnsFrom": [
+ "source_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "contracts_raw_record_id_raw_records_id_fk": {
+ "name": "contracts_raw_record_id_raw_records_id_fk",
+ "tableFrom": "contracts",
+ "tableTo": "raw_records",
+ "columnsFrom": [
+ "raw_record_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "contracts_orgao_entity_id_entities_id_fk": {
+ "name": "contracts_orgao_entity_id_entities_id_fk",
+ "tableFrom": "contracts",
+ "tableTo": "entities",
+ "columnsFrom": [
+ "orgao_entity_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "contracts_fornecedor_entity_id_entities_id_fk": {
+ "name": "contracts_fornecedor_entity_id_entities_id_fk",
+ "tableFrom": "contracts",
+ "tableTo": "entities",
+ "columnsFrom": [
+ "fornecedor_entity_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.payments": {
+ "name": "payments",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_key": {
+ "name": "source_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "raw_record_id": {
+ "name": "raw_record_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "credor_entity_id": {
+ "name": "credor_entity_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "orgao_entity_id": {
+ "name": "orgao_entity_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "contract_id": {
+ "name": "contract_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "numero_empenho": {
+ "name": "numero_empenho",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "exercicio": {
+ "name": "exercicio",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "valor_empenhado": {
+ "name": "valor_empenhado",
+ "type": "numeric(18, 2)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "valor_liquidado": {
+ "name": "valor_liquidado",
+ "type": "numeric(18, 2)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "valor_pago": {
+ "name": "valor_pago",
+ "type": "numeric(18, 2)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "moeda": {
+ "name": "moeda",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'BRL'"
+ },
+ "data": {
+ "name": "data",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "descricao": {
+ "name": "descricao",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "municipio_ibge": {
+ "name": "municipio_ibge",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "uf": {
+ "name": "uf",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_url": {
+ "name": "source_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "fetched_at": {
+ "name": "fetched_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "trust_type": {
+ "name": "trust_type",
+ "type": "trust_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'fato_oficial'"
+ },
+ "criado_em": {
+ "name": "criado_em",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "atualizado_em": {
+ "name": "atualizado_em",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "payments_source_idx": {
+ "name": "payments_source_idx",
+ "columns": [
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payments_credor_idx": {
+ "name": "payments_credor_idx",
+ "columns": [
+ {
+ "expression": "credor_entity_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payments_orgao_idx": {
+ "name": "payments_orgao_idx",
+ "columns": [
+ {
+ "expression": "orgao_entity_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payments_contract_idx": {
+ "name": "payments_contract_idx",
+ "columns": [
+ {
+ "expression": "contract_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payments_municipio_idx": {
+ "name": "payments_municipio_idx",
+ "columns": [
+ {
+ "expression": "municipio_ibge",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "payments_source_id_sources_id_fk": {
+ "name": "payments_source_id_sources_id_fk",
+ "tableFrom": "payments",
+ "tableTo": "sources",
+ "columnsFrom": [
+ "source_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "payments_raw_record_id_raw_records_id_fk": {
+ "name": "payments_raw_record_id_raw_records_id_fk",
+ "tableFrom": "payments",
+ "tableTo": "raw_records",
+ "columnsFrom": [
+ "raw_record_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "payments_credor_entity_id_entities_id_fk": {
+ "name": "payments_credor_entity_id_entities_id_fk",
+ "tableFrom": "payments",
+ "tableTo": "entities",
+ "columnsFrom": [
+ "credor_entity_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "payments_orgao_entity_id_entities_id_fk": {
+ "name": "payments_orgao_entity_id_entities_id_fk",
+ "tableFrom": "payments",
+ "tableTo": "entities",
+ "columnsFrom": [
+ "orgao_entity_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "payments_contract_id_contracts_id_fk": {
+ "name": "payments_contract_id_contracts_id_fk",
+ "tableFrom": "payments",
+ "tableTo": "contracts",
+ "columnsFrom": [
+ "contract_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.gazette_mentions": {
+ "name": "gazette_mentions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "gazette_id": {
+ "name": "gazette_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "contract_id": {
+ "name": "contract_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "trecho": {
+ "name": "trecho",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "raw_documento": {
+ "name": "raw_documento",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "raw_numero_contrato": {
+ "name": "raw_numero_contrato",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "confianca": {
+ "name": "confianca",
+ "type": "numeric(4, 3)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "criado_em": {
+ "name": "criado_em",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "gazette_mentions_gazette_idx": {
+ "name": "gazette_mentions_gazette_idx",
+ "columns": [
+ {
+ "expression": "gazette_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "gazette_mentions_entity_idx": {
+ "name": "gazette_mentions_entity_idx",
+ "columns": [
+ {
+ "expression": "entity_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "gazette_mentions_contract_idx": {
+ "name": "gazette_mentions_contract_idx",
+ "columns": [
+ {
+ "expression": "contract_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "gazette_mentions_gazette_id_gazettes_id_fk": {
+ "name": "gazette_mentions_gazette_id_gazettes_id_fk",
+ "tableFrom": "gazette_mentions",
+ "tableTo": "gazettes",
+ "columnsFrom": [
+ "gazette_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "gazette_mentions_entity_id_entities_id_fk": {
+ "name": "gazette_mentions_entity_id_entities_id_fk",
+ "tableFrom": "gazette_mentions",
+ "tableTo": "entities",
+ "columnsFrom": [
+ "entity_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "gazette_mentions_contract_id_contracts_id_fk": {
+ "name": "gazette_mentions_contract_id_contracts_id_fk",
+ "tableFrom": "gazette_mentions",
+ "tableTo": "contracts",
+ "columnsFrom": [
+ "contract_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.gazettes": {
+ "name": "gazettes",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_key": {
+ "name": "source_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "raw_record_id": {
+ "name": "raw_record_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "territory_ibge": {
+ "name": "territory_ibge",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "uf": {
+ "name": "uf",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "date": {
+ "name": "date",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "edition": {
+ "name": "edition",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_extra_edition": {
+ "name": "is_extra_edition",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "txt_url": {
+ "name": "txt_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_url": {
+ "name": "source_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "fetched_at": {
+ "name": "fetched_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "trust_type": {
+ "name": "trust_type",
+ "type": "trust_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'fato_oficial'"
+ },
+ "criado_em": {
+ "name": "criado_em",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "gazettes_source_key_idx": {
+ "name": "gazettes_source_key_idx",
+ "columns": [
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "gazettes_date_idx": {
+ "name": "gazettes_date_idx",
+ "columns": [
+ {
+ "expression": "territory_ibge",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "date",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "gazettes_source_id_sources_id_fk": {
+ "name": "gazettes_source_id_sources_id_fk",
+ "tableFrom": "gazettes",
+ "tableTo": "sources",
+ "columnsFrom": [
+ "source_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "gazettes_raw_record_id_raw_records_id_fk": {
+ "name": "gazettes_raw_record_id_raw_records_id_fk",
+ "tableFrom": "gazettes",
+ "tableTo": "raw_records",
+ "columnsFrom": [
+ "raw_record_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.company_partners": {
+ "name": "company_partners",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "raw_record_id": {
+ "name": "raw_record_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "nome_socio": {
+ "name": "nome_socio",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "documento_socio": {
+ "name": "documento_socio",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "qualificacao": {
+ "name": "qualificacao",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "faixa_etaria": {
+ "name": "faixa_etaria",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "data_entrada": {
+ "name": "data_entrada",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "criado_em": {
+ "name": "criado_em",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "company_partners_entity_idx": {
+ "name": "company_partners_entity_idx",
+ "columns": [
+ {
+ "expression": "entity_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_partners_nome_idx": {
+ "name": "company_partners_nome_idx",
+ "columns": [
+ {
+ "expression": "nome_socio",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "company_partners_entity_id_entities_id_fk": {
+ "name": "company_partners_entity_id_entities_id_fk",
+ "tableFrom": "company_partners",
+ "tableTo": "entities",
+ "columnsFrom": [
+ "entity_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "company_partners_source_id_sources_id_fk": {
+ "name": "company_partners_source_id_sources_id_fk",
+ "tableFrom": "company_partners",
+ "tableTo": "sources",
+ "columnsFrom": [
+ "source_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "company_partners_raw_record_id_raw_records_id_fk": {
+ "name": "company_partners_raw_record_id_raw_records_id_fk",
+ "tableFrom": "company_partners",
+ "tableTo": "raw_records",
+ "columnsFrom": [
+ "raw_record_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sanctions": {
+ "name": "sanctions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "raw_record_id": {
+ "name": "raw_record_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cadastro": {
+ "name": "cadastro",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "documento": {
+ "name": "documento",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "nome_sancionado": {
+ "name": "nome_sancionado",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tipo_sancao": {
+ "name": "tipo_sancao",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "orgao_sancionador": {
+ "name": "orgao_sancionador",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "data_inicio": {
+ "name": "data_inicio",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "data_fim": {
+ "name": "data_fim",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "fundamentacao": {
+ "name": "fundamentacao",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_url": {
+ "name": "source_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "fetched_at": {
+ "name": "fetched_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "trust_type": {
+ "name": "trust_type",
+ "type": "trust_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'fato_oficial'"
+ },
+ "criado_em": {
+ "name": "criado_em",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "sanctions_entity_idx": {
+ "name": "sanctions_entity_idx",
+ "columns": [
+ {
+ "expression": "entity_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "sanctions_documento_idx": {
+ "name": "sanctions_documento_idx",
+ "columns": [
+ {
+ "expression": "documento",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "sanctions_entity_id_entities_id_fk": {
+ "name": "sanctions_entity_id_entities_id_fk",
+ "tableFrom": "sanctions",
+ "tableTo": "entities",
+ "columnsFrom": [
+ "entity_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "sanctions_source_id_sources_id_fk": {
+ "name": "sanctions_source_id_sources_id_fk",
+ "tableFrom": "sanctions",
+ "tableTo": "sources",
+ "columnsFrom": [
+ "source_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "sanctions_raw_record_id_raw_records_id_fk": {
+ "name": "sanctions_raw_record_id_raw_records_id_fk",
+ "tableFrom": "sanctions",
+ "tableTo": "raw_records",
+ "columnsFrom": [
+ "raw_record_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.civic_events": {
+ "name": "civic_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_key": {
+ "name": "source_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "raw_record_id": {
+ "name": "raw_record_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tipo": {
+ "name": "tipo",
+ "type": "civic_event_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "categoria": {
+ "name": "categoria",
+ "type": "civic_event_category",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "titulo": {
+ "name": "titulo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "resumo": {
+ "name": "resumo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "data_evento": {
+ "name": "data_evento",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "valor": {
+ "name": "valor",
+ "type": "numeric(18, 2)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "moeda": {
+ "name": "moeda",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'BRL'"
+ },
+ "municipio_ibge": {
+ "name": "municipio_ibge",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "uf": {
+ "name": "uf",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "territorio": {
+ "name": "territorio",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "entidades": {
+ "name": "entidades",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "limitacoes": {
+ "name": "limitacoes",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_url": {
+ "name": "source_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "fetched_at": {
+ "name": "fetched_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "trust_type": {
+ "name": "trust_type",
+ "type": "trust_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'fato_oficial'"
+ },
+ "search_document": {
+ "name": "search_document",
+ "type": "tsvector",
+ "primaryKey": false,
+ "notNull": false,
+ "generated": {
+ "as": "to_tsvector('portuguese', coalesce(titulo, '') || ' ' || coalesce(resumo, ''))",
+ "type": "stored"
+ }
+ },
+ "criado_em": {
+ "name": "criado_em",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "atualizado_em": {
+ "name": "atualizado_em",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "civic_events_source_tipo_idx": {
+ "name": "civic_events_source_tipo_idx",
+ "columns": [
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "tipo",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "civic_events_category_idx": {
+ "name": "civic_events_category_idx",
+ "columns": [
+ {
+ "expression": "categoria",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "civic_events_source_idx": {
+ "name": "civic_events_source_idx",
+ "columns": [
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "civic_events_municipio_idx": {
+ "name": "civic_events_municipio_idx",
+ "columns": [
+ {
+ "expression": "municipio_ibge",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "civic_events_data_idx": {
+ "name": "civic_events_data_idx",
+ "columns": [
+ {
+ "expression": "data_evento",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "civic_events_search_idx": {
+ "name": "civic_events_search_idx",
+ "columns": [
+ {
+ "expression": "search_document",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "civic_events_titulo_trgm_idx": {
+ "name": "civic_events_titulo_trgm_idx",
+ "columns": [
+ {
+ "expression": "\"titulo\" gin_trgm_ops",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "civic_events_source_id_sources_id_fk": {
+ "name": "civic_events_source_id_sources_id_fk",
+ "tableFrom": "civic_events",
+ "tableTo": "sources",
+ "columnsFrom": [
+ "source_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "civic_events_raw_record_id_raw_records_id_fk": {
+ "name": "civic_events_raw_record_id_raw_records_id_fk",
+ "tableFrom": "civic_events",
+ "tableTo": "raw_records",
+ "columnsFrom": [
+ "raw_record_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.evidence": {
+ "name": "evidence",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "evidence_key": {
+ "name": "evidence_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "civic_event_id": {
+ "name": "civic_event_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "raw_record_id": {
+ "name": "raw_record_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_key": {
+ "name": "source_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "field_path": {
+ "name": "field_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "titulo": {
+ "name": "titulo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_url": {
+ "name": "source_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "trecho": {
+ "name": "trecho",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "pagina": {
+ "name": "pagina",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "posicao_inicio": {
+ "name": "posicao_inicio",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "posicao_fim": {
+ "name": "posicao_fim",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metodo_extracao": {
+ "name": "metodo_extracao",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "fetched_at": {
+ "name": "fetched_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "trust_type": {
+ "name": "trust_type",
+ "type": "trust_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'fato_oficial'"
+ },
+ "criado_em": {
+ "name": "criado_em",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "evidence_key_idx": {
+ "name": "evidence_key_idx",
+ "columns": [
+ {
+ "expression": "evidence_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "evidence_event_idx": {
+ "name": "evidence_event_idx",
+ "columns": [
+ {
+ "expression": "civic_event_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "evidence_raw_idx": {
+ "name": "evidence_raw_idx",
+ "columns": [
+ {
+ "expression": "raw_record_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "evidence_source_idx": {
+ "name": "evidence_source_idx",
+ "columns": [
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "evidence_civic_event_id_civic_events_id_fk": {
+ "name": "evidence_civic_event_id_civic_events_id_fk",
+ "tableFrom": "evidence",
+ "tableTo": "civic_events",
+ "columnsFrom": [
+ "civic_event_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "evidence_raw_record_id_raw_records_id_fk": {
+ "name": "evidence_raw_record_id_raw_records_id_fk",
+ "tableFrom": "evidence",
+ "tableTo": "raw_records",
+ "columnsFrom": [
+ "raw_record_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "evidence_source_id_sources_id_fk": {
+ "name": "evidence_source_id_sources_id_fk",
+ "tableFrom": "evidence",
+ "tableTo": "sources",
+ "columnsFrom": [
+ "source_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.entity_relationships": {
+ "name": "entity_relationships",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "relationship_key": {
+ "name": "relationship_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "from_entity_id": {
+ "name": "from_entity_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "to_entity_id": {
+ "name": "to_entity_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tipo": {
+ "name": "tipo",
+ "type": "relationship_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_key": {
+ "name": "source_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "raw_record_id": {
+ "name": "raw_record_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "civic_event_id": {
+ "name": "civic_event_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "evidence_id": {
+ "name": "evidence_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "descricao": {
+ "name": "descricao",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "confianca": {
+ "name": "confianca",
+ "type": "numeric(4, 3)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'1.000'"
+ },
+ "data_inicio": {
+ "name": "data_inicio",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "data_fim": {
+ "name": "data_fim",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "trust_type": {
+ "name": "trust_type",
+ "type": "trust_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'fato_oficial'"
+ },
+ "criado_em": {
+ "name": "criado_em",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "entity_relationships_key_idx": {
+ "name": "entity_relationships_key_idx",
+ "columns": [
+ {
+ "expression": "relationship_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "entity_relationships_from_idx": {
+ "name": "entity_relationships_from_idx",
+ "columns": [
+ {
+ "expression": "from_entity_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "entity_relationships_to_idx": {
+ "name": "entity_relationships_to_idx",
+ "columns": [
+ {
+ "expression": "to_entity_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "entity_relationships_source_idx": {
+ "name": "entity_relationships_source_idx",
+ "columns": [
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "entity_relationships_type_idx": {
+ "name": "entity_relationships_type_idx",
+ "columns": [
+ {
+ "expression": "tipo",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "entity_relationships_from_entity_id_entities_id_fk": {
+ "name": "entity_relationships_from_entity_id_entities_id_fk",
+ "tableFrom": "entity_relationships",
+ "tableTo": "entities",
+ "columnsFrom": [
+ "from_entity_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "entity_relationships_to_entity_id_entities_id_fk": {
+ "name": "entity_relationships_to_entity_id_entities_id_fk",
+ "tableFrom": "entity_relationships",
+ "tableTo": "entities",
+ "columnsFrom": [
+ "to_entity_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "entity_relationships_source_id_sources_id_fk": {
+ "name": "entity_relationships_source_id_sources_id_fk",
+ "tableFrom": "entity_relationships",
+ "tableTo": "sources",
+ "columnsFrom": [
+ "source_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "entity_relationships_raw_record_id_raw_records_id_fk": {
+ "name": "entity_relationships_raw_record_id_raw_records_id_fk",
+ "tableFrom": "entity_relationships",
+ "tableTo": "raw_records",
+ "columnsFrom": [
+ "raw_record_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "entity_relationships_civic_event_id_civic_events_id_fk": {
+ "name": "entity_relationships_civic_event_id_civic_events_id_fk",
+ "tableFrom": "entity_relationships",
+ "tableTo": "civic_events",
+ "columnsFrom": [
+ "civic_event_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "entity_relationships_evidence_id_evidence_id_fk": {
+ "name": "entity_relationships_evidence_id_evidence_id_fk",
+ "tableFrom": "entity_relationships",
+ "tableTo": "evidence",
+ "columnsFrom": [
+ "evidence_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.money_flows": {
+ "name": "money_flows",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "flow_key": {
+ "name": "flow_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_key": {
+ "name": "source_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "raw_record_id": {
+ "name": "raw_record_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "civic_event_id": {
+ "name": "civic_event_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "contract_id": {
+ "name": "contract_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "orgao_entity_id": {
+ "name": "orgao_entity_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "counterparty_entity_id": {
+ "name": "counterparty_entity_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tipo": {
+ "name": "tipo",
+ "type": "money_flow_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "valor": {
+ "name": "valor",
+ "type": "numeric(18, 2)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "moeda": {
+ "name": "moeda",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'BRL'"
+ },
+ "data_competencia": {
+ "name": "data_competencia",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "data_movimento": {
+ "name": "data_movimento",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "exercicio": {
+ "name": "exercicio",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "municipio_ibge": {
+ "name": "municipio_ibge",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "uf": {
+ "name": "uf",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "descricao": {
+ "name": "descricao",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_url": {
+ "name": "source_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "fetched_at": {
+ "name": "fetched_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "trust_type": {
+ "name": "trust_type",
+ "type": "trust_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'fato_oficial'"
+ },
+ "criado_em": {
+ "name": "criado_em",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "atualizado_em": {
+ "name": "atualizado_em",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "money_flows_key_idx": {
+ "name": "money_flows_key_idx",
+ "columns": [
+ {
+ "expression": "flow_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "money_flows_source_idx": {
+ "name": "money_flows_source_idx",
+ "columns": [
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "money_flows_type_idx": {
+ "name": "money_flows_type_idx",
+ "columns": [
+ {
+ "expression": "tipo",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "money_flows_contract_idx": {
+ "name": "money_flows_contract_idx",
+ "columns": [
+ {
+ "expression": "contract_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "money_flows_orgao_idx": {
+ "name": "money_flows_orgao_idx",
+ "columns": [
+ {
+ "expression": "orgao_entity_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "money_flows_counterparty_idx": {
+ "name": "money_flows_counterparty_idx",
+ "columns": [
+ {
+ "expression": "counterparty_entity_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "money_flows_municipio_idx": {
+ "name": "money_flows_municipio_idx",
+ "columns": [
+ {
+ "expression": "municipio_ibge",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "money_flows_source_id_sources_id_fk": {
+ "name": "money_flows_source_id_sources_id_fk",
+ "tableFrom": "money_flows",
+ "tableTo": "sources",
+ "columnsFrom": [
+ "source_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "money_flows_raw_record_id_raw_records_id_fk": {
+ "name": "money_flows_raw_record_id_raw_records_id_fk",
+ "tableFrom": "money_flows",
+ "tableTo": "raw_records",
+ "columnsFrom": [
+ "raw_record_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "money_flows_civic_event_id_civic_events_id_fk": {
+ "name": "money_flows_civic_event_id_civic_events_id_fk",
+ "tableFrom": "money_flows",
+ "tableTo": "civic_events",
+ "columnsFrom": [
+ "civic_event_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "money_flows_contract_id_contracts_id_fk": {
+ "name": "money_flows_contract_id_contracts_id_fk",
+ "tableFrom": "money_flows",
+ "tableTo": "contracts",
+ "columnsFrom": [
+ "contract_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "money_flows_orgao_entity_id_entities_id_fk": {
+ "name": "money_flows_orgao_entity_id_entities_id_fk",
+ "tableFrom": "money_flows",
+ "tableTo": "entities",
+ "columnsFrom": [
+ "orgao_entity_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "money_flows_counterparty_entity_id_entities_id_fk": {
+ "name": "money_flows_counterparty_entity_id_entities_id_fk",
+ "tableFrom": "money_flows",
+ "tableTo": "entities",
+ "columnsFrom": [
+ "counterparty_entity_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.source_coverage": {
+ "name": "source_coverage",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "collector": {
+ "name": "collector",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "territory_ibge": {
+ "name": "territory_ibge",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "uf": {
+ "name": "uf",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "record_type": {
+ "name": "record_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "source_coverage_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "coverage_start": {
+ "name": "coverage_start",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "coverage_end": {
+ "name": "coverage_end",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_attempt_at": {
+ "name": "last_attempt_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_success_at": {
+ "name": "last_success_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "total_records": {
+ "name": "total_records",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "limitations": {
+ "name": "limitations",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "atualizado_em": {
+ "name": "atualizado_em",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "source_coverage_unique_idx": {
+ "name": "source_coverage_unique_idx",
+ "columns": [
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "collector",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "territory_ibge",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "record_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "source_coverage_source_idx": {
+ "name": "source_coverage_source_idx",
+ "columns": [
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "source_coverage_territory_idx": {
+ "name": "source_coverage_territory_idx",
+ "columns": [
+ {
+ "expression": "territory_ibge",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "source_coverage_source_id_sources_id_fk": {
+ "name": "source_coverage_source_id_sources_id_fk",
+ "tableFrom": "source_coverage",
+ "tableTo": "sources",
+ "columnsFrom": [
+ "source_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.civic_event_category": {
+ "name": "civic_event_category",
+ "schema": "public",
+ "values": [
+ "contratacao",
+ "pagamento",
+ "receita",
+ "obra_zeladoria",
+ "ato_normativo",
+ "nomeacao_exoneracao",
+ "proposta_legislativa",
+ "indicacao_requerimento",
+ "sancao",
+ "audiencia_conselho",
+ "limitacao_fonte",
+ "relacionamento"
+ ]
+ },
+ "public.civic_event_type": {
+ "name": "civic_event_type",
+ "schema": "public",
+ "values": [
+ "licitacao_publicada",
+ "contrato_publicado",
+ "contrato_atualizado",
+ "pagamento_registrado",
+ "receita_registrada",
+ "ato_publicado",
+ "proposta_legislativa",
+ "indicacao_zeladoria",
+ "sancao_registrada",
+ "relacionamento_documentado",
+ "limitacao_detectada",
+ "revisao_aplicada"
+ ]
+ },
+ "public.contract_event_type": {
+ "name": "contract_event_type",
+ "schema": "public",
+ "values": [
+ "publicacao",
+ "assinatura",
+ "aditivo",
+ "pagamento",
+ "alteracao",
+ "rescisao"
+ ]
+ },
+ "public.document_kind": {
+ "name": "document_kind",
+ "schema": "public",
+ "values": [
+ "cnpj",
+ "cpf"
+ ]
+ },
+ "public.entity_kind": {
+ "name": "entity_kind",
+ "schema": "public",
+ "values": [
+ "empresa",
+ "orgao",
+ "unidade_orgao",
+ "pessoa_publica"
+ ]
+ },
+ "public.money_flow_type": {
+ "name": "money_flow_type",
+ "schema": "public",
+ "values": [
+ "previsto",
+ "contratado",
+ "empenhado",
+ "liquidado",
+ "pago",
+ "anulado",
+ "reforcado",
+ "aditado",
+ "receita_arrecadada"
+ ]
+ },
+ "public.relationship_type": {
+ "name": "relationship_type",
+ "schema": "public",
+ "values": [
+ "socio_oficial",
+ "administrador_oficial",
+ "fornecedor",
+ "orgao_responsavel",
+ "doacao_eleitoral_oficial",
+ "sancao_oficial",
+ "mencao_documentada",
+ "representante_legal"
+ ]
+ },
+ "public.source_coverage_status": {
+ "name": "source_coverage_status",
+ "schema": "public",
+ "values": [
+ "fresh",
+ "partial",
+ "no_data",
+ "unavailable",
+ "error",
+ "pending"
+ ]
+ },
+ "public.trust_type": {
+ "name": "trust_type",
+ "schema": "public",
+ "values": [
+ "fato_oficial",
+ "explicacao",
+ "sinal_atencao",
+ "noticia",
+ "opiniao"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json
index 0a0454d..90ada69 100644
--- a/packages/db/migrations/meta/_journal.json
+++ b/packages/db/migrations/meta/_journal.json
@@ -29,6 +29,13 @@
"when": 1779749964200,
"tag": "0003_short_sentinel",
"breakpoints": true
+ },
+ {
+ "idx": 4,
+ "version": "7",
+ "when": 1780115468621,
+ "tag": "0004_awesome_wallop",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts
index 6be0794..9173b41 100644
--- a/packages/db/src/client.ts
+++ b/packages/db/src/client.ts
@@ -6,7 +6,7 @@ import * as schema from "./schema/index";
// própria instância a partir da sua DATABASE_URL — sem singleton global.
export function createDb(connectionString: string, options?: { max?: number }) {
const client = postgres(connectionString, { max: options?.max ?? 10 });
- return drizzle(client, { schema });
+ return Object.assign(drizzle(client, { schema }), { $client: client });
}
export type DB = ReturnType;
diff --git a/packages/db/src/schema/_enums.ts b/packages/db/src/schema/_enums.ts
index 42a961f..089d306 100644
--- a/packages/db/src/schema/_enums.ts
+++ b/packages/db/src/schema/_enums.ts
@@ -32,3 +32,65 @@ export const contractEventType = pgEnum("contract_event_type", [
"alteracao",
"rescisao",
]);
+
+export const civicEventType = pgEnum("civic_event_type", [
+ "licitacao_publicada",
+ "contrato_publicado",
+ "contrato_atualizado",
+ "pagamento_registrado",
+ "receita_registrada",
+ "ato_publicado",
+ "proposta_legislativa",
+ "indicacao_zeladoria",
+ "sancao_registrada",
+ "relacionamento_documentado",
+ "limitacao_detectada",
+ "revisao_aplicada",
+]);
+
+export const civicEventCategory = pgEnum("civic_event_category", [
+ "contratacao",
+ "pagamento",
+ "receita",
+ "obra_zeladoria",
+ "ato_normativo",
+ "nomeacao_exoneracao",
+ "proposta_legislativa",
+ "indicacao_requerimento",
+ "sancao",
+ "audiencia_conselho",
+ "limitacao_fonte",
+ "relacionamento",
+]);
+
+export const relationshipType = pgEnum("relationship_type", [
+ "socio_oficial",
+ "administrador_oficial",
+ "fornecedor",
+ "orgao_responsavel",
+ "doacao_eleitoral_oficial",
+ "sancao_oficial",
+ "mencao_documentada",
+ "representante_legal",
+]);
+
+export const moneyFlowType = pgEnum("money_flow_type", [
+ "previsto",
+ "contratado",
+ "empenhado",
+ "liquidado",
+ "pago",
+ "anulado",
+ "reforcado",
+ "aditado",
+ "receita_arrecadada",
+]);
+
+export const sourceCoverageStatus = pgEnum("source_coverage_status", [
+ "fresh",
+ "partial",
+ "no_data",
+ "unavailable",
+ "error",
+ "pending",
+]);
diff --git a/packages/db/src/schema/civic-events.ts b/packages/db/src/schema/civic-events.ts
new file mode 100644
index 0000000..80c322e
--- /dev/null
+++ b/packages/db/src/schema/civic-events.ts
@@ -0,0 +1,70 @@
+import {
+ pgTable,
+ uuid,
+ text,
+ numeric,
+ date,
+ timestamp,
+ jsonb,
+ customType,
+ index,
+ uniqueIndex,
+} from "drizzle-orm/pg-core";
+import { sql } from "drizzle-orm";
+import { civicEventCategory, civicEventType, trustType } from "./_enums";
+import { sources } from "./sources";
+import { rawRecords } from "./raw-records";
+
+const tsvector = customType<{ data: string }>({
+ dataType() {
+ return "tsvector";
+ },
+});
+
+// Unidade pública principal do produto: um acontecimento verificável da cidade.
+// Pode nascer de Diário, PNCP, TCE-SP, Câmara, CGU, TSE etc. Fatos ficam aqui;
+// explicações e sinais usam `trust_type` e evidências próprias.
+export const civicEvents = pgTable(
+ "civic_events",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ sourceId: text("source_id")
+ .notNull()
+ .references(() => sources.id),
+ sourceKey: text("source_key").notNull(),
+ rawRecordId: uuid("raw_record_id").references(() => rawRecords.id),
+ tipo: civicEventType("tipo").notNull(),
+ categoria: civicEventCategory("categoria").notNull(),
+ titulo: text("titulo").notNull(),
+ resumo: text("resumo"),
+ dataEvento: date("data_evento"),
+ valor: numeric("valor", { precision: 18, scale: 2 }),
+ moeda: text("moeda").notNull().default("BRL"),
+ municipioIbge: text("municipio_ibge"),
+ uf: text("uf"),
+ territorio: jsonb("territorio"),
+ entidades: jsonb("entidades"),
+ limitacoes: jsonb("limitacoes"),
+ sourceUrl: text("source_url"),
+ publishedAt: timestamp("published_at", { withTimezone: true }),
+ fetchedAt: timestamp("fetched_at", { withTimezone: true }),
+ trustType: trustType("trust_type").notNull().default("fato_oficial"),
+ searchDocument: tsvector("search_document").generatedAlwaysAs(
+ sql`to_tsvector('portuguese', coalesce(titulo, '') || ' ' || coalesce(resumo, ''))`,
+ ),
+ criadoEm: timestamp("criado_em", { withTimezone: true }).notNull().defaultNow(),
+ atualizadoEm: timestamp("atualizado_em", { withTimezone: true }).notNull().defaultNow(),
+ },
+ (t) => [
+ uniqueIndex("civic_events_source_tipo_idx").on(t.sourceId, t.sourceKey, t.tipo),
+ index("civic_events_category_idx").on(t.categoria),
+ index("civic_events_source_idx").on(t.sourceId, t.sourceKey),
+ index("civic_events_municipio_idx").on(t.municipioIbge),
+ index("civic_events_data_idx").on(t.dataEvento),
+ index("civic_events_search_idx").using("gin", t.searchDocument),
+ index("civic_events_titulo_trgm_idx").using("gin", sql`${t.titulo} gin_trgm_ops`),
+ ],
+);
+
+export type CivicEvent = typeof civicEvents.$inferSelect;
+export type NewCivicEvent = typeof civicEvents.$inferInsert;
diff --git a/packages/db/src/schema/entity-relationships.ts b/packages/db/src/schema/entity-relationships.ts
new file mode 100644
index 0000000..95688ff
--- /dev/null
+++ b/packages/db/src/schema/entity-relationships.ts
@@ -0,0 +1,58 @@
+import {
+ pgTable,
+ uuid,
+ text,
+ numeric,
+ date,
+ timestamp,
+ jsonb,
+ index,
+ uniqueIndex,
+} from "drizzle-orm/pg-core";
+import { relationshipType, trustType } from "./_enums";
+import { entities } from "./entities";
+import { sources } from "./sources";
+import { rawRecords } from "./raw-records";
+import { civicEvents } from "./civic-events";
+import { evidence } from "./evidence";
+
+// Arestas documentadas entre entidades canônicas. Não registra parentesco por
+// sobrenome nem hipótese pública sem evidência oficial.
+export const entityRelationships = pgTable(
+ "entity_relationships",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ relationshipKey: text("relationship_key").notNull(),
+ fromEntityId: uuid("from_entity_id")
+ .notNull()
+ .references(() => entities.id),
+ toEntityId: uuid("to_entity_id")
+ .notNull()
+ .references(() => entities.id),
+ tipo: relationshipType("tipo").notNull(),
+ sourceId: text("source_id")
+ .notNull()
+ .references(() => sources.id),
+ sourceKey: text("source_key"),
+ rawRecordId: uuid("raw_record_id").references(() => rawRecords.id),
+ civicEventId: uuid("civic_event_id").references(() => civicEvents.id),
+ evidenceId: uuid("evidence_id").references(() => evidence.id),
+ descricao: text("descricao"),
+ confianca: numeric("confianca", { precision: 4, scale: 3 }).notNull().default("1.000"),
+ dataInicio: date("data_inicio"),
+ dataFim: date("data_fim"),
+ metadata: jsonb("metadata"),
+ trustType: trustType("trust_type").notNull().default("fato_oficial"),
+ criadoEm: timestamp("criado_em", { withTimezone: true }).notNull().defaultNow(),
+ },
+ (t) => [
+ uniqueIndex("entity_relationships_key_idx").on(t.relationshipKey),
+ index("entity_relationships_from_idx").on(t.fromEntityId),
+ index("entity_relationships_to_idx").on(t.toEntityId),
+ index("entity_relationships_source_idx").on(t.sourceId, t.sourceKey),
+ index("entity_relationships_type_idx").on(t.tipo),
+ ],
+);
+
+export type EntityRelationship = typeof entityRelationships.$inferSelect;
+export type NewEntityRelationship = typeof entityRelationships.$inferInsert;
diff --git a/packages/db/src/schema/evidence.ts b/packages/db/src/schema/evidence.ts
new file mode 100644
index 0000000..09a0d63
--- /dev/null
+++ b/packages/db/src/schema/evidence.ts
@@ -0,0 +1,50 @@
+import {
+ pgTable,
+ uuid,
+ text,
+ integer,
+ timestamp,
+ index,
+ uniqueIndex,
+} from "drizzle-orm/pg-core";
+import { trustType } from "./_enums";
+import { sources } from "./sources";
+import { rawRecords } from "./raw-records";
+import { civicEvents } from "./civic-events";
+
+// Evidência granular. Cada campo público sensível deve conseguir apontar para
+// documento, trecho, payload ou posição de extração que sustenta a afirmação.
+export const evidence = pgTable(
+ "evidence",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ evidenceKey: text("evidence_key").notNull(),
+ civicEventId: uuid("civic_event_id").references(() => civicEvents.id),
+ rawRecordId: uuid("raw_record_id").references(() => rawRecords.id),
+ sourceId: text("source_id")
+ .notNull()
+ .references(() => sources.id),
+ sourceKey: text("source_key"),
+ fieldPath: text("field_path"),
+ titulo: text("titulo").notNull(),
+ sourceUrl: text("source_url"),
+ trecho: text("trecho"),
+ pagina: integer("pagina"),
+ posicaoInicio: integer("posicao_inicio"),
+ posicaoFim: integer("posicao_fim"),
+ metodoExtracao: text("metodo_extracao").notNull(),
+ publishedAt: timestamp("published_at", { withTimezone: true }),
+ fetchedAt: timestamp("fetched_at", { withTimezone: true }),
+ trustType: trustType("trust_type").notNull().default("fato_oficial"),
+ criadoEm: timestamp("criado_em", { withTimezone: true }).notNull().defaultNow(),
+ },
+ (t) => [
+ uniqueIndex("evidence_key_idx").on(t.evidenceKey),
+ index("evidence_event_idx").on(t.civicEventId),
+ index("evidence_raw_idx").on(t.rawRecordId),
+ index("evidence_source_idx").on(t.sourceId, t.sourceKey),
+ ],
+);
+
+export type Evidence = typeof evidence.$inferSelect;
+export type NewEvidence = typeof evidence.$inferInsert;
diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts
index 0c8b8e6..9d4b736 100644
--- a/packages/db/src/schema/index.ts
+++ b/packages/db/src/schema/index.ts
@@ -7,3 +7,8 @@ export * from "./payments";
export * from "./gazettes";
export * from "./company-partners";
export * from "./sanctions";
+export * from "./civic-events";
+export * from "./evidence";
+export * from "./entity-relationships";
+export * from "./money-flows";
+export * from "./source-coverage";
diff --git a/packages/db/src/schema/money-flows.ts b/packages/db/src/schema/money-flows.ts
new file mode 100644
index 0000000..616d0ea
--- /dev/null
+++ b/packages/db/src/schema/money-flows.ts
@@ -0,0 +1,63 @@
+import {
+ pgTable,
+ uuid,
+ text,
+ integer,
+ numeric,
+ date,
+ timestamp,
+ index,
+ uniqueIndex,
+} from "drizzle-orm/pg-core";
+import { moneyFlowType, trustType } from "./_enums";
+import { sources } from "./sources";
+import { rawRecords } from "./raw-records";
+import { entities } from "./entities";
+import { contracts } from "./contracts";
+import { civicEvents } from "./civic-events";
+
+// Fluxo financeiro normalizado. Contratos, empenhos, liquidações, pagamentos e
+// receitas entram na mesma linha analítica, com origem e evidência explícitas.
+export const moneyFlows = pgTable(
+ "money_flows",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ flowKey: text("flow_key").notNull(),
+ sourceId: text("source_id")
+ .notNull()
+ .references(() => sources.id),
+ sourceKey: text("source_key").notNull(),
+ rawRecordId: uuid("raw_record_id").references(() => rawRecords.id),
+ civicEventId: uuid("civic_event_id").references(() => civicEvents.id),
+ contractId: uuid("contract_id").references(() => contracts.id),
+ orgaoEntityId: uuid("orgao_entity_id").references(() => entities.id),
+ counterpartyEntityId: uuid("counterparty_entity_id").references(() => entities.id),
+ tipo: moneyFlowType("tipo").notNull(),
+ valor: numeric("valor", { precision: 18, scale: 2 }).notNull(),
+ moeda: text("moeda").notNull().default("BRL"),
+ dataCompetencia: date("data_competencia"),
+ dataMovimento: date("data_movimento"),
+ exercicio: integer("exercicio"),
+ municipioIbge: text("municipio_ibge"),
+ uf: text("uf"),
+ descricao: text("descricao"),
+ sourceUrl: text("source_url"),
+ publishedAt: timestamp("published_at", { withTimezone: true }),
+ fetchedAt: timestamp("fetched_at", { withTimezone: true }),
+ trustType: trustType("trust_type").notNull().default("fato_oficial"),
+ criadoEm: timestamp("criado_em", { withTimezone: true }).notNull().defaultNow(),
+ atualizadoEm: timestamp("atualizado_em", { withTimezone: true }).notNull().defaultNow(),
+ },
+ (t) => [
+ uniqueIndex("money_flows_key_idx").on(t.flowKey),
+ index("money_flows_source_idx").on(t.sourceId, t.sourceKey),
+ index("money_flows_type_idx").on(t.tipo),
+ index("money_flows_contract_idx").on(t.contractId),
+ index("money_flows_orgao_idx").on(t.orgaoEntityId),
+ index("money_flows_counterparty_idx").on(t.counterpartyEntityId),
+ index("money_flows_municipio_idx").on(t.municipioIbge),
+ ],
+);
+
+export type MoneyFlow = typeof moneyFlows.$inferSelect;
+export type NewMoneyFlow = typeof moneyFlows.$inferInsert;
diff --git a/packages/db/src/schema/source-coverage.ts b/packages/db/src/schema/source-coverage.ts
new file mode 100644
index 0000000..5057dd3
--- /dev/null
+++ b/packages/db/src/schema/source-coverage.ts
@@ -0,0 +1,52 @@
+import {
+ pgTable,
+ uuid,
+ text,
+ integer,
+ date,
+ timestamp,
+ jsonb,
+ index,
+ uniqueIndex,
+} from "drizzle-orm/pg-core";
+import { sourceCoverageStatus } from "./_enums";
+import { sources } from "./sources";
+
+// Estado operacional de cada fonte/coletor. Permite publicar lacunas como dado:
+// sem API, sem chave, sem registros, atrasado, parcial ou com erro.
+export const sourceCoverage = pgTable(
+ "source_coverage",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ sourceId: text("source_id")
+ .notNull()
+ .references(() => sources.id),
+ collector: text("collector").notNull(),
+ territoryIbge: text("territory_ibge"),
+ uf: text("uf"),
+ recordType: text("record_type").notNull(),
+ status: sourceCoverageStatus("status").notNull(),
+ coverageStart: date("coverage_start"),
+ coverageEnd: date("coverage_end"),
+ lastAttemptAt: timestamp("last_attempt_at", { withTimezone: true }).notNull(),
+ lastSuccessAt: timestamp("last_success_at", { withTimezone: true }),
+ totalRecords: integer("total_records"),
+ errorMessage: text("error_message"),
+ limitations: text("limitations"),
+ metadata: jsonb("metadata"),
+ atualizadoEm: timestamp("atualizado_em", { withTimezone: true }).notNull().defaultNow(),
+ },
+ (t) => [
+ uniqueIndex("source_coverage_unique_idx").on(
+ t.sourceId,
+ t.collector,
+ t.territoryIbge,
+ t.recordType,
+ ),
+ index("source_coverage_source_idx").on(t.sourceId, t.status),
+ index("source_coverage_territory_idx").on(t.territoryIbge),
+ ],
+);
+
+export type SourceCoverage = typeof sourceCoverage.$inferSelect;
+export type NewSourceCoverage = typeof sourceCoverage.$inferInsert;
diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts
index 54dc5f9..0fbdfb1 100644
--- a/packages/db/src/seed.ts
+++ b/packages/db/src/seed.ts
@@ -87,6 +87,18 @@ const fontes: NewSource[] = [
"Seção SIAFIC (despesas/empenhos com credor) marcada como 'em breve' em 2026-05 — a execução detalhada ainda não foi publicada no portal.",
defaultTrustType: "fato_oficial",
},
+ {
+ id: "camara-americana",
+ nome: "Câmara Municipal de Americana",
+ descricao: "Atividade legislativa municipal: sessões, proposições, indicações, requerimentos e votações.",
+ baseUrl: "https://www.camara-americana.sp.gov.br",
+ licenca: "Publicação oficial municipal",
+ cobertura:
+ "Atos e tramitação legislativa da Câmara Municipal de Americana-SP, conforme disponibilidade do portal oficial.",
+ limitacoes:
+ "Coleta inicial usa descoberta HTML; projetos, indicações, requerimentos e votações ainda precisam de mapeadores dedicados por seção.",
+ defaultTrustType: "fato_oficial",
+ },
{
id: "receita-cnpj",
nome: "Cadastro Nacional da Pessoa Jurídica (Receita Federal via BrasilAPI)",