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() {
-

Quem está no poder?

+

Agentes públicos e sobrenomes citados

- Agentes públicos que mais aparecem no Diário de Americana e sobrenomes que se repetem. + Menções em atos oficiais do Diário de Americana, sem inferir parentesco.

@@ -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 ( + + +
+
+ +
+

{label}

+
+

+ {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 ( - - -
-
- -
-

- {label} -

-
-

- {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)",