mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
Add TSE 2022 election results model and local seed import.
This commit is contained in:
parent
040c36bed7
commit
c42b16baab
33 changed files with 10255 additions and 96 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -42,6 +42,9 @@ next-env.d.ts
|
|||
|
||||
/media
|
||||
|
||||
# TSE open-data zip cache (pnpm db:seed:tse)
|
||||
/data/tse/
|
||||
|
||||
# Playwright
|
||||
node_modules/
|
||||
/test-results/
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Baseline eleitoral TSE 2022
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-17
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos")
|
||||
Status: Fase 1 (A3) implementada — modelo + import
|
||||
Atualizado em: 2026-07-18
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (A3/A4 Baseline TSE 2022)
|
||||
Responsável: —
|
||||
|
||||
## Referência visual (UX Pilot)
|
||||
|
|
@ -43,13 +43,16 @@ Candidatos de interesse (destaques na UI): **Solla = dep. federal BA, PT, nº 13
|
|||
- **Access control:** dado público TSE. `read` para autenticado em `campaignUser` ou `users` (não aberto ao público). `create/update/delete` admin only. Sem `Consent`, sem transação em runtime. Import via CLI com `overrideAccess: true` fora do runtime deployado.
|
||||
- **Sem PII.** Não persistir CPF nem título eleitoral. A identidade cross-ano usa `identityKey = sha256(normalize(urnaName) + birthCity + birthState + party)` — só dados públicos da consulta de candidaturas.
|
||||
- **Migration** `add_election_results` (push:false). `pnpm build` aplica em prod no deploy. Não esbarra no bloqueador LGPD (dado público, sem liderança real).
|
||||
- **Import idempotente** pela chave única (upsert), com proveniência (URL, data, SHA-256) no cabeçalho do script. Sem step de revalidate (páginas de campanha são dinâmicas com auth, sem ISR/tag).
|
||||
- **Import idempotente por escopo** `(year, office, turn)`: delete + bulk insert via `payload.db.drizzle` (não upsert row a row). Proveniência (URL, SHA-256) no cabeçalho de `scripts/seed-tse-results.mjs`. Sem step de revalidate (páginas de campanha são dinâmicas com auth, sem ISR/tag).
|
||||
- **Sem votos de legenda na v1.** Campo `voteType` permanece no schema; só rows `nominal` são importadas. O agregado `votosLegenda` do tally cobre o que os insights precisam. Dataset `votacao_partido_munzona` fica fora do escopo A3.
|
||||
- **Votos anulados sub judice (2022):** `electionCandidateVote.votes` ← `QT_VOTOS_NOMINAIS` (apurados); validade fica no tally (`votosNominaisValidos`, `votosAnulados`).
|
||||
- **Mapeamento de município:** `NM_MUNICIPIO` (TSE) → nome canônico de `bahiaTerritories.ts` via normalização case/acento/apóstrofo; falha alta em município não mapeado; persiste `cityCode` (TSE) + `cityName` canônico. Teste cobre os 417 municípios.
|
||||
- **Agregação núcleo→baseline é autossuficiente** (revisão 2026-07-17): `citiesForTerritory` **já existe** em `src/lib/bahiaTerritories.ts` (não vem do plano `zonas-por-municipio.md`), e a correspondência cidade↔zona pode ser resolvida pelas próprias rows de `electionTally` (cada par cidade×zona importado é uma row — `where cityName = C` retorna todas as zonas da cidade). `tseZonesForCity` (plano `zonas-por-municipio.md`) é **opcional**: quando existir, vira a fonte preferida por ser validada por fixture. Sem `tseZones` → todas as zonas da(s) cidade(s); só `region` → todas as cidades do território; sem geografia → estado "sem baseline TSE".
|
||||
- **i18n e naming** seguem o AGENTS.md: identificadores em inglês, strings visíveis em pt-BR.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- Mapeamento `CD_MUNICIPIO` (TSE) → nome canônico em `CitiesByState.BA`: usar `NM_MUNICIPIO` do CSV + validação cruzada.
|
||||
- ~~Mapeamento `CD_MUNICIPIO` (TSE) → nome canônico~~ — resolvido na Fase 1 via `canonicalizeMunicipalityName` + `cityCode` persistido.
|
||||
- Limiares de classificação territorial (defesa/ataque/indecisa/perdida) — definir com produto.
|
||||
- Mostrar 1º e 2º turno para presidente/governador, ou só o decisivo? Recomendação: ambos, com destaque ao decisivo.
|
||||
- `lideranca` vê ranking/competidor? Recomendação: sim, é dado público e útil ao engajamento.
|
||||
|
|
@ -96,7 +99,7 @@ flowchart LR
|
|||
|
||||
### Fases
|
||||
|
||||
1. **Modelo + import** — collections, migration, `scripts/seed-tse-results.mjs` + `pnpm db:seed:tse`, helpers `src/lib/electionResults.ts` e `src/lib/electionCandidateIdentity.ts`, teste int (totais conhecidos, `elected=true` para Solla/Lula/Jerônimo, idempotência).
|
||||
1. **Modelo + import** — **feito (A3, 2026-07-18).** Collections `electionTally` / `electionCandidateVote` / `electionCandidate`, migration `20260718_195854_add_election_results`, `pnpm db:seed:tse`, helpers em `src/lib/electionResults*.ts` + `electionCandidateIdentity.ts`, import drizzle em `src/utilities/electionResultsImport.ts`, testes unit/int com fixtures em `tests/fixtures/tse/`. Seed local BA 2022: **211.014** votos nominais, **2.700** apurações, **1.654** candidatos (6 escopos office×turn).
|
||||
2. **Detalhe do núcleo** — bloco "Baseline eleitoral 2022" na aba overview; view model `NucleusElectoralBaselineViewModel` em `src/utilities/nucleusViewModels.ts`; componente `src/components/campaign/NucleusElectoralBaseline.tsx`.
|
||||
3. **Overview da lista** — estende `overview-lista-nucleos.md`: somar baselines sobre o conjunto filtrado (`buildNucleusListWhere`); novo bloco no `NucleusListOverview`.
|
||||
4. **Insights automáticos (Gap vs 2022)** — implementa somente o insight "Gap vs 2022" (ver seção "Insights automáticos" abaixo). `src/components/campaign/NucleusInsights.tsx` + `src/lib/electionInsights.ts`. Os demais insights viram itens separados no roadmap com planos próprios.
|
||||
|
|
@ -167,8 +170,12 @@ Cada insight é uma **derivação de leitura** (sem escrita, sem `Consent`, sem
|
|||
|
||||
## Arquivos a criar/alterar
|
||||
|
||||
- Criar: `src/collections/ElectionTally.ts`, `src/collections/ElectionCandidateVote.ts`, `src/collections/ElectionCandidate.ts`, `src/lib/electionResults.ts`, `src/lib/electionInsights.ts`, `src/lib/electionCandidateIdentity.ts`, `src/utilities/nucleusElectoralBaseline.ts`, `src/components/campaign/NucleusElectoralBaseline.tsx`, `src/components/campaign/NucleusInsights.tsx`, `scripts/seed-tse-results.mjs`, `scripts/reconcile-running-again.mjs`, migration `add_election_results`, testes int. (Os demais componentes de insight — `electionAlliances.ts`, `DobradinhaOpportunities.tsx` etc. — ficam nos planos separados dos respectivos insights.)
|
||||
- Alterar: `src/payload.config.ts`, `src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx` + `src/utilities/nucleusDetailPageData.ts`, `src/utilities/nucleusPageData.ts`/`nucleusViewModels.ts`, overview da lista, `package.json` (`db:seed:tse`).
|
||||
**Fase 1 (A3) — feitos:**
|
||||
- Criar: `src/collections/ElectionTally.ts`, `ElectionCandidateVote.ts`, `ElectionCandidate.ts`; `src/lib/electionResults.ts`, `electionResultsParse.ts`, `electionResultsBuild.ts`, `electionResultsCsv.ts`, `electionResultsZip.ts`, `electionCandidateIdentity.ts`; `src/utilities/electionResultsImport.ts`; `scripts/seed-tse-results.mjs`; migration `20260718_195854_add_election_results`; fixtures `tests/fixtures/tse/*`; testes unit/int.
|
||||
- Alterar: `src/payload.config.ts`, `src/utilities/campaignAccess.ts` (`canReadElectionData` / `canMutateElectionData`), `package.json` (`db:seed:tse`), `scripts/seed-loader.mjs` (stub `server-only`), `.gitignore` (`/data/tse/`).
|
||||
|
||||
**Fases 2–5 (A4+) — ainda a criar:**
|
||||
- `src/lib/electionInsights.ts`, `src/utilities/nucleusElectoralBaseline.ts`, `src/components/campaign/NucleusElectoralBaseline.tsx`, `NucleusInsights.tsx`, `scripts/reconcile-running-again.mjs`; alterar páginas do núcleo / overview.
|
||||
|
||||
## Dependências
|
||||
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ flowchart TD
|
|||
subgraph TrilhaA["Trilha A — dados eleitorais e território"]
|
||||
A1["A1 Território multi-município/bairro<br/>(migration)"]
|
||||
A2["A2 Zonas TSE por município<br/>(auto-preenchimento)"]
|
||||
A3["A3 Baseline TSE 2022 — Fase 1<br/>(import, collections)"]
|
||||
A3["A3 Baseline TSE 2022 — Fase 1<br/>(import, collections) ✓"]
|
||||
A4["A4 Baseline no produto + Gap vs 2022<br/>(detalhe, overview)"]
|
||||
A5["A5 Insights: conversão, classificação,<br/>alavancagem, mobilização, competitiva"]
|
||||
A6["A6 Dobradinha 2026"]
|
||||
|
|
@ -130,7 +130,7 @@ flowchart TD
|
|||
JUR -.chave de push.-> D2
|
||||
```
|
||||
|
||||
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): A1, A3, B1, B2, C1, C2 (engenharia — produção espera o jurídico), D1, além dos fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase).
|
||||
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): A1, B1, B2, C1, C2 (engenharia — produção espera o jurídico), D1, além dos fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **A3 entregue** (2026-07-18) — collections + `pnpm db:seed:tse`.
|
||||
|
||||
### Sequência de execução por janela do calendário
|
||||
|
||||
|
|
@ -143,7 +143,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): A1, A3, B1,
|
|||
| 3 | A2 Zonas TSE por município (auto-preenchimento) | [detalhes](plans/zonas-por-municipio.md) | A1 (nasce contra `cities[]`) | B1, C1, A3 |
|
||||
| 4 | B1 Overview da lista de núcleos | [detalhes](plans/overview-lista-nucleos.md) | — | A1, A2, C1 |
|
||||
| 5 | C1 Compartilhar página (quick win de campo) | [detalhes](plans/compartilhar-pagina.md) | — | tudo |
|
||||
| 6 | A3 Baseline TSE 2022 — Fase 1 (import) | [detalhes](plans/baseline-eleitoral-tse.md) | — (dado público; sem bloqueador LGPD) | tudo |
|
||||
| 6 | A3 Baseline TSE 2022 — Fase 1 (import) **feito** | [detalhes](plans/baseline-eleitoral-tse.md) | — (dado público; sem bloqueador LGPD) | tudo |
|
||||
|
||||
**Janela 2 — 05/08 → 16/08 (pré-propaganda): base nominal + inteligência + agenda prontas para o arranque.**
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
"db:stop": "docker compose down",
|
||||
"db:pull": "node scripts/db-pull.mjs",
|
||||
"db:seed:posts": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-posts.mjs",
|
||||
"db:seed:tse": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-tse-results.mjs",
|
||||
"dev": "node scripts/guard-dev-db.mjs && cross-env NODE_OPTIONS=--no-deprecation next dev",
|
||||
"devsafe": "rm -rf .next && node scripts/guard-dev-db.mjs && cross-env NODE_OPTIONS=--no-deprecation next dev",
|
||||
"generate:importmap": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload generate:importmap",
|
||||
|
|
@ -89,8 +90,10 @@
|
|||
"@types/node": "22.19.9",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/yauzl": "^3.4.0",
|
||||
"@vercel/blob": "2.3.1",
|
||||
"@vitejs/plugin-react": "4.5.2",
|
||||
"csv-parse": "^7.0.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "15.4.11",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
|
|
@ -104,7 +107,8 @@
|
|||
"typescript": "5.7.3",
|
||||
"typescript-eslint": "^8.55.0",
|
||||
"vite-tsconfig-paths": "6.0.5",
|
||||
"vitest": "4.0.18"
|
||||
"vitest": "4.0.18",
|
||||
"yauzl": "^3.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.20.2 || >=20.9.0",
|
||||
|
|
|
|||
|
|
@ -138,12 +138,18 @@ importers:
|
|||
'@types/react-dom':
|
||||
specifier: ^19.2.3
|
||||
version: 19.2.3(@types/react@19.2.14)
|
||||
'@types/yauzl':
|
||||
specifier: ^3.4.0
|
||||
version: 3.4.0
|
||||
'@vercel/blob':
|
||||
specifier: 2.3.1
|
||||
version: 2.3.1
|
||||
'@vitejs/plugin-react':
|
||||
specifier: 4.5.2
|
||||
version: 4.5.2(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.77.4)(tsx@4.21.0)(yaml@2.8.2))
|
||||
csv-parse:
|
||||
specifier: ^7.0.1
|
||||
version: 7.0.1
|
||||
eslint:
|
||||
specifier: ^9.39.2
|
||||
version: 9.39.2(jiti@2.6.1)
|
||||
|
|
@ -186,6 +192,9 @@ importers:
|
|||
vitest:
|
||||
specifier: 4.0.18
|
||||
version: 4.0.18(@types/node@22.19.9)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.10(@types/node@22.19.9)(typescript@5.7.3))(sass@1.77.4)(tsx@4.21.0)(yaml@2.8.2)
|
||||
yauzl:
|
||||
specifier: ^3.4.0
|
||||
version: 3.4.0
|
||||
|
||||
packages:
|
||||
|
||||
|
|
@ -2828,6 +2837,9 @@ packages:
|
|||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
'@types/yauzl@3.4.0':
|
||||
resolution: {integrity: sha512-NRPn5w6h8dhcnmx3YIRQcqMywY/+nND/uOkJessedcrowO3C0AssHp3tMJpxKAwOhFOo0OV1y9VtsC5hbKKBAw==}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.55.0':
|
||||
resolution: {integrity: sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
|
@ -3498,6 +3510,9 @@ packages:
|
|||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
csv-parse@7.0.1:
|
||||
resolution: {integrity: sha512-+2z7Ar0APQ7Uu6fX4cn+pitRmxjZ1WPBcGmZFKmA74FCyi7Et/XZx8cjNQ5CjbZ4HCOxXCOpRBYvYH08Qa003A==}
|
||||
|
||||
damerau-levenshtein@1.0.8:
|
||||
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
|
||||
|
||||
|
|
@ -5240,6 +5255,9 @@ packages:
|
|||
peerDependencies:
|
||||
graphql: ^16.8.1
|
||||
|
||||
pend@1.2.0:
|
||||
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
|
||||
|
||||
pg-cloudflare@1.3.0:
|
||||
resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==}
|
||||
|
||||
|
|
@ -6438,6 +6456,10 @@ packages:
|
|||
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
yauzl@3.4.0:
|
||||
resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
yjs@13.6.29:
|
||||
resolution: {integrity: sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==}
|
||||
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
|
||||
|
|
@ -9098,6 +9120,10 @@ snapshots:
|
|||
dependencies:
|
||||
'@types/node': 22.19.9
|
||||
|
||||
'@types/yauzl@3.4.0':
|
||||
dependencies:
|
||||
'@types/node': 22.19.9
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
|
|
@ -9750,6 +9776,8 @@ snapshots:
|
|||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
csv-parse@7.0.1: {}
|
||||
|
||||
damerau-levenshtein@1.0.8: {}
|
||||
|
||||
data-uri-to-buffer@4.0.1: {}
|
||||
|
|
@ -11755,6 +11783,8 @@ snapshots:
|
|||
- typescript
|
||||
- utf-8-validate
|
||||
|
||||
pend@1.2.0: {}
|
||||
|
||||
pg-cloudflare@1.3.0:
|
||||
optional: true
|
||||
|
||||
|
|
@ -13155,6 +13185,10 @@ snapshots:
|
|||
y18n: 5.0.8
|
||||
yargs-parser: 21.1.1
|
||||
|
||||
yauzl@3.4.0:
|
||||
dependencies:
|
||||
pend: 1.2.0
|
||||
|
||||
yjs@13.6.29:
|
||||
dependencies:
|
||||
lib0: 0.2.117
|
||||
|
|
|
|||
45
scripts/assert-local-database.mjs
Normal file
45
scripts/assert-local-database.mjs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* Shared local-DB guard for CLI scripts (seeds, pnpm dev guard, etc.).
|
||||
* Host allowlist + ALLOW_REMOTE_DB escape hatch.
|
||||
*/
|
||||
|
||||
const OVERRIDE_FLAG = 'ALLOW_REMOTE_DB'
|
||||
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', 'postgres'])
|
||||
|
||||
/**
|
||||
* @param {string} label Log/error prefix, e.g. "seed:tse"
|
||||
* @param {string} [usageHint] Extra lines shown when refusing a remote host
|
||||
*/
|
||||
export const assertLocalDatabase = (label, usageHint = '') => {
|
||||
const die = (message) => {
|
||||
console.error(`\n[${label}] ${message}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL
|
||||
if (!databaseUrl) die('DATABASE_URL is not set. Refusing to continue.')
|
||||
|
||||
if (process.env[OVERRIDE_FLAG] === 'true' || process.env[OVERRIDE_FLAG] === '1') {
|
||||
console.warn(
|
||||
`\n[${label}] ${OVERRIDE_FLAG} is set — connecting to a remote database on purpose. Be careful.\n`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let host
|
||||
try {
|
||||
host = new URL(databaseUrl).hostname
|
||||
} catch {
|
||||
die('DATABASE_URL is not a valid connection string.')
|
||||
}
|
||||
|
||||
if (!LOCAL_HOSTS.has(host)) {
|
||||
die(
|
||||
`DATABASE_URL points at a non-local host ("${host}").\n` +
|
||||
(usageHint ? `${usageHint}\n\n` : '') +
|
||||
`If you REALLY mean to use a remote database, re-run with:\n ${OVERRIDE_FLAG}=true …`,
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`[${label}] OK — using local database host "${host}".`)
|
||||
}
|
||||
|
|
@ -9,8 +9,7 @@
|
|||
*/
|
||||
import { config as loadEnv } from 'dotenv'
|
||||
|
||||
const OVERRIDE_FLAG = 'ALLOW_REMOTE_DB'
|
||||
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', 'postgres'])
|
||||
import { assertLocalDatabase } from './assert-local-database.mjs'
|
||||
|
||||
// Mirror Next.js precedence (.env.local wins over .env) WITHOUT overriding a
|
||||
// DATABASE_URL that is already set in the real environment (e.g. injected by
|
||||
|
|
@ -18,40 +17,10 @@ const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', 'postgr
|
|||
loadEnv({ path: '.env.local' })
|
||||
loadEnv({ path: '.env' })
|
||||
|
||||
const fail = (message) => {
|
||||
console.error(`\n[guard-dev-db] ${message}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL
|
||||
|
||||
if (!databaseUrl) {
|
||||
fail('DATABASE_URL is not set. Refusing to start the dev server.')
|
||||
}
|
||||
|
||||
if (process.env[OVERRIDE_FLAG] === 'true' || process.env[OVERRIDE_FLAG] === '1') {
|
||||
console.warn(
|
||||
`\n[guard-dev-db] ${OVERRIDE_FLAG} is set — connecting to a remote database on purpose. Be careful.\n`,
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
let host
|
||||
try {
|
||||
host = new URL(databaseUrl).hostname
|
||||
} catch {
|
||||
fail('DATABASE_URL is not a valid connection string.')
|
||||
}
|
||||
|
||||
if (!LOCAL_HOSTS.has(host)) {
|
||||
fail(
|
||||
`DATABASE_URL points at a non-local host ("${host}").\n` +
|
||||
'This is almost certainly the production or a shared database.\n\n' +
|
||||
'Local development should use the local Postgres from docker-compose:\n' +
|
||||
' 1. pnpm db:start\n' +
|
||||
' 2. set DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo in .env.local\n\n' +
|
||||
`If you REALLY mean to connect to a remote database, re-run with:\n ${OVERRIDE_FLAG}=true pnpm dev`,
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`[guard-dev-db] OK — using local database host "${host}".`)
|
||||
assertLocalDatabase(
|
||||
'guard-dev-db',
|
||||
'This is almost certainly the production or a shared database.\n\n' +
|
||||
'Local development should use the local Postgres from docker-compose:\n' +
|
||||
' 1. pnpm db:start\n' +
|
||||
' 2. set DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo in .env.local',
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* Seed-only Node module loader: stubs `next/cache` with no-ops.
|
||||
* Seed-only Node module loader: stubs `next/cache` and `server-only`.
|
||||
*
|
||||
* The `post` and `tag` collections call `revalidateTag()` (via
|
||||
* `src/utilities/documents.ts`) inside their `afterChange` hooks. `revalidateTag`
|
||||
|
|
@ -8,12 +8,16 @@
|
|||
* every `payload.create`. Rather than editing the collections (owned elsewhere),
|
||||
* we replace `next/cache` with harmless no-ops for the duration of the seed.
|
||||
*
|
||||
* Registered via `--import=./scripts/seed-loader.mjs` in the `db:seed:posts`
|
||||
* npm script; it affects only that process.
|
||||
* Campaign collections also pull in utilities marked `import 'server-only'`; that
|
||||
* package throws outside the Next.js server runtime, so we stub it as an empty
|
||||
* module for CLI seeds (`db:seed:posts`, `db:seed:tse`).
|
||||
*
|
||||
* Registered via `--import=./scripts/seed-loader.mjs` in the seed npm scripts;
|
||||
* it affects only that process.
|
||||
*/
|
||||
import { register } from 'node:module'
|
||||
|
||||
const shim =
|
||||
const nextCacheShim =
|
||||
'data:text/javascript,' +
|
||||
encodeURIComponent(
|
||||
[
|
||||
|
|
@ -24,12 +28,17 @@ const shim =
|
|||
].join('\n'),
|
||||
)
|
||||
|
||||
const serverOnlyShim = 'data:text/javascript,' + encodeURIComponent('export {};')
|
||||
|
||||
const hooks =
|
||||
'data:text/javascript,' +
|
||||
encodeURIComponent(
|
||||
`export function resolve(specifier, context, nextResolve) {
|
||||
if (specifier === 'next/cache') {
|
||||
return { url: ${JSON.stringify(shim)}, shortCircuit: true }
|
||||
return { url: ${JSON.stringify(nextCacheShim)}, shortCircuit: true }
|
||||
}
|
||||
if (specifier === 'server-only') {
|
||||
return { url: ${JSON.stringify(serverOnlyShim)}, shortCircuit: true }
|
||||
}
|
||||
return nextResolve(specifier, context)
|
||||
}`,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ import { convertHTMLToLexical, editorConfigFactory } from '@payloadcms/richtext-
|
|||
import { del as blobDel } from '@vercel/blob'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { assertLocalDatabase } from './assert-local-database.mjs'
|
||||
|
||||
// Mirror Next.js precedence (.env.local wins over .env) without clobbering a
|
||||
// DATABASE_URL already set in the real environment.
|
||||
loadEnv({ path: '.env.local' })
|
||||
|
|
@ -36,49 +38,11 @@ loadEnv({ path: '.env' })
|
|||
|
||||
const config = (await import('../src/payload.config.ts')).default
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB-safety guard (same shape as scripts/guard-dev-db.mjs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const OVERRIDE_FLAG = 'ALLOW_REMOTE_DB'
|
||||
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', 'postgres'])
|
||||
|
||||
const die = (message) => {
|
||||
console.error(`\n[seed:posts] ${message}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const assertLocalDatabase = () => {
|
||||
const databaseUrl = process.env.DATABASE_URL
|
||||
if (!databaseUrl) die('DATABASE_URL is not set. Refusing to seed.')
|
||||
|
||||
if (process.env[OVERRIDE_FLAG] === 'true' || process.env[OVERRIDE_FLAG] === '1') {
|
||||
console.warn(
|
||||
`\n[seed:posts] ${OVERRIDE_FLAG} is set — seeding a remote database on purpose. Be careful.\n`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let host
|
||||
try {
|
||||
host = new URL(databaseUrl).hostname
|
||||
} catch {
|
||||
die('DATABASE_URL is not a valid connection string.')
|
||||
}
|
||||
|
||||
if (!LOCAL_HOSTS.has(host)) {
|
||||
die(
|
||||
`DATABASE_URL points at a non-local host ("${host}").\n` +
|
||||
'This seed WRITES data (posts/tags/media) and is meant for the local Postgres.\n\n' +
|
||||
' 1. pnpm db:start\n' +
|
||||
' 2. set DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo in .env.local\n\n' +
|
||||
`If you REALLY mean to seed a remote database, re-run with:\n ${OVERRIDE_FLAG}=true pnpm db:seed:posts`,
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`[seed:posts] OK — using local database host "${host}".`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Taxonomy (Section 7): the categories + control tag, and the per-article map.
|
||||
// Keys are the WordPress slugs of the live articles. Every post gets exactly
|
||||
|
|
@ -450,7 +414,12 @@ async function ensureCoverMedia(payload, { slug, coverUrl, alt }) {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
assertLocalDatabase()
|
||||
assertLocalDatabase(
|
||||
'seed:posts',
|
||||
'This seed WRITES data (posts/tags/media) and is meant for the local Postgres.\n' +
|
||||
' 1. pnpm db:start\n' +
|
||||
' 2. set DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo in .env.local',
|
||||
)
|
||||
|
||||
const payload = await getPayload({ config })
|
||||
const editorConfig = await editorConfigFactory.default({ config: payload.config })
|
||||
|
|
|
|||
173
scripts/seed-tse-results.mjs
Normal file
173
scripts/seed-tse-results.mjs
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
/**
|
||||
* Seeds electionTally / electionCandidateVote / electionCandidate from TSE
|
||||
* open data for the 2022 general election (Bahia scope).
|
||||
*
|
||||
* Provenance (do not strip):
|
||||
* - Portal: https://dadosabertos.tse.jus.br/dataset/resultados-2022
|
||||
* - Portal: https://dadosabertos.tse.jus.br/dataset/candidatos-2022
|
||||
* - votacao_candidato_munzona_2022.zip
|
||||
* https://cdn.tse.jus.br/estatistica/sead/odsele/votacao_candidato_munzona/votacao_candidato_munzona_2022.zip
|
||||
* - detalhe_votacao_munzona_2022.zip
|
||||
* https://cdn.tse.jus.br/estatistica/sead/odsele/detalhe_votacao_munzona/detalhe_votacao_munzona_2022.zip
|
||||
* - consulta_cand_2022.zip
|
||||
* https://cdn.tse.jus.br/estatistica/sead/odsele/consulta_cand/consulta_cand_2022.zip
|
||||
* - License: Creative Commons Atribuição (dados abertos TSE)
|
||||
* - electionCandidateVote.votes ← QT_VOTOS_NOMINAIS (votos apurados; validade no tally)
|
||||
* - v1: only voteType=nominal (no votacao_partido_munzona / legenda rows)
|
||||
*
|
||||
* Safety: refuses non-local DATABASE_URL unless ALLOW_REMOTE_DB=true.
|
||||
* Idempotent: replace-by-scope (year, office, turn) via drizzle transaction.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm db:seed:tse
|
||||
* ALLOW_REMOTE_DB=true pnpm db:seed:tse
|
||||
* TSE_CACHE_DIR=./data/tse pnpm db:seed:tse # reuse downloaded zips
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, readFile, writeFile, access } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { config as loadEnv } from 'dotenv'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { assertLocalDatabase } from './assert-local-database.mjs'
|
||||
|
||||
loadEnv({ path: '.env.local' })
|
||||
loadEnv({ path: '.env' })
|
||||
|
||||
const config = (await import('../src/payload.config.ts')).default
|
||||
const { parseTseCsvBuffer } = await import('../src/lib/electionResultsCsv.ts')
|
||||
const { buildElectionResultsFromCsvRows } = await import('../src/lib/electionResultsBuild.ts')
|
||||
const { downloadToBuffer, readZipEntry } = await import('../src/lib/electionResultsZip.ts')
|
||||
const { buildImportBundles, importElectionBundles } = await import(
|
||||
'../src/utilities/electionResultsImport.ts'
|
||||
)
|
||||
|
||||
const SOURCES = [
|
||||
{
|
||||
key: 'votacao_candidato_munzona_2022',
|
||||
url: 'https://cdn.tse.jus.br/estatistica/sead/odsele/votacao_candidato_munzona/votacao_candidato_munzona_2022.zip',
|
||||
},
|
||||
{
|
||||
key: 'detalhe_votacao_munzona_2022',
|
||||
url: 'https://cdn.tse.jus.br/estatistica/sead/odsele/detalhe_votacao_munzona/detalhe_votacao_munzona_2022.zip',
|
||||
},
|
||||
{
|
||||
key: 'consulta_cand_2022',
|
||||
url: 'https://cdn.tse.jus.br/estatistica/sead/odsele/consulta_cand/consulta_cand_2022.zip',
|
||||
},
|
||||
]
|
||||
|
||||
const die = (message) => {
|
||||
console.error(`\n[seed:tse] ${message}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex')
|
||||
|
||||
const cacheDir = () => process.env.TSE_CACHE_DIR || join(process.cwd(), 'data', 'tse')
|
||||
|
||||
const ensureCachedZip = async ({ key, url }) => {
|
||||
const dir = cacheDir()
|
||||
await mkdir(dir, { recursive: true })
|
||||
const path = join(dir, `${key}.zip`)
|
||||
try {
|
||||
await access(path)
|
||||
console.log(`[seed:tse] cache hit ${path}`)
|
||||
const buffer = await readFile(path)
|
||||
return { key, url, buffer, hash: sha256(buffer) }
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error
|
||||
}
|
||||
console.log(`[seed:tse] downloading ${url}`)
|
||||
const buffer = await downloadToBuffer(url)
|
||||
const hash = sha256(buffer)
|
||||
await writeFile(path, buffer)
|
||||
console.log(`[seed:tse] saved ${path} (${buffer.length} bytes, sha256=${hash})`)
|
||||
return { key, url, buffer, hash }
|
||||
}
|
||||
|
||||
const loadCsvFromZip = async (zipBuffer, fileName) => {
|
||||
const entry = await readZipEntry(zipBuffer, (name) => name.endsWith(fileName))
|
||||
if (!entry) die(`ZIP entry not found: ${fileName}`)
|
||||
console.log(`[seed:tse] parsing ${entry.fileName} (${entry.buffer.length} bytes)`)
|
||||
return parseTseCsvBuffer(entry.buffer)
|
||||
}
|
||||
|
||||
const main = async () => {
|
||||
assertLocalDatabase(
|
||||
'seed:tse',
|
||||
'This seed WRITES election reference data and is meant for the local Postgres.\n' +
|
||||
' 1. pnpm db:start\n' +
|
||||
' 2. set DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo in .env.local',
|
||||
)
|
||||
|
||||
const [votacaoZip, detalheZip, candZip] = await Promise.all(SOURCES.map(ensureCachedZip))
|
||||
|
||||
console.log('[seed:tse] provenance:')
|
||||
for (const source of [votacaoZip, detalheZip, candZip]) {
|
||||
console.log(` - ${source.url}`)
|
||||
console.log(` sha256=${source.hash}`)
|
||||
}
|
||||
|
||||
// Sequential: BA vote CSV alone is hundreds of MB; parallel parse spikes peak RAM.
|
||||
const voteBa = await loadCsvFromZip(votacaoZip.buffer, 'votacao_candidato_munzona_2022_BA.csv')
|
||||
const voteBr = await loadCsvFromZip(votacaoZip.buffer, 'votacao_candidato_munzona_2022_BR.csv')
|
||||
const detalheBa = await loadCsvFromZip(
|
||||
detalheZip.buffer,
|
||||
'detalhe_votacao_munzona_2022_BA.csv',
|
||||
)
|
||||
const detalheBr = await loadCsvFromZip(
|
||||
detalheZip.buffer,
|
||||
'detalhe_votacao_munzona_2022_BR.csv',
|
||||
)
|
||||
const candBa = await loadCsvFromZip(candZip.buffer, 'consulta_cand_2022_BA.csv')
|
||||
const candBr = await loadCsvFromZip(candZip.buffer, 'consulta_cand_2022_BR.csv')
|
||||
|
||||
const built = buildElectionResultsFromCsvRows({
|
||||
voteRows: [...voteBa, ...voteBr],
|
||||
detalheRows: [...detalheBa, ...detalheBr],
|
||||
candBaRows: candBa,
|
||||
candBrRows: candBr,
|
||||
})
|
||||
|
||||
if (built.unknownMunicipalities.length > 0) {
|
||||
die(
|
||||
`Municípios TSE sem mapeamento canônico (${built.unknownMunicipalities.length}):\n - ` +
|
||||
built.unknownMunicipalities.slice(0, 20).join('\n - ') +
|
||||
(built.unknownMunicipalities.length > 20 ? '\n - …' : ''),
|
||||
)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[seed:tse] parsed votes=${built.votes.length} tallies=${built.tallies.length} candidates=${built.candidates.length}`,
|
||||
)
|
||||
|
||||
const bundles = buildImportBundles(built)
|
||||
console.log(`[seed:tse] importing ${bundles.length} scopes…`)
|
||||
|
||||
const payload = await getPayload({ config })
|
||||
const counts = await importElectionBundles(payload, bundles)
|
||||
|
||||
let votes = 0
|
||||
let tallies = 0
|
||||
let candidates = 0
|
||||
for (const count of counts) {
|
||||
votes += count.votesInserted
|
||||
tallies += count.talliesInserted
|
||||
candidates += count.candidatesInserted
|
||||
console.log(
|
||||
` ${count.scope.office} turn=${count.scope.turn}: votes=${count.votesInserted} tallies=${count.talliesInserted} candidates=${count.candidatesInserted}`,
|
||||
)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\n[seed:tse] done. inserted votes=${votes} tallies=${tallies} candidates=${candidates}`,
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[seed:tse] fatal:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
159
src/collections/ElectionCandidate.ts
Normal file
159
src/collections/ElectionCandidate.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { canMutateElectionData, canReadElectionData } from '@/utilities/campaignAccess'
|
||||
|
||||
import {
|
||||
ELECTION_ELECTED_BY_OPTIONS,
|
||||
ELECTION_OFFICE_OPTIONS,
|
||||
ELECTION_RUNNING_AGAIN_OPTIONS,
|
||||
ELECTION_TURN_OPTIONS,
|
||||
} from '@/lib/electionResults'
|
||||
|
||||
const slug = 'electionCandidate'
|
||||
|
||||
export const ElectionCandidate: CollectionConfig<typeof slug> = {
|
||||
slug,
|
||||
labels: {
|
||||
singular: 'Candidato eleitoral',
|
||||
plural: 'Candidatos eleitorais',
|
||||
},
|
||||
admin: {
|
||||
group: 'Dados Eleitorais',
|
||||
useAsTitle: 'urnaName',
|
||||
defaultColumns: [
|
||||
'year',
|
||||
'office',
|
||||
'candidateNumber',
|
||||
'urnaName',
|
||||
'party',
|
||||
'elected',
|
||||
'updatedAt',
|
||||
],
|
||||
description: 'Registro de candidatura TSE (dado público).',
|
||||
},
|
||||
access: {
|
||||
create: canMutateElectionData,
|
||||
read: canReadElectionData,
|
||||
update: canMutateElectionData,
|
||||
delete: canMutateElectionData,
|
||||
},
|
||||
indexes: [
|
||||
{
|
||||
fields: ['year', 'office', 'turn', 'state', 'candidateNumber'],
|
||||
unique: true,
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
name: 'year',
|
||||
type: 'number',
|
||||
label: 'Ano',
|
||||
required: true,
|
||||
index: true,
|
||||
min: 2000,
|
||||
max: 2100,
|
||||
},
|
||||
{
|
||||
name: 'office',
|
||||
type: 'select',
|
||||
label: 'Cargo',
|
||||
required: true,
|
||||
index: true,
|
||||
options: [...ELECTION_OFFICE_OPTIONS],
|
||||
},
|
||||
{
|
||||
name: 'turn',
|
||||
type: 'select',
|
||||
label: 'Turno',
|
||||
required: true,
|
||||
index: true,
|
||||
options: [...ELECTION_TURN_OPTIONS],
|
||||
},
|
||||
{
|
||||
name: 'state',
|
||||
type: 'text',
|
||||
label: 'UF',
|
||||
required: true,
|
||||
defaultValue: 'BA',
|
||||
maxLength: 2,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'candidateNumber',
|
||||
type: 'number',
|
||||
label: 'Número do candidato',
|
||||
required: true,
|
||||
index: true,
|
||||
min: 0,
|
||||
},
|
||||
{
|
||||
name: 'urnaName',
|
||||
type: 'text',
|
||||
label: 'Nome na urna',
|
||||
required: true,
|
||||
maxLength: 160,
|
||||
},
|
||||
{
|
||||
name: 'completeName',
|
||||
type: 'text',
|
||||
label: 'Nome completo',
|
||||
maxLength: 200,
|
||||
},
|
||||
{
|
||||
name: 'party',
|
||||
type: 'text',
|
||||
label: 'Partido',
|
||||
maxLength: 40,
|
||||
},
|
||||
{
|
||||
name: 'coalition',
|
||||
type: 'text',
|
||||
label: 'Coligação',
|
||||
maxLength: 240,
|
||||
},
|
||||
{
|
||||
name: 'candidateStatus',
|
||||
type: 'text',
|
||||
label: 'Situação da candidatura',
|
||||
maxLength: 80,
|
||||
},
|
||||
{
|
||||
name: 'elected',
|
||||
type: 'checkbox',
|
||||
label: 'Eleito',
|
||||
defaultValue: false,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'electedBy',
|
||||
type: 'select',
|
||||
label: 'Forma de eleição',
|
||||
options: [...ELECTION_ELECTED_BY_OPTIONS],
|
||||
},
|
||||
{
|
||||
name: 'totalVotesState',
|
||||
type: 'number',
|
||||
label: 'Total de votos no estado',
|
||||
min: 0,
|
||||
},
|
||||
{
|
||||
name: 'identityKey',
|
||||
type: 'text',
|
||||
label: 'Chave de identidade cross-ano',
|
||||
index: true,
|
||||
maxLength: 64,
|
||||
admin: {
|
||||
description: 'sha256(nome urna + naturalidade + partido); só dados públicos.',
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'runningAgain2026',
|
||||
type: 'select',
|
||||
label: 'Concorre de novo em 2026',
|
||||
required: true,
|
||||
defaultValue: 'desconhecido',
|
||||
options: [...ELECTION_RUNNING_AGAIN_OPTIONS],
|
||||
},
|
||||
],
|
||||
}
|
||||
158
src/collections/ElectionCandidateVote.ts
Normal file
158
src/collections/ElectionCandidateVote.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { canMutateElectionData, canReadElectionData } from '@/utilities/campaignAccess'
|
||||
|
||||
import {
|
||||
ELECTION_OFFICE_OPTIONS,
|
||||
ELECTION_TURN_OPTIONS,
|
||||
ELECTION_VOTE_TYPE_OPTIONS,
|
||||
} from '@/lib/electionResults'
|
||||
|
||||
const slug = 'electionCandidateVote'
|
||||
|
||||
export const ElectionCandidateVote: CollectionConfig<typeof slug> = {
|
||||
slug,
|
||||
labels: {
|
||||
singular: 'Voto por candidato',
|
||||
plural: 'Votos por candidato',
|
||||
},
|
||||
admin: {
|
||||
group: 'Dados Eleitorais',
|
||||
useAsTitle: 'candidateName',
|
||||
defaultColumns: [
|
||||
'year',
|
||||
'office',
|
||||
'cityName',
|
||||
'zoneNumber',
|
||||
'candidateNumber',
|
||||
'votes',
|
||||
'updatedAt',
|
||||
],
|
||||
description: 'Votos nominais oficiais TSE por município e zona (dado público).',
|
||||
},
|
||||
access: {
|
||||
create: canMutateElectionData,
|
||||
read: canReadElectionData,
|
||||
update: canMutateElectionData,
|
||||
delete: canMutateElectionData,
|
||||
},
|
||||
indexes: [
|
||||
{
|
||||
fields: [
|
||||
'year',
|
||||
'office',
|
||||
'turn',
|
||||
'state',
|
||||
'cityCode',
|
||||
'zoneNumber',
|
||||
'candidateNumber',
|
||||
'voteType',
|
||||
],
|
||||
unique: true,
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
name: 'year',
|
||||
type: 'number',
|
||||
label: 'Ano',
|
||||
required: true,
|
||||
index: true,
|
||||
min: 2000,
|
||||
max: 2100,
|
||||
},
|
||||
{
|
||||
name: 'office',
|
||||
type: 'select',
|
||||
label: 'Cargo',
|
||||
required: true,
|
||||
index: true,
|
||||
options: [...ELECTION_OFFICE_OPTIONS],
|
||||
},
|
||||
{
|
||||
name: 'turn',
|
||||
type: 'select',
|
||||
label: 'Turno',
|
||||
required: true,
|
||||
index: true,
|
||||
options: [...ELECTION_TURN_OPTIONS],
|
||||
},
|
||||
{
|
||||
name: 'state',
|
||||
type: 'text',
|
||||
label: 'UF',
|
||||
required: true,
|
||||
defaultValue: 'BA',
|
||||
maxLength: 2,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'cityCode',
|
||||
type: 'text',
|
||||
label: 'Código do município (TSE)',
|
||||
required: true,
|
||||
index: true,
|
||||
maxLength: 16,
|
||||
},
|
||||
{
|
||||
name: 'cityName',
|
||||
type: 'text',
|
||||
label: 'Município',
|
||||
required: true,
|
||||
index: true,
|
||||
maxLength: 120,
|
||||
},
|
||||
{
|
||||
name: 'zoneNumber',
|
||||
type: 'number',
|
||||
label: 'Zona TSE',
|
||||
required: true,
|
||||
index: true,
|
||||
min: 1,
|
||||
max: 999,
|
||||
},
|
||||
{
|
||||
name: 'candidateNumber',
|
||||
type: 'number',
|
||||
label: 'Número do candidato',
|
||||
required: true,
|
||||
index: true,
|
||||
min: 0,
|
||||
},
|
||||
{
|
||||
name: 'candidateName',
|
||||
type: 'text',
|
||||
label: 'Nome na urna',
|
||||
required: true,
|
||||
maxLength: 160,
|
||||
},
|
||||
{
|
||||
name: 'coalition',
|
||||
type: 'text',
|
||||
label: 'Coligação',
|
||||
maxLength: 240,
|
||||
},
|
||||
{
|
||||
name: 'party',
|
||||
type: 'text',
|
||||
label: 'Partido',
|
||||
maxLength: 40,
|
||||
},
|
||||
{
|
||||
name: 'voteType',
|
||||
type: 'select',
|
||||
label: 'Tipo de voto',
|
||||
required: true,
|
||||
index: true,
|
||||
defaultValue: 'nominal',
|
||||
options: [...ELECTION_VOTE_TYPE_OPTIONS],
|
||||
},
|
||||
{
|
||||
name: 'votes',
|
||||
type: 'number',
|
||||
label: 'Votos',
|
||||
required: true,
|
||||
min: 1,
|
||||
},
|
||||
],
|
||||
}
|
||||
188
src/collections/ElectionTally.ts
Normal file
188
src/collections/ElectionTally.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { canMutateElectionData, canReadElectionData } from '@/utilities/campaignAccess'
|
||||
|
||||
import { ELECTION_OFFICE_OPTIONS, ELECTION_TURN_OPTIONS } from '@/lib/electionResults'
|
||||
|
||||
const slug = 'electionTally'
|
||||
|
||||
export const ElectionTally: CollectionConfig<typeof slug> = {
|
||||
slug,
|
||||
labels: {
|
||||
singular: 'Apuração eleitoral',
|
||||
plural: 'Apurações eleitorais',
|
||||
},
|
||||
admin: {
|
||||
group: 'Dados Eleitorais',
|
||||
useAsTitle: 'cityName',
|
||||
defaultColumns: ['year', 'office', 'turn', 'cityName', 'zoneNumber', 'updatedAt'],
|
||||
description: 'Totais oficiais TSE por município e zona (dado público).',
|
||||
},
|
||||
access: {
|
||||
create: canMutateElectionData,
|
||||
read: canReadElectionData,
|
||||
update: canMutateElectionData,
|
||||
delete: canMutateElectionData,
|
||||
},
|
||||
indexes: [
|
||||
{
|
||||
fields: ['year', 'office', 'turn', 'state', 'cityCode', 'zoneNumber'],
|
||||
unique: true,
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
name: 'year',
|
||||
type: 'number',
|
||||
label: 'Ano',
|
||||
required: true,
|
||||
index: true,
|
||||
min: 2000,
|
||||
max: 2100,
|
||||
},
|
||||
{
|
||||
name: 'office',
|
||||
type: 'select',
|
||||
label: 'Cargo',
|
||||
required: true,
|
||||
index: true,
|
||||
options: [...ELECTION_OFFICE_OPTIONS],
|
||||
},
|
||||
{
|
||||
name: 'turn',
|
||||
type: 'select',
|
||||
label: 'Turno',
|
||||
required: true,
|
||||
index: true,
|
||||
options: [...ELECTION_TURN_OPTIONS],
|
||||
},
|
||||
{
|
||||
name: 'state',
|
||||
type: 'text',
|
||||
label: 'UF',
|
||||
required: true,
|
||||
defaultValue: 'BA',
|
||||
maxLength: 2,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'cityCode',
|
||||
type: 'text',
|
||||
label: 'Código do município (TSE)',
|
||||
required: true,
|
||||
index: true,
|
||||
maxLength: 16,
|
||||
},
|
||||
{
|
||||
name: 'cityName',
|
||||
type: 'text',
|
||||
label: 'Município',
|
||||
required: true,
|
||||
index: true,
|
||||
maxLength: 120,
|
||||
},
|
||||
{
|
||||
name: 'zoneNumber',
|
||||
type: 'number',
|
||||
label: 'Zona TSE',
|
||||
required: true,
|
||||
index: true,
|
||||
min: 1,
|
||||
max: 999,
|
||||
},
|
||||
{
|
||||
name: 'aptos',
|
||||
type: 'number',
|
||||
label: 'Eleitores aptos',
|
||||
required: true,
|
||||
min: 0,
|
||||
},
|
||||
{
|
||||
name: 'comparecimento',
|
||||
type: 'number',
|
||||
label: 'Comparecimento',
|
||||
required: true,
|
||||
min: 0,
|
||||
},
|
||||
{
|
||||
name: 'abstencoes',
|
||||
type: 'number',
|
||||
label: 'Abstenções',
|
||||
required: true,
|
||||
min: 0,
|
||||
},
|
||||
{
|
||||
name: 'votosValidos',
|
||||
type: 'number',
|
||||
label: 'Votos válidos',
|
||||
required: true,
|
||||
min: 0,
|
||||
},
|
||||
{
|
||||
name: 'votosNominaisValidos',
|
||||
type: 'number',
|
||||
label: 'Votos nominais válidos',
|
||||
required: true,
|
||||
min: 0,
|
||||
},
|
||||
{
|
||||
name: 'votosLegenda',
|
||||
type: 'number',
|
||||
label: 'Votos de legenda',
|
||||
required: true,
|
||||
min: 0,
|
||||
},
|
||||
{
|
||||
name: 'votosBranco',
|
||||
type: 'number',
|
||||
label: 'Votos em branco',
|
||||
required: true,
|
||||
min: 0,
|
||||
},
|
||||
{
|
||||
name: 'votosNulo',
|
||||
type: 'number',
|
||||
label: 'Votos nulos',
|
||||
required: true,
|
||||
min: 0,
|
||||
},
|
||||
{
|
||||
name: 'votosAnulados',
|
||||
type: 'number',
|
||||
label: 'Votos anulados',
|
||||
required: true,
|
||||
min: 0,
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
name: 'winnerCandidateNumber',
|
||||
type: 'number',
|
||||
label: 'Número do mais votado',
|
||||
min: 0,
|
||||
},
|
||||
{
|
||||
name: 'winnerCandidateName',
|
||||
type: 'text',
|
||||
label: 'Nome do mais votado',
|
||||
maxLength: 160,
|
||||
},
|
||||
{
|
||||
name: 'winnerVotes',
|
||||
type: 'number',
|
||||
label: 'Votos do mais votado',
|
||||
min: 0,
|
||||
},
|
||||
{
|
||||
name: 'winnerCoalition',
|
||||
type: 'text',
|
||||
label: 'Coligação do mais votado',
|
||||
maxLength: 240,
|
||||
},
|
||||
{
|
||||
name: 'winnerParty',
|
||||
type: 'text',
|
||||
label: 'Partido do mais votado',
|
||||
maxLength: 40,
|
||||
},
|
||||
],
|
||||
}
|
||||
30
src/lib/electionCandidateIdentity.ts
Normal file
30
src/lib/electionCandidateIdentity.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { createHash } from 'node:crypto'
|
||||
|
||||
import { normalizeMunicipalityKey } from '@/lib/electionResults'
|
||||
|
||||
/** Same NFD/case fold as municipality keys; empty string for missing parts. */
|
||||
export const normalizeIdentityPart = (value: string | null | undefined): string =>
|
||||
normalizeMunicipalityKey(value ?? '')
|
||||
|
||||
export type IdentityKeyInput = {
|
||||
urnaName: string | null | undefined
|
||||
birthCity: string | null | undefined
|
||||
birthState: string | null | undefined
|
||||
party: string | null | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* identityKey = sha256(normalize(urnaName) + birthCity + birthState + party)
|
||||
* Collisions (homonyms) and false negatives (party change) are expected;
|
||||
* admin review sets runningAgain2026 before relying on the match.
|
||||
*/
|
||||
export const computeIdentityKey = (input: IdentityKeyInput): string => {
|
||||
const material = [
|
||||
normalizeIdentityPart(input.urnaName),
|
||||
normalizeIdentityPart(input.birthCity),
|
||||
normalizeIdentityPart(input.birthState),
|
||||
normalizeIdentityPart(input.party),
|
||||
].join('|')
|
||||
|
||||
return createHash('sha256').update(material, 'utf8').digest('hex')
|
||||
}
|
||||
317
src/lib/electionResults.ts
Normal file
317
src/lib/electionResults.ts
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
import { bahiaMunicipalities } from '@/lib/bahiaTerritories'
|
||||
|
||||
export const ELECTION_YEAR_2022 = 2022 as const
|
||||
|
||||
export const ELECTION_OFFICE_OPTIONS = [
|
||||
{ label: 'Presidente', value: 'presidente' },
|
||||
{ label: 'Governador', value: 'governador' },
|
||||
{ label: 'Deputado federal', value: 'deputado_federal' },
|
||||
{ label: 'Deputado estadual', value: 'deputado_estadual' },
|
||||
] as const
|
||||
|
||||
export const ELECTION_OFFICES = ELECTION_OFFICE_OPTIONS.map((o) => o.value)
|
||||
export type ElectionOffice = (typeof ELECTION_OFFICES)[number]
|
||||
|
||||
export const ELECTION_TURN_OPTIONS = [
|
||||
{ label: '1º turno', value: '1' },
|
||||
{ label: '2º turno', value: '2' },
|
||||
] as const
|
||||
|
||||
export const ELECTION_TURNS = ELECTION_TURN_OPTIONS.map((o) => o.value)
|
||||
export type ElectionTurn = (typeof ELECTION_TURNS)[number]
|
||||
|
||||
export const ELECTION_VOTE_TYPE_OPTIONS = [
|
||||
{ label: 'Nominal', value: 'nominal' },
|
||||
{ label: 'Legenda', value: 'legenda' },
|
||||
] as const
|
||||
|
||||
export const ELECTION_VOTE_TYPES = ELECTION_VOTE_TYPE_OPTIONS.map((o) => o.value)
|
||||
export type ElectionVoteType = (typeof ELECTION_VOTE_TYPES)[number]
|
||||
|
||||
export const ELECTION_ELECTED_BY_OPTIONS = [
|
||||
{ label: 'Quociente partidário', value: 'QP' },
|
||||
{ label: 'Média', value: 'média' },
|
||||
{ label: '2º turno', value: '2º turno' },
|
||||
] as const
|
||||
|
||||
export const ELECTION_ELECTED_BY = ELECTION_ELECTED_BY_OPTIONS.map((o) => o.value)
|
||||
export type ElectionElectedBy = (typeof ELECTION_ELECTED_BY)[number]
|
||||
|
||||
export const ELECTION_RUNNING_AGAIN_OPTIONS = [
|
||||
{ label: 'Sim', value: 'sim' },
|
||||
{ label: 'Não', value: 'nao' },
|
||||
{ label: 'Desconhecido', value: 'desconhecido' },
|
||||
] as const
|
||||
|
||||
export const ELECTION_RUNNING_AGAIN = ELECTION_RUNNING_AGAIN_OPTIONS.map((o) => o.value)
|
||||
export type ElectionRunningAgain = (typeof ELECTION_RUNNING_AGAIN)[number]
|
||||
|
||||
/** Solla — dep. federal BA, PT, nº 1313 (eleito em 2022). */
|
||||
export const SOLLA_CANDIDATE_NUMBER = 1313
|
||||
export const LULA_CANDIDATE_NUMBER = 13
|
||||
export const JERONIMO_CANDIDATE_NUMBER = 13
|
||||
|
||||
/** TSE CD_CARGO → office enum used in collections. */
|
||||
export const TSE_CARGO_TO_OFFICE: Readonly<Record<string, ElectionOffice>> = {
|
||||
'1': 'presidente',
|
||||
'3': 'governador',
|
||||
'6': 'deputado_federal',
|
||||
'7': 'deputado_estadual',
|
||||
}
|
||||
|
||||
export type ParsedElectedStatus = {
|
||||
elected: boolean
|
||||
electedBy: ElectionElectedBy | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Map TSE DS_SIT_TOT_TURNO to elected flag + electedBy.
|
||||
* Values observed in 2022 consulta_cand / votacao datasets.
|
||||
* When `turn` is `2` and the candidate is elected, electedBy defaults to `2º turno`
|
||||
* (TSE often writes plain "ELEITO" on the second-turn file).
|
||||
*/
|
||||
export const parseElectedStatus = (
|
||||
dsSitTotTurno: string | null | undefined,
|
||||
turn?: ElectionTurn | null,
|
||||
): ParsedElectedStatus => {
|
||||
const value = (dsSitTotTurno ?? '').trim().toUpperCase()
|
||||
if (!value || value === '#NULO#' || value === 'NÃO ELEITO' || value === 'NAO ELEITO') {
|
||||
return { elected: false, electedBy: null }
|
||||
}
|
||||
if (value.includes('2º TURNO') || value.includes('2O TURNO') || value.includes('2° TURNO')) {
|
||||
// "ELEITO … 2º TURNO" counts as elected; bare "2º TURNO" means advanced only.
|
||||
if (value.startsWith('ELEITO')) {
|
||||
return { elected: true, electedBy: '2º turno' }
|
||||
}
|
||||
return { elected: false, electedBy: null }
|
||||
}
|
||||
if (value.includes('MÉDIA') || value.includes('MEDIA')) {
|
||||
return { elected: true, electedBy: 'média' }
|
||||
}
|
||||
if (value.startsWith('ELEITO')) {
|
||||
return { elected: true, electedBy: turn === '2' ? '2º turno' : 'QP' }
|
||||
}
|
||||
return { elected: false, electedBy: null }
|
||||
}
|
||||
|
||||
/** Fold accents and case for municipality matching. */
|
||||
export const normalizeMunicipalityKey = (value: string): string =>
|
||||
value
|
||||
.normalize('NFD')
|
||||
.replace(/\p{M}/gu, '')
|
||||
.replace(/['’`]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
|
||||
const bahiaMunicipalityByKey = new Map(
|
||||
bahiaMunicipalities.map((name) => [normalizeMunicipalityKey(name), name]),
|
||||
)
|
||||
|
||||
/**
|
||||
* Known TSE NM_MUNICIPIO spellings that diverge from the SEPLAN/SECULT canonical
|
||||
* names in bahiaTerritories (2022 open-data files). Keys are normalizeMunicipalityKey outputs.
|
||||
*/
|
||||
const TSE_MUNICIPALITY_ALIASES: Readonly<Record<string, string>> = {
|
||||
// TSE: CAMACÃ → canonical Camacan
|
||||
CAMACA: 'Camacan',
|
||||
// TSE: "DIAS D ÁVILA" (space, no apostrophe) → Dias d'Ávila
|
||||
'DIAS D AVILA': "Dias d'Ávila",
|
||||
// TSE: MUQUÉM DO SÃO FRANCISCO → Muquém de São Francisco
|
||||
'MUQUEM DO SAO FRANCISCO': 'Muquém de São Francisco',
|
||||
// TSE: SANTA TEREZINHA → Santa Teresinha
|
||||
'SANTA TEREZINHA': 'Santa Teresinha',
|
||||
}
|
||||
|
||||
for (const [aliasKey, canonical] of Object.entries(TSE_MUNICIPALITY_ALIASES)) {
|
||||
if (!bahiaMunicipalityByKey.has(normalizeMunicipalityKey(canonical))) {
|
||||
throw new Error(`TSE alias points to unknown canonical municipality: ${canonical}`)
|
||||
}
|
||||
bahiaMunicipalityByKey.set(aliasKey, canonical)
|
||||
}
|
||||
|
||||
export class UnknownMunicipalityError extends Error {
|
||||
constructor(public readonly tseName: string) {
|
||||
super(`Município TSE sem mapeamento canônico: "${tseName}"`)
|
||||
this.name = 'UnknownMunicipalityError'
|
||||
}
|
||||
}
|
||||
|
||||
/** Cache raw TSE NM_MUNICIPIO → canonical (millions of repeated lookups on seed). */
|
||||
const municipalityResolveCache = new Map<string, string>()
|
||||
|
||||
/**
|
||||
* Map TSE NM_MUNICIPIO (usually ALL CAPS) to the canonical Bahia municipality name
|
||||
* used by bahiaTerritories / electoralNucleus.city. Fails closed on unknown names.
|
||||
*/
|
||||
export const canonicalizeMunicipalityName = (tseMunicipalityName: string): string => {
|
||||
const cached = municipalityResolveCache.get(tseMunicipalityName)
|
||||
if (cached) return cached
|
||||
|
||||
const key = normalizeMunicipalityKey(tseMunicipalityName)
|
||||
const canonical = bahiaMunicipalityByKey.get(key)
|
||||
if (!canonical) {
|
||||
throw new UnknownMunicipalityError(tseMunicipalityName)
|
||||
}
|
||||
municipalityResolveCache.set(tseMunicipalityName, canonical)
|
||||
return canonical
|
||||
}
|
||||
|
||||
export const isElectionTurn = (value: string): value is ElectionTurn =>
|
||||
(ELECTION_TURNS as readonly string[]).includes(value)
|
||||
|
||||
export type CandidateVoteRow = {
|
||||
year: number
|
||||
office: ElectionOffice
|
||||
turn: ElectionTurn
|
||||
state: string
|
||||
cityCode: string
|
||||
cityName: string
|
||||
zoneNumber: number
|
||||
candidateNumber: number
|
||||
candidateName: string
|
||||
coalition: string | null
|
||||
party: string | null
|
||||
voteType: ElectionVoteType
|
||||
votes: number
|
||||
}
|
||||
|
||||
/** Winner fields as stored on electionTally (null when no nominal votes in that cell). */
|
||||
export type TallyWinner = {
|
||||
winnerCandidateNumber: number | null
|
||||
winnerCandidateName: string | null
|
||||
winnerVotes: number | null
|
||||
winnerCoalition: string | null
|
||||
winnerParty: string | null
|
||||
}
|
||||
|
||||
type ResolvedWinner = {
|
||||
winnerCandidateNumber: number
|
||||
winnerCandidateName: string
|
||||
winnerVotes: number
|
||||
winnerCoalition: string | null
|
||||
winnerParty: string | null
|
||||
}
|
||||
|
||||
const EMPTY_WINNER: TallyWinner = {
|
||||
winnerCandidateNumber: null,
|
||||
winnerCandidateName: null,
|
||||
winnerVotes: null,
|
||||
winnerCoalition: null,
|
||||
winnerParty: null,
|
||||
}
|
||||
|
||||
export const winnerKey = (cityCode: string, zoneNumber: number, office: ElectionOffice, turn: ElectionTurn) =>
|
||||
`${cityCode}|${zoneNumber}|${office}|${turn}`
|
||||
|
||||
/**
|
||||
* Compute the local winner (highest nominal votes) for each city+zone+office+turn.
|
||||
* Ties: first seen candidate with the max vote count wins (stable for import order).
|
||||
*/
|
||||
export const computeWinnersByScope = (
|
||||
votes: readonly CandidateVoteRow[],
|
||||
): Map<string, ResolvedWinner> => {
|
||||
const winners = new Map<string, ResolvedWinner>()
|
||||
|
||||
for (const row of votes) {
|
||||
if (row.voteType !== 'nominal' || row.votes <= 0) continue
|
||||
const key = winnerKey(row.cityCode, row.zoneNumber, row.office, row.turn)
|
||||
const current = winners.get(key)
|
||||
if (!current || current.winnerVotes < row.votes) {
|
||||
winners.set(key, {
|
||||
winnerCandidateNumber: row.candidateNumber,
|
||||
winnerCandidateName: row.candidateName,
|
||||
winnerVotes: row.votes,
|
||||
winnerCoalition: row.coalition,
|
||||
winnerParty: row.party,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return winners
|
||||
}
|
||||
|
||||
export type TseDetalheApuracaoRow = {
|
||||
year: number
|
||||
office: ElectionOffice
|
||||
turn: ElectionTurn
|
||||
state: string
|
||||
cityCode: string
|
||||
cityName: string
|
||||
zoneNumber: number
|
||||
aptos: number
|
||||
comparecimento: number
|
||||
abstencoes: number
|
||||
votosValidos: number
|
||||
votosNominaisValidos: number
|
||||
votosLegenda: number
|
||||
votosBranco: number
|
||||
votosNulo: number
|
||||
votosAnulados: number
|
||||
}
|
||||
|
||||
export type ElectionTallyRow = TseDetalheApuracaoRow & TallyWinner
|
||||
|
||||
export type ElectionCandidateRow = {
|
||||
year: number
|
||||
office: ElectionOffice
|
||||
turn: ElectionTurn
|
||||
state: string
|
||||
candidateNumber: number
|
||||
urnaName: string
|
||||
completeName: string | null
|
||||
party: string | null
|
||||
coalition: string | null
|
||||
candidateStatus: string | null
|
||||
elected: boolean
|
||||
electedBy: ElectionElectedBy | null
|
||||
totalVotesState: number
|
||||
identityKey: string
|
||||
runningAgain2026: ElectionRunningAgain
|
||||
}
|
||||
|
||||
export const mergeTallyWithWinners = (
|
||||
tallies: readonly TseDetalheApuracaoRow[],
|
||||
winners: ReadonlyMap<string, ResolvedWinner>,
|
||||
): ElectionTallyRow[] =>
|
||||
tallies.map((tally) => {
|
||||
const winner =
|
||||
winners.get(winnerKey(tally.cityCode, tally.zoneNumber, tally.office, tally.turn)) ??
|
||||
EMPTY_WINNER
|
||||
return { ...tally, ...winner }
|
||||
})
|
||||
|
||||
/** Parse a TSE integer cell that may be "#NULO#" / empty. */
|
||||
export const parseTseInt = (value: string | undefined | null, fallback = 0): number => {
|
||||
if (value == null) return fallback
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed || trimmed === '#NULO#' || trimmed === '#NE#') return fallback
|
||||
const n = Number(trimmed)
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
}
|
||||
|
||||
export const parseTseOptionalText = (value: string | undefined | null): string | null => {
|
||||
if (value == null) return null
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed || trimmed === '#NULO#' || trimmed === '#NE#') return null
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export const officeFromTseCargo = (cdCargo: string): ElectionOffice | null => {
|
||||
const office = TSE_CARGO_TO_OFFICE[cdCargo.trim()]
|
||||
return office ?? null
|
||||
}
|
||||
|
||||
export const turnFromTse = (nrTurno: string): ElectionTurn | null => {
|
||||
const turn = nrTurno.trim()
|
||||
return isElectionTurn(turn) ? turn : null
|
||||
}
|
||||
|
||||
export const assertAllCanonicalMunicipalitiesResolvable = (): void => {
|
||||
for (const name of bahiaMunicipalities) {
|
||||
const resolved = canonicalizeMunicipalityName(name)
|
||||
if (resolved !== name) {
|
||||
throw new Error(`canonicalizeMunicipalityName drifted for "${name}" → "${resolved}"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
98
src/lib/electionResultsBuild.ts
Normal file
98
src/lib/electionResultsBuild.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import type {
|
||||
CandidateVoteRow,
|
||||
ElectionCandidateRow,
|
||||
ElectionOffice,
|
||||
TseDetalheApuracaoRow,
|
||||
} from '@/lib/electionResults'
|
||||
import { ELECTION_OFFICES, ELECTION_YEAR_2022, UnknownMunicipalityError } from '@/lib/electionResults'
|
||||
import {
|
||||
applyStateVoteTotals,
|
||||
dedupeCandidates,
|
||||
parseCandidateVoteRow,
|
||||
parseConsultaCandRow,
|
||||
parseDetalheApuracaoRow,
|
||||
type TseCsvRow,
|
||||
} from '@/lib/electionResultsParse'
|
||||
|
||||
const ALL_OFFICES = new Set<ElectionOffice>(ELECTION_OFFICES)
|
||||
const STATE_OFFICES = new Set<ElectionOffice>([
|
||||
'governador',
|
||||
'deputado_federal',
|
||||
'deputado_estadual',
|
||||
])
|
||||
const PRESIDENT_OFFICE = new Set<ElectionOffice>(['presidente'])
|
||||
|
||||
export type BuiltElectionResults = {
|
||||
votes: CandidateVoteRow[]
|
||||
tallies: TseDetalheApuracaoRow[]
|
||||
candidates: ElectionCandidateRow[]
|
||||
unknownMunicipalities: string[]
|
||||
}
|
||||
|
||||
const collectParsed = <T extends { year: number }>(
|
||||
rows: readonly TseCsvRow[],
|
||||
year: number,
|
||||
unknownMunicipalities: Set<string>,
|
||||
parseRow: (row: TseCsvRow) => T | null,
|
||||
): T[] => {
|
||||
const out: T[] = []
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const parsed = parseRow(row)
|
||||
if (parsed && parsed.year === year) out.push(parsed)
|
||||
} catch (error) {
|
||||
if (error instanceof UnknownMunicipalityError) {
|
||||
unknownMunicipalities.add(error.tseName)
|
||||
continue
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Build typed election rows from already-parsed TSE CSV tables.
|
||||
* Safe for fixtures and for the full seed (no I/O).
|
||||
*/
|
||||
export const buildElectionResultsFromCsvRows = (args: {
|
||||
voteRows: readonly TseCsvRow[]
|
||||
detalheRows: readonly TseCsvRow[]
|
||||
candBaRows: readonly TseCsvRow[]
|
||||
candBrRows: readonly TseCsvRow[]
|
||||
year?: number
|
||||
}): BuiltElectionResults => {
|
||||
const year = args.year ?? ELECTION_YEAR_2022
|
||||
const unknownMunicipalities = new Set<string>()
|
||||
|
||||
const votes = collectParsed(args.voteRows, year, unknownMunicipalities, (row) =>
|
||||
parseCandidateVoteRow(row, { stateFilter: 'BA', offices: ALL_OFFICES }),
|
||||
)
|
||||
const tallies = collectParsed(args.detalheRows, year, unknownMunicipalities, (row) =>
|
||||
parseDetalheApuracaoRow(row, { stateFilter: 'BA', offices: ALL_OFFICES }),
|
||||
)
|
||||
|
||||
const candidatesRaw: ElectionCandidateRow[] = []
|
||||
for (const row of args.candBaRows) {
|
||||
const parsed = parseConsultaCandRow(row, {
|
||||
stateFilter: 'BA',
|
||||
offices: STATE_OFFICES,
|
||||
})
|
||||
if (parsed && parsed.year === year) candidatesRaw.push(parsed)
|
||||
}
|
||||
for (const row of args.candBrRows) {
|
||||
const parsed = parseConsultaCandRow(row, {
|
||||
stateFilter: 'BR',
|
||||
offices: PRESIDENT_OFFICE,
|
||||
forceState: 'BA',
|
||||
})
|
||||
if (parsed && parsed.year === year) candidatesRaw.push(parsed)
|
||||
}
|
||||
|
||||
return {
|
||||
votes,
|
||||
tallies,
|
||||
candidates: applyStateVoteTotals(dedupeCandidates(candidatesRaw), votes),
|
||||
unknownMunicipalities: [...unknownMunicipalities].sort(),
|
||||
}
|
||||
}
|
||||
22
src/lib/electionResultsCsv.ts
Normal file
22
src/lib/electionResultsCsv.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { parse } from 'csv-parse/sync'
|
||||
|
||||
import type { TseCsvRow } from '@/lib/electionResultsParse'
|
||||
|
||||
/**
|
||||
* Parse a TSE open-data CSV buffer (Latin-1 / ISO-8859-1, `;` separator, quoted fields).
|
||||
*/
|
||||
export const parseTseCsvBuffer = (buffer: Buffer): TseCsvRow[] => {
|
||||
const text = buffer.toString('latin1')
|
||||
return parse(text, {
|
||||
columns: true,
|
||||
delimiter: ';',
|
||||
relax_quotes: true,
|
||||
relax_column_count: true,
|
||||
skip_empty_lines: true,
|
||||
trim: true,
|
||||
bom: true,
|
||||
}) as TseCsvRow[]
|
||||
}
|
||||
|
||||
export const parseTseCsvString = (text: string): TseCsvRow[] =>
|
||||
parseTseCsvBuffer(Buffer.from(text, 'utf8'))
|
||||
220
src/lib/electionResultsParse.ts
Normal file
220
src/lib/electionResultsParse.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
/** Pure parsers for TSE open-data CSVs (Latin-1, `;`-separated). */
|
||||
|
||||
import {
|
||||
canonicalizeMunicipalityName,
|
||||
officeFromTseCargo,
|
||||
parseElectedStatus,
|
||||
parseTseInt,
|
||||
parseTseOptionalText,
|
||||
turnFromTse,
|
||||
type CandidateVoteRow,
|
||||
type ElectionCandidateRow,
|
||||
type ElectionOffice,
|
||||
type ElectionTurn,
|
||||
type TseDetalheApuracaoRow,
|
||||
} from '@/lib/electionResults'
|
||||
import { computeIdentityKey } from '@/lib/electionCandidateIdentity'
|
||||
|
||||
export type TseCsvRow = Record<string, string>
|
||||
|
||||
const cell = (row: TseCsvRow, key: string): string => row[key] ?? ''
|
||||
|
||||
type ScopeParseOptions = {
|
||||
/** Uppercase UF (e.g. BA). Compared as-is against SG_UF — normalize at the call site. */
|
||||
stateFilter?: string
|
||||
offices?: ReadonlySet<ElectionOffice>
|
||||
}
|
||||
|
||||
const matchScope = (
|
||||
row: TseCsvRow,
|
||||
options: ScopeParseOptions,
|
||||
): { state: string; office: ElectionOffice; turn: ElectionTurn } | null => {
|
||||
const state = cell(row, 'SG_UF').trim().toUpperCase()
|
||||
if (options.stateFilter && state !== options.stateFilter) return null
|
||||
|
||||
const office = officeFromTseCargo(cell(row, 'CD_CARGO'))
|
||||
if (!office) return null
|
||||
if (options.offices && !options.offices.has(office)) return null
|
||||
|
||||
const turn = turnFromTse(cell(row, 'NR_TURNO'))
|
||||
if (!turn) return null
|
||||
|
||||
return { state, office, turn }
|
||||
}
|
||||
|
||||
export type ParseVoteOptions = ScopeParseOptions & {
|
||||
/** Skip zero-vote rows (default true — collection requires votes >= 1). */
|
||||
skipZeroVotes?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a votacao_candidato_munzona row into a CandidateVoteRow.
|
||||
* Returns null when the row is out of scope (wrong UF/cargo) or has zero votes.
|
||||
*/
|
||||
export const parseCandidateVoteRow = (
|
||||
row: TseCsvRow,
|
||||
options: ParseVoteOptions = {},
|
||||
): CandidateVoteRow | null => {
|
||||
const scope = matchScope(row, options)
|
||||
if (!scope) return null
|
||||
|
||||
const votes = parseTseInt(cell(row, 'QT_VOTOS_NOMINAIS'))
|
||||
if ((options.skipZeroVotes ?? true) && votes <= 0) return null
|
||||
|
||||
return {
|
||||
year: parseTseInt(cell(row, 'ANO_ELEICAO')),
|
||||
office: scope.office,
|
||||
turn: scope.turn,
|
||||
state: scope.state,
|
||||
cityCode: cell(row, 'CD_MUNICIPIO').trim(),
|
||||
cityName: canonicalizeMunicipalityName(cell(row, 'NM_MUNICIPIO')),
|
||||
zoneNumber: parseTseInt(cell(row, 'NR_ZONA')),
|
||||
candidateNumber: parseTseInt(cell(row, 'NR_CANDIDATO')),
|
||||
candidateName: parseTseOptionalText(cell(row, 'NM_URNA_CANDIDATO')) ?? '',
|
||||
coalition: parseTseOptionalText(cell(row, 'NM_COLIGACAO')),
|
||||
party: parseTseOptionalText(cell(row, 'SG_PARTIDO')),
|
||||
voteType: 'nominal',
|
||||
votes,
|
||||
}
|
||||
}
|
||||
|
||||
export type ParseDetalheOptions = ScopeParseOptions
|
||||
|
||||
/**
|
||||
* Parse detalhe_votacao_munzona into a tally row (without winner — filled later).
|
||||
*/
|
||||
export const parseDetalheApuracaoRow = (
|
||||
row: TseCsvRow,
|
||||
options: ParseDetalheOptions = {},
|
||||
): TseDetalheApuracaoRow | null => {
|
||||
const scope = matchScope(row, options)
|
||||
if (!scope) return null
|
||||
|
||||
// Prefer QT_VOTOS_VALIDOS when present; else nominais_validos + legenda.
|
||||
const votosNominaisValidos = parseTseInt(
|
||||
cell(row, 'QT_VOTOS_NOMINAIS_VALIDOS') || cell(row, 'QT_VOTOS_NOMINAIS'),
|
||||
)
|
||||
const votosLegenda = parseTseInt(cell(row, 'QT_VOTOS_LEGENDA'))
|
||||
const votosValidos =
|
||||
parseTseInt(cell(row, 'QT_VOTOS_VALIDOS')) || votosNominaisValidos + votosLegenda
|
||||
|
||||
return {
|
||||
year: parseTseInt(cell(row, 'ANO_ELEICAO')),
|
||||
office: scope.office,
|
||||
turn: scope.turn,
|
||||
state: scope.state,
|
||||
cityCode: cell(row, 'CD_MUNICIPIO').trim(),
|
||||
cityName: canonicalizeMunicipalityName(cell(row, 'NM_MUNICIPIO')),
|
||||
zoneNumber: parseTseInt(cell(row, 'NR_ZONA')),
|
||||
aptos: parseTseInt(cell(row, 'QT_APTOS')),
|
||||
comparecimento: parseTseInt(cell(row, 'QT_COMPARECIMENTO')),
|
||||
abstencoes: parseTseInt(cell(row, 'QT_ABSTENCOES')),
|
||||
votosValidos,
|
||||
votosNominaisValidos,
|
||||
votosLegenda,
|
||||
votosBranco: parseTseInt(cell(row, 'QT_VOTOS_BRANCOS')),
|
||||
votosNulo: parseTseInt(cell(row, 'QT_VOTOS_NULOS')),
|
||||
votosAnulados: parseTseInt(
|
||||
cell(row, 'QT_VOTOS_ANULADOS') || cell(row, 'QT_VOTOS_ANULADOS_APU_SEP'),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export type ParseCandidateOptions = ScopeParseOptions & {
|
||||
/** Override state stored (presidente candidacies are national; store BA for our model). */
|
||||
forceState?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse consulta_cand into ElectionCandidateRow.
|
||||
* President candidates live in the BR file (SG_UF=BR); we store state=BA for the BA campaign model.
|
||||
*/
|
||||
export const parseConsultaCandRow = (
|
||||
row: TseCsvRow,
|
||||
options: ParseCandidateOptions = {},
|
||||
): ElectionCandidateRow | null => {
|
||||
const scope = matchScope(row, options)
|
||||
if (!scope) return null
|
||||
|
||||
const urnaName = parseTseOptionalText(cell(row, 'NM_URNA_CANDIDATO')) ?? ''
|
||||
const party = parseTseOptionalText(cell(row, 'SG_PARTIDO'))
|
||||
const birthCity = parseTseOptionalText(cell(row, 'NM_MUNICIPIO_NASCIMENTO'))
|
||||
const birthState = parseTseOptionalText(cell(row, 'SG_UF_NASCIMENTO'))
|
||||
const { elected, electedBy } = parseElectedStatus(cell(row, 'DS_SIT_TOT_TURNO'), scope.turn)
|
||||
|
||||
return {
|
||||
year: parseTseInt(cell(row, 'ANO_ELEICAO')),
|
||||
office: scope.office,
|
||||
turn: scope.turn,
|
||||
state: (options.forceState ?? scope.state).toUpperCase(),
|
||||
candidateNumber: parseTseInt(cell(row, 'NR_CANDIDATO')),
|
||||
urnaName,
|
||||
completeName: parseTseOptionalText(cell(row, 'NM_CANDIDATO')),
|
||||
party,
|
||||
coalition: parseTseOptionalText(cell(row, 'NM_COLIGACAO')),
|
||||
candidateStatus: parseTseOptionalText(cell(row, 'DS_SITUACAO_CANDIDATURA')),
|
||||
elected,
|
||||
electedBy,
|
||||
totalVotesState: 0,
|
||||
identityKey: computeIdentityKey({ urnaName, birthCity, birthState, party }),
|
||||
runningAgain2026: 'desconhecido',
|
||||
}
|
||||
}
|
||||
|
||||
export type ScopeKey = {
|
||||
year: number
|
||||
office: ElectionOffice
|
||||
turn: ElectionTurn
|
||||
}
|
||||
|
||||
export const scopeKeyString = (scope: ScopeKey): string =>
|
||||
`${scope.year}|${scope.office}|${scope.turn}`
|
||||
|
||||
const candidateAggregateKey = (row: {
|
||||
year: number
|
||||
office: ElectionOffice
|
||||
turn: ElectionTurn
|
||||
state: string
|
||||
candidateNumber: number
|
||||
}): string =>
|
||||
`${row.year}|${row.office}|${row.turn}|${row.state}|${row.candidateNumber}`
|
||||
|
||||
/** Deduplicate candidate rows by unique key, preferring elected=true when colliding. */
|
||||
export const dedupeCandidates = (rows: readonly ElectionCandidateRow[]): ElectionCandidateRow[] => {
|
||||
const map = new Map<string, ElectionCandidateRow>()
|
||||
for (const row of rows) {
|
||||
const key = candidateAggregateKey(row)
|
||||
const existing = map.get(key)
|
||||
if (!existing || (!existing.elected && row.elected)) {
|
||||
map.set(key, row)
|
||||
}
|
||||
}
|
||||
return [...map.values()]
|
||||
}
|
||||
|
||||
/** Aggregate nominal votes per candidate across all zones → totalVotesState. */
|
||||
export const applyStateVoteTotals = (
|
||||
candidates: readonly ElectionCandidateRow[],
|
||||
votes: readonly CandidateVoteRow[],
|
||||
): ElectionCandidateRow[] => {
|
||||
const totals = new Map<string, number>()
|
||||
for (const vote of votes) {
|
||||
const key = candidateAggregateKey(vote)
|
||||
totals.set(key, (totals.get(key) ?? 0) + vote.votes)
|
||||
}
|
||||
return candidates.map((candidate) => ({
|
||||
...candidate,
|
||||
totalVotesState: totals.get(candidateAggregateKey(candidate)) ?? 0,
|
||||
}))
|
||||
}
|
||||
|
||||
export const groupByScope = <T extends ScopeKey>(rows: readonly T[]): Map<string, T[]> => {
|
||||
const map = new Map<string, T[]>()
|
||||
for (const row of rows) {
|
||||
const key = scopeKeyString(row)
|
||||
const list = map.get(key)
|
||||
if (list) list.push(row)
|
||||
else map.set(key, [row])
|
||||
}
|
||||
return map
|
||||
}
|
||||
69
src/lib/electionResultsZip.ts
Normal file
69
src/lib/electionResultsZip.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { fromBuffer as yauzlFromBuffer } from 'yauzl'
|
||||
|
||||
/**
|
||||
* Read a single entry from a ZIP buffer by exact filename or suffix match.
|
||||
* Returns null when no matching entry exists.
|
||||
*/
|
||||
export const readZipEntry = async (
|
||||
zipBuffer: Buffer,
|
||||
matcher: string | ((fileName: string) => boolean),
|
||||
): Promise<{ fileName: string; buffer: Buffer } | null> => {
|
||||
const match =
|
||||
typeof matcher === 'string'
|
||||
? (name: string) => name === matcher || name.endsWith(`/${matcher}`) || name.endsWith(matcher)
|
||||
: matcher
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
yauzlFromBuffer(zipBuffer, { lazyEntries: true }, (err, zipfile) => {
|
||||
if (err || !zipfile) {
|
||||
reject(err ?? new Error('Falha ao abrir ZIP'))
|
||||
return
|
||||
}
|
||||
|
||||
let settled = false
|
||||
const finish = (value: { fileName: string; buffer: Buffer } | null) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
try {
|
||||
zipfile.close()
|
||||
} catch {
|
||||
// ignore close errors after resolve
|
||||
}
|
||||
resolve(value)
|
||||
}
|
||||
|
||||
zipfile.readEntry()
|
||||
zipfile.on('entry', (entry) => {
|
||||
if (settled) return
|
||||
if (/\/$/.test(entry.fileName) || !match(entry.fileName)) {
|
||||
zipfile.readEntry()
|
||||
return
|
||||
}
|
||||
zipfile.openReadStream(entry, (streamErr, readStream) => {
|
||||
if (streamErr || !readStream) {
|
||||
reject(streamErr ?? new Error(`Falha ao ler ${entry.fileName}`))
|
||||
return
|
||||
}
|
||||
const chunks: Buffer[] = []
|
||||
readStream.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
readStream.on('error', reject)
|
||||
readStream.on('end', () => {
|
||||
finish({ fileName: entry.fileName, buffer: Buffer.concat(chunks) })
|
||||
})
|
||||
})
|
||||
})
|
||||
zipfile.on('end', () => {
|
||||
finish(null)
|
||||
})
|
||||
zipfile.on('error', reject)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const downloadToBuffer = async (url: string): Promise<Buffer> => {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Download falhou (${response.status}): ${url}`)
|
||||
}
|
||||
return Buffer.from(await response.arrayBuffer())
|
||||
}
|
||||
7709
src/migrations/20260718_195854_add_election_results.json
Normal file
7709
src/migrations/20260718_195854_add_election_results.json
Normal file
File diff suppressed because it is too large
Load diff
153
src/migrations/20260718_195854_add_election_results.ts
Normal file
153
src/migrations/20260718_195854_add_election_results.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TYPE "public"."enum_election_tally_office" AS ENUM('presidente', 'governador', 'deputado_federal', 'deputado_estadual');
|
||||
CREATE TYPE "public"."enum_election_tally_turn" AS ENUM('1', '2');
|
||||
CREATE TYPE "public"."enum_election_candidate_vote_office" AS ENUM('presidente', 'governador', 'deputado_federal', 'deputado_estadual');
|
||||
CREATE TYPE "public"."enum_election_candidate_vote_turn" AS ENUM('1', '2');
|
||||
CREATE TYPE "public"."enum_election_candidate_vote_vote_type" AS ENUM('nominal', 'legenda');
|
||||
CREATE TYPE "public"."enum_election_candidate_office" AS ENUM('presidente', 'governador', 'deputado_federal', 'deputado_estadual');
|
||||
CREATE TYPE "public"."enum_election_candidate_turn" AS ENUM('1', '2');
|
||||
CREATE TYPE "public"."enum_election_candidate_elected_by" AS ENUM('QP', 'média', '2º turno');
|
||||
CREATE TYPE "public"."enum_election_candidate_running_again2026" AS ENUM('sim', 'nao', 'desconhecido');
|
||||
CREATE TABLE "election_tally" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"year" numeric NOT NULL,
|
||||
"office" "enum_election_tally_office" NOT NULL,
|
||||
"turn" "enum_election_tally_turn" NOT NULL,
|
||||
"state" varchar DEFAULT 'BA' NOT NULL,
|
||||
"city_code" varchar NOT NULL,
|
||||
"city_name" varchar NOT NULL,
|
||||
"zone_number" numeric NOT NULL,
|
||||
"aptos" numeric NOT NULL,
|
||||
"comparecimento" numeric NOT NULL,
|
||||
"abstencoes" numeric NOT NULL,
|
||||
"votos_validos" numeric NOT NULL,
|
||||
"votos_nominais_validos" numeric NOT NULL,
|
||||
"votos_legenda" numeric NOT NULL,
|
||||
"votos_branco" numeric NOT NULL,
|
||||
"votos_nulo" numeric NOT NULL,
|
||||
"votos_anulados" numeric DEFAULT 0 NOT NULL,
|
||||
"winner_candidate_number" numeric,
|
||||
"winner_candidate_name" varchar,
|
||||
"winner_votes" numeric,
|
||||
"winner_coalition" varchar,
|
||||
"winner_party" varchar,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "election_candidate_vote" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"year" numeric NOT NULL,
|
||||
"office" "enum_election_candidate_vote_office" NOT NULL,
|
||||
"turn" "enum_election_candidate_vote_turn" NOT NULL,
|
||||
"state" varchar DEFAULT 'BA' NOT NULL,
|
||||
"city_code" varchar NOT NULL,
|
||||
"city_name" varchar NOT NULL,
|
||||
"zone_number" numeric NOT NULL,
|
||||
"candidate_number" numeric NOT NULL,
|
||||
"candidate_name" varchar NOT NULL,
|
||||
"coalition" varchar,
|
||||
"party" varchar,
|
||||
"vote_type" "enum_election_candidate_vote_vote_type" DEFAULT 'nominal' NOT NULL,
|
||||
"votes" numeric NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "election_candidate" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"year" numeric NOT NULL,
|
||||
"office" "enum_election_candidate_office" NOT NULL,
|
||||
"turn" "enum_election_candidate_turn" NOT NULL,
|
||||
"state" varchar DEFAULT 'BA' NOT NULL,
|
||||
"candidate_number" numeric NOT NULL,
|
||||
"urna_name" varchar NOT NULL,
|
||||
"complete_name" varchar,
|
||||
"party" varchar,
|
||||
"coalition" varchar,
|
||||
"candidate_status" varchar,
|
||||
"elected" boolean DEFAULT false,
|
||||
"elected_by" "enum_election_candidate_elected_by",
|
||||
"total_votes_state" numeric,
|
||||
"identity_key" varchar,
|
||||
"running_again2026" "enum_election_candidate_running_again2026" DEFAULT 'desconhecido' NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "election_tally_id" integer;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "election_candidate_vote_id" integer;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "election_candidate_id" integer;
|
||||
CREATE INDEX "election_tally_year_idx" ON "election_tally" USING btree ("year");
|
||||
CREATE INDEX "election_tally_office_idx" ON "election_tally" USING btree ("office");
|
||||
CREATE INDEX "election_tally_turn_idx" ON "election_tally" USING btree ("turn");
|
||||
CREATE INDEX "election_tally_state_idx" ON "election_tally" USING btree ("state");
|
||||
CREATE INDEX "election_tally_city_code_idx" ON "election_tally" USING btree ("city_code");
|
||||
CREATE INDEX "election_tally_city_name_idx" ON "election_tally" USING btree ("city_name");
|
||||
CREATE INDEX "election_tally_zone_number_idx" ON "election_tally" USING btree ("zone_number");
|
||||
CREATE INDEX "election_tally_updated_at_idx" ON "election_tally" USING btree ("updated_at");
|
||||
CREATE INDEX "election_tally_created_at_idx" ON "election_tally" USING btree ("created_at");
|
||||
CREATE UNIQUE INDEX "year_office_turn_state_cityCode_zoneNumber_idx" ON "election_tally" USING btree ("year","office","turn","state","city_code","zone_number");
|
||||
CREATE INDEX "election_candidate_vote_year_idx" ON "election_candidate_vote" USING btree ("year");
|
||||
CREATE INDEX "election_candidate_vote_office_idx" ON "election_candidate_vote" USING btree ("office");
|
||||
CREATE INDEX "election_candidate_vote_turn_idx" ON "election_candidate_vote" USING btree ("turn");
|
||||
CREATE INDEX "election_candidate_vote_state_idx" ON "election_candidate_vote" USING btree ("state");
|
||||
CREATE INDEX "election_candidate_vote_city_code_idx" ON "election_candidate_vote" USING btree ("city_code");
|
||||
CREATE INDEX "election_candidate_vote_city_name_idx" ON "election_candidate_vote" USING btree ("city_name");
|
||||
CREATE INDEX "election_candidate_vote_zone_number_idx" ON "election_candidate_vote" USING btree ("zone_number");
|
||||
CREATE INDEX "election_candidate_vote_candidate_number_idx" ON "election_candidate_vote" USING btree ("candidate_number");
|
||||
CREATE INDEX "election_candidate_vote_vote_type_idx" ON "election_candidate_vote" USING btree ("vote_type");
|
||||
CREATE INDEX "election_candidate_vote_updated_at_idx" ON "election_candidate_vote" USING btree ("updated_at");
|
||||
CREATE INDEX "election_candidate_vote_created_at_idx" ON "election_candidate_vote" USING btree ("created_at");
|
||||
CREATE UNIQUE INDEX "compound_index_idx" ON "election_candidate_vote" USING btree ("year","office","turn","state","city_code","zone_number","candidate_number","vote_type");
|
||||
CREATE INDEX "election_candidate_year_idx" ON "election_candidate" USING btree ("year");
|
||||
CREATE INDEX "election_candidate_office_idx" ON "election_candidate" USING btree ("office");
|
||||
CREATE INDEX "election_candidate_turn_idx" ON "election_candidate" USING btree ("turn");
|
||||
CREATE INDEX "election_candidate_state_idx" ON "election_candidate" USING btree ("state");
|
||||
CREATE INDEX "election_candidate_candidate_number_idx" ON "election_candidate" USING btree ("candidate_number");
|
||||
CREATE INDEX "election_candidate_elected_idx" ON "election_candidate" USING btree ("elected");
|
||||
CREATE INDEX "election_candidate_identity_key_idx" ON "election_candidate" USING btree ("identity_key");
|
||||
CREATE INDEX "election_candidate_updated_at_idx" ON "election_candidate" USING btree ("updated_at");
|
||||
CREATE INDEX "election_candidate_created_at_idx" ON "election_candidate" USING btree ("created_at");
|
||||
CREATE UNIQUE INDEX "year_office_turn_state_candidateNumber_idx" ON "election_candidate" USING btree ("year","office","turn","state","candidate_number");
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_election_tally_fk" FOREIGN KEY ("election_tally_id") REFERENCES "public"."election_tally"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_election_candidate_vote_fk" FOREIGN KEY ("election_candidate_vote_id") REFERENCES "public"."election_candidate_vote"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_election_candidate_fk" FOREIGN KEY ("election_candidate_id") REFERENCES "public"."election_candidate"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "payload_locked_documents_rels_election_tally_id_idx" ON "payload_locked_documents_rels" USING btree ("election_tally_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_election_candidate_vote_id_idx" ON "payload_locked_documents_rels" USING btree ("election_candidate_vote_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_election_candidate_id_idx" ON "payload_locked_documents_rels" USING btree ("election_candidate_id");`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "election_tally" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "election_candidate_vote" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "election_candidate" DISABLE ROW LEVEL SECURITY;
|
||||
DROP TABLE "election_tally" CASCADE;
|
||||
DROP TABLE "election_candidate_vote" CASCADE;
|
||||
DROP TABLE "election_candidate" CASCADE;
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_election_tally_fk";
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_election_candidate_vote_fk";
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_election_candidate_fk";
|
||||
|
||||
DROP INDEX "payload_locked_documents_rels_election_tally_id_idx";
|
||||
DROP INDEX "payload_locked_documents_rels_election_candidate_vote_id_idx";
|
||||
DROP INDEX "payload_locked_documents_rels_election_candidate_id_idx";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "election_tally_id";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "election_candidate_vote_id";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "election_candidate_id";
|
||||
DROP TYPE "public"."enum_election_tally_office";
|
||||
DROP TYPE "public"."enum_election_tally_turn";
|
||||
DROP TYPE "public"."enum_election_candidate_vote_office";
|
||||
DROP TYPE "public"."enum_election_candidate_vote_turn";
|
||||
DROP TYPE "public"."enum_election_candidate_vote_vote_type";
|
||||
DROP TYPE "public"."enum_election_candidate_office";
|
||||
DROP TYPE "public"."enum_election_candidate_turn";
|
||||
DROP TYPE "public"."enum_election_candidate_elected_by";
|
||||
DROP TYPE "public"."enum_election_candidate_running_again2026";`)
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import * as migration_20260715_181058_add_post_and_tag from './20260715_181058_a
|
|||
import * as migration_20260715_215834_rename_tag_visible_to_hidden from './20260715_215834_rename_tag_visible_to_hidden';
|
||||
import * as migration_20260716_010420_add_campaign_user from './20260716_010420_add_campaign_user';
|
||||
import * as migration_20260718_010733_consolidate_campaign_schema from './20260718_010733_consolidate_campaign_schema';
|
||||
import * as migration_20260718_195854_add_election_results from './20260718_195854_add_election_results';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -36,4 +37,10 @@ export const migrations = [
|
|||
down: migration_20260718_010733_consolidate_campaign_schema.down,
|
||||
name: '20260718_010733_consolidate_campaign_schema',
|
||||
},
|
||||
];
|
||||
{
|
||||
up: migration_20260718_195854_add_election_results.up,
|
||||
down: migration_20260718_195854_add_election_results.down,
|
||||
name: '20260718_195854_add_election_results',
|
||||
},
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -74,6 +74,9 @@ export interface Config {
|
|||
electoralNucleus: ElectoralNucleus;
|
||||
leadership: Leadership;
|
||||
nucleusUpdate: NucleusUpdate;
|
||||
electionTally: ElectionTally;
|
||||
electionCandidateVote: ElectionCandidateVote;
|
||||
electionCandidate: ElectionCandidate;
|
||||
media: Media;
|
||||
petition: Petition;
|
||||
contact: Contact;
|
||||
|
|
@ -96,6 +99,9 @@ export interface Config {
|
|||
electoralNucleus: ElectoralNucleusSelect<false> | ElectoralNucleusSelect<true>;
|
||||
leadership: LeadershipSelect<false> | LeadershipSelect<true>;
|
||||
nucleusUpdate: NucleusUpdateSelect<false> | NucleusUpdateSelect<true>;
|
||||
electionTally: ElectionTallySelect<false> | ElectionTallySelect<true>;
|
||||
electionCandidateVote: ElectionCandidateVoteSelect<false> | ElectionCandidateVoteSelect<true>;
|
||||
electionCandidate: ElectionCandidateSelect<false> | ElectionCandidateSelect<true>;
|
||||
media: MediaSelect<false> | MediaSelect<true>;
|
||||
petition: PetitionSelect<false> | PetitionSelect<true>;
|
||||
contact: ContactSelect<false> | ContactSelect<true>;
|
||||
|
|
@ -461,6 +467,91 @@ export interface NucleusUpdate {
|
|||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* Totais oficiais TSE por município e zona (dado público).
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "electionTally".
|
||||
*/
|
||||
export interface ElectionTally {
|
||||
id: number;
|
||||
year: number;
|
||||
office: 'presidente' | 'governador' | 'deputado_federal' | 'deputado_estadual';
|
||||
turn: '1' | '2';
|
||||
state: string;
|
||||
cityCode: string;
|
||||
cityName: string;
|
||||
zoneNumber: number;
|
||||
aptos: number;
|
||||
comparecimento: number;
|
||||
abstencoes: number;
|
||||
votosValidos: number;
|
||||
votosNominaisValidos: number;
|
||||
votosLegenda: number;
|
||||
votosBranco: number;
|
||||
votosNulo: number;
|
||||
votosAnulados: number;
|
||||
winnerCandidateNumber?: number | null;
|
||||
winnerCandidateName?: string | null;
|
||||
winnerVotes?: number | null;
|
||||
winnerCoalition?: string | null;
|
||||
winnerParty?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* Votos nominais oficiais TSE por município e zona (dado público).
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "electionCandidateVote".
|
||||
*/
|
||||
export interface ElectionCandidateVote {
|
||||
id: number;
|
||||
year: number;
|
||||
office: 'presidente' | 'governador' | 'deputado_federal' | 'deputado_estadual';
|
||||
turn: '1' | '2';
|
||||
state: string;
|
||||
cityCode: string;
|
||||
cityName: string;
|
||||
zoneNumber: number;
|
||||
candidateNumber: number;
|
||||
candidateName: string;
|
||||
coalition?: string | null;
|
||||
party?: string | null;
|
||||
voteType: 'nominal' | 'legenda';
|
||||
votes: number;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* Registro de candidatura TSE (dado público).
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "electionCandidate".
|
||||
*/
|
||||
export interface ElectionCandidate {
|
||||
id: number;
|
||||
year: number;
|
||||
office: 'presidente' | 'governador' | 'deputado_federal' | 'deputado_estadual';
|
||||
turn: '1' | '2';
|
||||
state: string;
|
||||
candidateNumber: number;
|
||||
urnaName: string;
|
||||
completeName?: string | null;
|
||||
party?: string | null;
|
||||
coalition?: string | null;
|
||||
candidateStatus?: string | null;
|
||||
elected?: boolean | null;
|
||||
electedBy?: ('QP' | 'média' | '2º turno') | null;
|
||||
totalVotesState?: number | null;
|
||||
/**
|
||||
* sha256(nome urna + naturalidade + partido); só dados públicos.
|
||||
*/
|
||||
identityKey?: string | null;
|
||||
runningAgain2026: 'sim' | 'nao' | 'desconhecido';
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "media".
|
||||
|
|
@ -735,6 +826,18 @@ export interface PayloadLockedDocument {
|
|||
relationTo: 'nucleusUpdate';
|
||||
value: number | NucleusUpdate;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'electionTally';
|
||||
value: number | ElectionTally;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'electionCandidateVote';
|
||||
value: number | ElectionCandidateVote;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'electionCandidate';
|
||||
value: number | ElectionCandidate;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'media';
|
||||
value: number | Media;
|
||||
|
|
@ -988,6 +1091,79 @@ export interface NucleusUpdateSelect<T extends boolean = true> {
|
|||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "electionTally_select".
|
||||
*/
|
||||
export interface ElectionTallySelect<T extends boolean = true> {
|
||||
year?: T;
|
||||
office?: T;
|
||||
turn?: T;
|
||||
state?: T;
|
||||
cityCode?: T;
|
||||
cityName?: T;
|
||||
zoneNumber?: T;
|
||||
aptos?: T;
|
||||
comparecimento?: T;
|
||||
abstencoes?: T;
|
||||
votosValidos?: T;
|
||||
votosNominaisValidos?: T;
|
||||
votosLegenda?: T;
|
||||
votosBranco?: T;
|
||||
votosNulo?: T;
|
||||
votosAnulados?: T;
|
||||
winnerCandidateNumber?: T;
|
||||
winnerCandidateName?: T;
|
||||
winnerVotes?: T;
|
||||
winnerCoalition?: T;
|
||||
winnerParty?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "electionCandidateVote_select".
|
||||
*/
|
||||
export interface ElectionCandidateVoteSelect<T extends boolean = true> {
|
||||
year?: T;
|
||||
office?: T;
|
||||
turn?: T;
|
||||
state?: T;
|
||||
cityCode?: T;
|
||||
cityName?: T;
|
||||
zoneNumber?: T;
|
||||
candidateNumber?: T;
|
||||
candidateName?: T;
|
||||
coalition?: T;
|
||||
party?: T;
|
||||
voteType?: T;
|
||||
votes?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "electionCandidate_select".
|
||||
*/
|
||||
export interface ElectionCandidateSelect<T extends boolean = true> {
|
||||
year?: T;
|
||||
office?: T;
|
||||
turn?: T;
|
||||
state?: T;
|
||||
candidateNumber?: T;
|
||||
urnaName?: T;
|
||||
completeName?: T;
|
||||
party?: T;
|
||||
coalition?: T;
|
||||
candidateStatus?: T;
|
||||
elected?: T;
|
||||
electedBy?: T;
|
||||
totalVotesState?: T;
|
||||
identityKey?: T;
|
||||
runningAgain2026?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "media_select".
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ import { CampaignInvite } from './collections/CampaignInvite'
|
|||
import { CampaignUser } from './collections/CampaignUser'
|
||||
import { Consent } from './collections/Consent'
|
||||
import { Contact } from './collections/Contact'
|
||||
import { ElectionCandidate } from './collections/ElectionCandidate'
|
||||
import { ElectionCandidateVote } from './collections/ElectionCandidateVote'
|
||||
import { ElectionTally } from './collections/ElectionTally'
|
||||
import { ElectoralNucleus } from './collections/ElectoralNucleus'
|
||||
import { Leadership } from './collections/Leadership'
|
||||
import { Media } from './collections/Media'
|
||||
|
|
@ -46,6 +49,9 @@ export default buildConfig({
|
|||
ElectoralNucleus,
|
||||
Leadership,
|
||||
NucleusUpdate,
|
||||
ElectionTally,
|
||||
ElectionCandidateVote,
|
||||
ElectionCandidate,
|
||||
Media,
|
||||
Petition,
|
||||
Contact,
|
||||
|
|
|
|||
|
|
@ -296,6 +296,13 @@ export const canManageNucleusLifecycle: FieldAccess = async ({ req }) => {
|
|||
|
||||
export const canSetDerivedNucleusField: FieldAccess = ({ req }) => isPayloadAdmin(req.user)
|
||||
|
||||
/** Public TSE election data: any authenticated campaign or admin user may read. */
|
||||
export const canReadElectionData: Access = ({ req }) =>
|
||||
isPayloadAdmin(req.user) || isCampaignUser(req.user)
|
||||
|
||||
/** Election reference data is mutated only by Payload admins (or CLI with overrideAccess). */
|
||||
export const canMutateElectionData: Access = ({ req }) => isPayloadAdmin(req.user)
|
||||
|
||||
type DynamicFind = (args: {
|
||||
collection: string
|
||||
depth: number
|
||||
|
|
|
|||
206
src/utilities/electionResultsImport.ts
Normal file
206
src/utilities/electionResultsImport.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import { and, eq } from '@payloadcms/db-postgres/drizzle'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import type {
|
||||
CandidateVoteRow,
|
||||
ElectionCandidateRow,
|
||||
ElectionOffice,
|
||||
ElectionTallyRow,
|
||||
ElectionTurn,
|
||||
TseDetalheApuracaoRow,
|
||||
} from '@/lib/electionResults'
|
||||
import { computeWinnersByScope, mergeTallyWithWinners } from '@/lib/electionResults'
|
||||
import { groupByScope } from '@/lib/electionResultsParse'
|
||||
|
||||
const VOTE_TABLE = 'election_candidate_vote'
|
||||
const TALLY_TABLE = 'election_tally'
|
||||
const CANDIDATE_TABLE = 'election_candidate'
|
||||
|
||||
/** ~15 columns × 500 ≈ 7500 bind params — well under PG 65535. */
|
||||
const INSERT_CHUNK_SIZE = 500
|
||||
|
||||
export type ElectionImportScope = {
|
||||
year: number
|
||||
office: ElectionOffice
|
||||
turn: ElectionTurn
|
||||
}
|
||||
|
||||
export type ElectionImportBundle = {
|
||||
scope: ElectionImportScope
|
||||
votes: CandidateVoteRow[]
|
||||
tallies: TseDetalheApuracaoRow[]
|
||||
candidates: ElectionCandidateRow[]
|
||||
}
|
||||
|
||||
export type ElectionImportCounts = {
|
||||
scope: ElectionImportScope
|
||||
votesDeleted: number
|
||||
votesInserted: number
|
||||
talliesDeleted: number
|
||||
talliesInserted: number
|
||||
candidatesDeleted: number
|
||||
candidatesInserted: number
|
||||
}
|
||||
|
||||
type DrizzleDeleteResult = { rowCount?: number | null }
|
||||
|
||||
type DrizzleTx = {
|
||||
delete: (table: unknown) => {
|
||||
where: (condition: unknown) => Promise<DrizzleDeleteResult>
|
||||
}
|
||||
insert: (table: unknown) => {
|
||||
values: (rows: Record<string, unknown>[]) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
type PayloadDb = {
|
||||
drizzle: {
|
||||
transaction: <T>(fn: (tx: DrizzleTx) => Promise<T>) => Promise<T>
|
||||
}
|
||||
tables: Record<string, Record<string, unknown>>
|
||||
}
|
||||
|
||||
const chunk = <T>(rows: T[], size: number): T[][] => {
|
||||
const out: T[][] = []
|
||||
for (let i = 0; i < rows.length; i += size) out.push(rows.slice(i, i + size))
|
||||
return out
|
||||
}
|
||||
|
||||
const getDb = (payload: Payload): PayloadDb => payload.db as unknown as PayloadDb
|
||||
|
||||
const requireTable = (db: PayloadDb, name: string) => {
|
||||
const table = db.tables[name]
|
||||
if (!table) throw new Error(`Tabela drizzle ausente: ${name}. Rode as migrations.`)
|
||||
return table
|
||||
}
|
||||
|
||||
const scopeWhere = (table: Record<string, unknown>, scope: ElectionImportScope) =>
|
||||
and(
|
||||
eq(table.year as never, scope.year),
|
||||
eq(table.office as never, scope.office),
|
||||
eq(table.turn as never, scope.turn),
|
||||
)
|
||||
|
||||
const deleteScope = async (
|
||||
tx: DrizzleTx,
|
||||
table: Record<string, unknown>,
|
||||
scope: ElectionImportScope,
|
||||
): Promise<number> => {
|
||||
// Avoid DELETE … RETURNING id (materializes every deleted row in memory on re-seed).
|
||||
const result = await tx.delete(table).where(scopeWhere(table, scope))
|
||||
return result.rowCount ?? 0
|
||||
}
|
||||
|
||||
const nowIso = () => new Date().toISOString()
|
||||
|
||||
const withTimestamps = <T extends Record<string, unknown>>(row: T, now: string) => ({
|
||||
...row,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
/**
|
||||
* Replace all election data for a single (year, office, turn) scope in one transaction:
|
||||
* delete existing rows, then bulk-insert the provided bundle.
|
||||
*/
|
||||
export const importElectionScope = async (
|
||||
payload: Payload,
|
||||
bundle: ElectionImportBundle,
|
||||
): Promise<ElectionImportCounts> => {
|
||||
const dbAdapter = getDb(payload)
|
||||
const voteTable = requireTable(dbAdapter, VOTE_TABLE)
|
||||
const tallyTable = requireTable(dbAdapter, TALLY_TABLE)
|
||||
const candidateTable = requireTable(dbAdapter, CANDIDATE_TABLE)
|
||||
|
||||
const winners = computeWinnersByScope(bundle.votes)
|
||||
const talliesWithWinners = mergeTallyWithWinners(bundle.tallies, winners)
|
||||
const now = nowIso()
|
||||
|
||||
return dbAdapter.drizzle.transaction(async (tx) => {
|
||||
const votesDeleted = await deleteScope(tx, voteTable, bundle.scope)
|
||||
const talliesDeleted = await deleteScope(tx, tallyTable, bundle.scope)
|
||||
const candidatesDeleted = await deleteScope(tx, candidateTable, bundle.scope)
|
||||
|
||||
for (const batch of chunk(bundle.votes, INSERT_CHUNK_SIZE)) {
|
||||
if (batch.length > 0) {
|
||||
await tx.insert(voteTable).values(batch.map((row) => withTimestamps(row, now)))
|
||||
}
|
||||
}
|
||||
|
||||
for (const batch of chunk(talliesWithWinners, INSERT_CHUNK_SIZE)) {
|
||||
if (batch.length > 0) {
|
||||
await tx.insert(tallyTable).values(
|
||||
batch.map((row: ElectionTallyRow) => withTimestamps(row, now)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (const batch of chunk(bundle.candidates, INSERT_CHUNK_SIZE)) {
|
||||
if (batch.length > 0) {
|
||||
await tx.insert(candidateTable).values(batch.map((row) => withTimestamps(row, now)))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
scope: bundle.scope,
|
||||
votesDeleted,
|
||||
votesInserted: bundle.votes.length,
|
||||
talliesDeleted,
|
||||
talliesInserted: talliesWithWinners.length,
|
||||
candidatesDeleted,
|
||||
candidatesInserted: bundle.candidates.length,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Import many scopes sequentially (each scope is its own transaction). */
|
||||
export const importElectionBundles = async (
|
||||
payload: Payload,
|
||||
bundles: readonly ElectionImportBundle[],
|
||||
): Promise<ElectionImportCounts[]> => {
|
||||
const results: ElectionImportCounts[] = []
|
||||
for (const bundle of bundles) {
|
||||
results.push(await importElectionScope(payload, bundle))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Build import bundles from flat parsed arrays, grouping by (year, office, turn).
|
||||
* Candidates without matching vote/tally scopes are still imported under their own scope.
|
||||
*/
|
||||
export const buildImportBundles = (args: {
|
||||
votes: readonly CandidateVoteRow[]
|
||||
tallies: readonly TseDetalheApuracaoRow[]
|
||||
candidates: readonly ElectionCandidateRow[]
|
||||
}): ElectionImportBundle[] => {
|
||||
const votesByScope = groupByScope(args.votes)
|
||||
const talliesByScope = groupByScope(args.tallies)
|
||||
const candidatesByScope = groupByScope(args.candidates)
|
||||
|
||||
const keys = new Set([
|
||||
...votesByScope.keys(),
|
||||
...talliesByScope.keys(),
|
||||
...candidatesByScope.keys(),
|
||||
])
|
||||
|
||||
return [...keys].map((key) => {
|
||||
const sample =
|
||||
votesByScope.get(key)?.[0] ??
|
||||
talliesByScope.get(key)?.[0] ??
|
||||
candidatesByScope.get(key)?.[0]
|
||||
if (!sample) {
|
||||
throw new Error(`Empty election import scope key: ${key}`)
|
||||
}
|
||||
return {
|
||||
scope: {
|
||||
year: sample.year,
|
||||
office: sample.office,
|
||||
turn: sample.turn,
|
||||
},
|
||||
votes: votesByScope.get(key) ?? [],
|
||||
tallies: talliesByScope.get(key) ?? [],
|
||||
candidates: candidatesByScope.get(key) ?? [],
|
||||
}
|
||||
})
|
||||
}
|
||||
8
tests/fixtures/tse/consulta_cand_ba_fixture.csv
vendored
Normal file
8
tests/fixtures/tse/consulta_cand_ba_fixture.csv
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
ANO_ELEICAO;NR_TURNO;SG_UF;CD_CARGO;NR_CANDIDATO;NM_URNA_CANDIDATO;NM_CANDIDATO;SG_PARTIDO;NM_COLIGACAO;DS_SITUACAO_CANDIDATURA;DS_SIT_TOT_TURNO;NM_MUNICIPIO_NASCIMENTO;SG_UF_NASCIMENTO
|
||||
2022;1;BA;6;1313;JORGE SOLLA;JORGE JOSE SANTOS PEREIRA SOLLA;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;DEFERIDO;ELEITO;SALVADOR;BA
|
||||
2022;1;BA;6;2222;RIVAL FEDERAL;RIVAL DA SILVA;PL;PARTIDO LIBERAL;DEFERIDO;#NULO#;FEIRA DE SANTANA;BA
|
||||
2022;1;BA;7;13131;ESTADUAL PT;CANDIDATO ESTADUAL;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;DEFERIDO;#NULO#;SALVADOR;BA
|
||||
2022;1;BA;3;13;JERÔNIMO;JERONIMO RODRIGUES SOUZA;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;DEFERIDO;2º TURNO;ACAJUTIBA;BA
|
||||
2022;2;BA;3;13;JERÔNIMO;JERONIMO RODRIGUES SOUZA;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;DEFERIDO;ELEITO;ACAJUTIBA;BA
|
||||
2022;1;BA;3;45;RIVAL GOV;RIVAL GOVERNADOR;PSDB;COLIGAÇÃO OPOSIÇÃO;DEFERIDO;2º TURNO;SALVADOR;BA
|
||||
2022;2;BA;3;45;RIVAL GOV;RIVAL GOVERNADOR;PSDB;COLIGAÇÃO OPOSIÇÃO;DEFERIDO;NÃO ELEITO;SALVADOR;BA
|
||||
|
5
tests/fixtures/tse/consulta_cand_br_fixture.csv
vendored
Normal file
5
tests/fixtures/tse/consulta_cand_br_fixture.csv
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
ANO_ELEICAO;NR_TURNO;SG_UF;CD_CARGO;NR_CANDIDATO;NM_URNA_CANDIDATO;NM_CANDIDATO;SG_PARTIDO;NM_COLIGACAO;DS_SITUACAO_CANDIDATURA;DS_SIT_TOT_TURNO;NM_MUNICIPIO_NASCIMENTO;SG_UF_NASCIMENTO
|
||||
2022;1;BR;1;13;LULA;LUIZ INACIO LULA DA SILVA;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;DEFERIDO;2º TURNO;GARANHUNS;PE
|
||||
2022;2;BR;1;13;LULA;LUIZ INACIO LULA DA SILVA;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;DEFERIDO;ELEITO;GARANHUNS;PE
|
||||
2022;1;BR;1;22;BOLSONARO;JAIR MESSIAS BOLSONARO;PL;PARTIDO LIBERAL;DEFERIDO;2º TURNO;CAMPINAS;SP
|
||||
2022;2;BR;1;22;BOLSONARO;JAIR MESSIAS BOLSONARO;PL;PARTIDO LIBERAL;DEFERIDO;NÃO ELEITO;CAMPINAS;SP
|
||||
|
9
tests/fixtures/tse/detalhe_votacao_munzona_fixture.csv
vendored
Normal file
9
tests/fixtures/tse/detalhe_votacao_munzona_fixture.csv
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
ANO_ELEICAO;NR_TURNO;SG_UF;CD_MUNICIPIO;NM_MUNICIPIO;NR_ZONA;CD_CARGO;QT_APTOS;QT_COMPARECIMENTO;QT_ABSTENCOES;QT_VOTOS_VALIDOS;QT_VOTOS_NOMINAIS_VALIDOS;QT_VOTOS_LEGENDA;QT_VOTOS_BRANCOS;QT_VOTOS_NULOS;QT_VOTOS_ANULADOS
|
||||
2022;1;BA;38490;SALVADOR;1;6;10000;8000;2000;7500;7300;200;300;200;0
|
||||
2022;1;BA;38490;SALVADOR;2;6;9000;7200;1800;6800;6600;200;250;150;0
|
||||
2022;1;BA;35150;FEIRA DE SANTANA;10;6;5000;4000;1000;3800;3700;100;120;80;0
|
||||
2022;1;BA;38490;SALVADOR;1;7;10000;8000;2000;7400;7200;200;300;300;0
|
||||
2022;1;BA;38490;SALVADOR;1;3;10000;8000;2000;7600;7400;200;200;200;0
|
||||
2022;2;BA;38490;SALVADOR;1;3;10000;8200;1800;7800;7600;200;200;200;0
|
||||
2022;1;BA;38490;SALVADOR;1;1;10000;8000;2000;7700;7500;200;150;150;0
|
||||
2022;2;BA;38490;SALVADOR;1;1;10000;8300;1700;7900;7700;200;200;200;0
|
||||
|
17
tests/fixtures/tse/votacao_candidato_munzona_fixture.csv
vendored
Normal file
17
tests/fixtures/tse/votacao_candidato_munzona_fixture.csv
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
ANO_ELEICAO;NR_TURNO;SG_UF;CD_MUNICIPIO;NM_MUNICIPIO;NR_ZONA;CD_CARGO;NR_CANDIDATO;NM_URNA_CANDIDATO;SG_PARTIDO;NM_COLIGACAO;QT_VOTOS_NOMINAIS;DS_SIT_TOT_TURNO
|
||||
2022;1;BA;38490;SALVADOR;1;6;1313;JORGE SOLLA;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;1200;ELEITO
|
||||
2022;1;BA;38490;SALVADOR;1;6;2222;RIVAL FEDERAL;PL;PARTIDO LIBERAL;800;#NULO#
|
||||
2022;1;BA;38490;SALVADOR;2;6;1313;JORGE SOLLA;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;900;ELEITO
|
||||
2022;1;BA;38490;SALVADOR;2;6;2222;RIVAL FEDERAL;PL;PARTIDO LIBERAL;1100;#NULO#
|
||||
2022;1;BA;35150;FEIRA DE SANTANA;10;6;1313;JORGE SOLLA;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;500;ELEITO
|
||||
2022;1;BA;35150;FEIRA DE SANTANA;10;6;2222;RIVAL FEDERAL;PL;PARTIDO LIBERAL;300;#NULO#
|
||||
2022;1;BA;38490;SALVADOR;1;7;13131;ESTADUAL PT;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;400;#NULO#
|
||||
2022;1;BA;38490;SALVADOR;1;3;13;JERÔNIMO;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;5000;2º TURNO
|
||||
2022;1;BA;38490;SALVADOR;1;3;45;RIVAL GOV;PSDB;COLIGAÇÃO OPOSIÇÃO;4200;2º TURNO
|
||||
2022;2;BA;38490;SALVADOR;1;3;13;JERÔNIMO;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;6000;ELEITO
|
||||
2022;2;BA;38490;SALVADOR;1;3;45;RIVAL GOV;PSDB;COLIGAÇÃO OPOSIÇÃO;4500;NÃO ELEITO
|
||||
2022;1;BA;38490;SALVADOR;1;1;13;LULA;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;7000;2º TURNO
|
||||
2022;1;BA;38490;SALVADOR;1;1;22;BOLSONARO;PL;PARTIDO LIBERAL;3000;2º TURNO
|
||||
2022;2;BA;38490;SALVADOR;1;1;13;LULA;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;8000;ELEITO
|
||||
2022;2;BA;38490;SALVADOR;1;1;22;BOLSONARO;PL;PARTIDO LIBERAL;3500;NÃO ELEITO
|
||||
2022;1;BA;38490;SALVADOR;1;6;9999;ZERO VOTOS;PSD;#NULO#;0;#NULO#
|
||||
|
35
tests/helpers/tseFixtures.ts
Normal file
35
tests/helpers/tseFixtures.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { parseTseCsvString } from '@/lib/electionResultsCsv'
|
||||
import { buildElectionResultsFromCsvRows } from '@/lib/electionResultsBuild'
|
||||
import type { BuiltElectionResults } from '@/lib/electionResultsBuild'
|
||||
|
||||
const fixtureDir = join(process.cwd(), 'tests/fixtures/tse')
|
||||
|
||||
const readFixture = (name: string): string => readFileSync(join(fixtureDir, name), 'utf8')
|
||||
|
||||
export const loadTseFixtureResults = (): BuiltElectionResults => {
|
||||
const voteRows = parseTseCsvString(readFixture('votacao_candidato_munzona_fixture.csv'))
|
||||
const detalheRows = parseTseCsvString(readFixture('detalhe_votacao_munzona_fixture.csv'))
|
||||
const candBaRows = parseTseCsvString(readFixture('consulta_cand_ba_fixture.csv'))
|
||||
const candBrRows = parseTseCsvString(readFixture('consulta_cand_br_fixture.csv'))
|
||||
|
||||
return buildElectionResultsFromCsvRows({
|
||||
voteRows,
|
||||
detalheRows,
|
||||
candBaRows,
|
||||
candBrRows,
|
||||
})
|
||||
}
|
||||
|
||||
/** Known totals from the fixture (after skipping the zero-vote row). */
|
||||
export const TSE_FIXTURE_EXPECTED = {
|
||||
sollaVotesTotal: 1200 + 900 + 500,
|
||||
lulaTurn2SalvadorZ1: 8000,
|
||||
jeronimoTurn2SalvadorZ1: 6000,
|
||||
federalWinnerSalvadorZ2: { candidateNumber: 2222, votes: 1100 },
|
||||
federalWinnerSalvadorZ1: { candidateNumber: 1313, votes: 1200 },
|
||||
voteRowCount: 15, // 16 CSV rows minus the zero-vote row
|
||||
tallyRowCount: 8,
|
||||
} as const
|
||||
186
tests/int/electionResultsImport.int.spec.ts
Normal file
186
tests/int/electionResultsImport.int.spec.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { getPayload, type Payload } from 'payload'
|
||||
|
||||
import config from '@/payload.config'
|
||||
import { SOLLA_CANDIDATE_NUMBER } from '@/lib/electionResults'
|
||||
import {
|
||||
buildImportBundles,
|
||||
importElectionBundles,
|
||||
} from '@/utilities/electionResultsImport'
|
||||
import { loadTseFixtureResults, TSE_FIXTURE_EXPECTED } from '../helpers/tseFixtures'
|
||||
|
||||
let payload: Payload
|
||||
|
||||
const deleteAllElectionData = async () => {
|
||||
for (const collection of [
|
||||
'electionCandidateVote',
|
||||
'electionTally',
|
||||
'electionCandidate',
|
||||
] as const) {
|
||||
const existing = await payload.find({
|
||||
collection,
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
})
|
||||
for (const doc of existing.docs) {
|
||||
await payload.delete({
|
||||
collection,
|
||||
id: doc.id,
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('election results import', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
await deleteAllElectionData()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteAllElectionData()
|
||||
})
|
||||
|
||||
it('imports fixture totals, winners, elected flags, and is idempotent', async () => {
|
||||
const built = loadTseFixtureResults()
|
||||
const bundles = buildImportBundles(built)
|
||||
|
||||
const first = await importElectionBundles(payload, bundles)
|
||||
const second = await importElectionBundles(payload, bundles)
|
||||
|
||||
const firstVotes = first.reduce((sum, c) => sum + c.votesInserted, 0)
|
||||
const secondVotes = second.reduce((sum, c) => sum + c.votesInserted, 0)
|
||||
expect(firstVotes).toBe(TSE_FIXTURE_EXPECTED.voteRowCount)
|
||||
expect(secondVotes).toBe(firstVotes)
|
||||
expect(second.reduce((sum, c) => sum + c.votesDeleted, 0)).toBe(firstVotes)
|
||||
|
||||
const votes = await payload.find({
|
||||
collection: 'electionCandidateVote',
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(votes.totalDocs).toBe(TSE_FIXTURE_EXPECTED.voteRowCount)
|
||||
|
||||
const sollaVotes = votes.docs
|
||||
.filter(
|
||||
(doc) =>
|
||||
doc.office === 'deputado_federal' &&
|
||||
Number(doc.candidateNumber) === SOLLA_CANDIDATE_NUMBER,
|
||||
)
|
||||
.reduce((sum, doc) => sum + Number(doc.votes), 0)
|
||||
expect(sollaVotes).toBe(TSE_FIXTURE_EXPECTED.sollaVotesTotal)
|
||||
|
||||
const tallies = await payload.find({
|
||||
collection: 'electionTally',
|
||||
where: {
|
||||
and: [
|
||||
{ office: { equals: 'deputado_federal' } },
|
||||
{ cityCode: { equals: '38490' } },
|
||||
{ zoneNumber: { equals: 1 } },
|
||||
{ turn: { equals: '1' } },
|
||||
],
|
||||
},
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(tallies.docs[0]?.winnerCandidateNumber).toBe(
|
||||
TSE_FIXTURE_EXPECTED.federalWinnerSalvadorZ1.candidateNumber,
|
||||
)
|
||||
expect(Number(tallies.docs[0]?.winnerVotes)).toBe(
|
||||
TSE_FIXTURE_EXPECTED.federalWinnerSalvadorZ1.votes,
|
||||
)
|
||||
|
||||
const candidates = await payload.find({
|
||||
collection: 'electionCandidate',
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
})
|
||||
const solla = candidates.docs.find(
|
||||
(doc) => doc.office === 'deputado_federal' && Number(doc.candidateNumber) === 1313,
|
||||
)
|
||||
const lula = candidates.docs.find(
|
||||
(doc) =>
|
||||
doc.office === 'presidente' &&
|
||||
Number(doc.candidateNumber) === 13 &&
|
||||
doc.turn === '2',
|
||||
)
|
||||
const jeronimo = candidates.docs.find(
|
||||
(doc) =>
|
||||
doc.office === 'governador' &&
|
||||
Number(doc.candidateNumber) === 13 &&
|
||||
doc.turn === '2',
|
||||
)
|
||||
expect(solla?.elected).toBe(true)
|
||||
expect(lula?.elected).toBe(true)
|
||||
expect(jeronimo?.elected).toBe(true)
|
||||
expect(lula?.identityKey).toBeTruthy()
|
||||
})
|
||||
|
||||
it('allows campaignUser read and denies campaignUser mutation', async () => {
|
||||
// Avoid campaignFixtures here: teqo_test may already include territory migrations
|
||||
// from other worktrees that diverge from this branch's electoralNucleus schema.
|
||||
const campaignUser = await payload.create({
|
||||
collection: 'campaignUser',
|
||||
data: {
|
||||
name: 'Election Access Tester',
|
||||
role: 'geral',
|
||||
email: `election-access-${Date.now()}@example.com`,
|
||||
password: 'Password123!',
|
||||
},
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
try {
|
||||
const readable = await payload.find({
|
||||
collection: 'electionTally',
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
user: campaignUser,
|
||||
overrideAccess: false,
|
||||
})
|
||||
expect(readable.totalDocs).toBeGreaterThan(0)
|
||||
|
||||
await expect(
|
||||
payload.create({
|
||||
collection: 'electionTally',
|
||||
data: {
|
||||
year: 2022,
|
||||
office: 'deputado_federal',
|
||||
turn: '1',
|
||||
state: 'BA',
|
||||
cityCode: '99999',
|
||||
cityName: 'Salvador',
|
||||
zoneNumber: 99,
|
||||
aptos: 1,
|
||||
comparecimento: 1,
|
||||
abstencoes: 0,
|
||||
votosValidos: 1,
|
||||
votosNominaisValidos: 1,
|
||||
votosLegenda: 0,
|
||||
votosBranco: 0,
|
||||
votosNulo: 0,
|
||||
votosAnulados: 0,
|
||||
},
|
||||
user: campaignUser,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
} finally {
|
||||
await payload.delete({
|
||||
collection: 'campaignUser',
|
||||
id: campaignUser.id,
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
161
tests/unit/electionResults.unit.spec.ts
Normal file
161
tests/unit/electionResults.unit.spec.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { bahiaMunicipalities } from '@/lib/bahiaTerritories'
|
||||
import { computeIdentityKey, normalizeIdentityPart } from '@/lib/electionCandidateIdentity'
|
||||
import {
|
||||
assertAllCanonicalMunicipalitiesResolvable,
|
||||
canonicalizeMunicipalityName,
|
||||
computeWinnersByScope,
|
||||
JERONIMO_CANDIDATE_NUMBER,
|
||||
LULA_CANDIDATE_NUMBER,
|
||||
mergeTallyWithWinners,
|
||||
normalizeMunicipalityKey,
|
||||
parseElectedStatus,
|
||||
SOLLA_CANDIDATE_NUMBER,
|
||||
UnknownMunicipalityError,
|
||||
winnerKey,
|
||||
} from '@/lib/electionResults'
|
||||
import { loadTseFixtureResults, TSE_FIXTURE_EXPECTED } from '../helpers/tseFixtures'
|
||||
|
||||
describe('electionResults municipality mapping', () => {
|
||||
it('resolves every canonical Bahia municipality through itself', () => {
|
||||
expect(bahiaMunicipalities).toHaveLength(417)
|
||||
expect(() => assertAllCanonicalMunicipalitiesResolvable()).not.toThrow()
|
||||
})
|
||||
|
||||
it('maps TSE ALL-CAPS names to canonical names', () => {
|
||||
expect(canonicalizeMunicipalityName('SALVADOR')).toBe('Salvador')
|
||||
expect(canonicalizeMunicipalityName('FEIRA DE SANTANA')).toBe('Feira de Santana')
|
||||
expect(canonicalizeMunicipalityName("DIAS D'AVILA")).toBe("Dias d'Ávila")
|
||||
expect(canonicalizeMunicipalityName('XIQUE-XIQUE')).toBe('Xique-Xique')
|
||||
})
|
||||
|
||||
it('maps known TSE spelling aliases to canonical names', () => {
|
||||
expect(canonicalizeMunicipalityName('CAMACÃ')).toBe('Camacan')
|
||||
expect(canonicalizeMunicipalityName('DIAS D ÁVILA')).toBe("Dias d'Ávila")
|
||||
expect(canonicalizeMunicipalityName('MUQUÉM DO SÃO FRANCISCO')).toBe(
|
||||
'Muquém de São Francisco',
|
||||
)
|
||||
expect(canonicalizeMunicipalityName('SANTA TEREZINHA')).toBe('Santa Teresinha')
|
||||
})
|
||||
|
||||
it('fails closed on unknown municipality names', () => {
|
||||
expect(() => canonicalizeMunicipalityName('CIDADE INEXISTENTE')).toThrow(UnknownMunicipalityError)
|
||||
})
|
||||
|
||||
it('normalizes accent and apostrophe variants to the same key', () => {
|
||||
expect(normalizeMunicipalityKey("Dias d'Ávila")).toBe(normalizeMunicipalityKey("DIAS D'AVILA"))
|
||||
})
|
||||
})
|
||||
|
||||
describe('electionResults elected status', () => {
|
||||
it('maps DS_SIT_TOT_TURNO variants', () => {
|
||||
expect(parseElectedStatus('ELEITO')).toEqual({ elected: true, electedBy: 'QP' })
|
||||
expect(parseElectedStatus('ELEITO POR MÉDIA')).toEqual({ elected: true, electedBy: 'média' })
|
||||
expect(parseElectedStatus('ELEITO EM 2º TURNO')).toEqual({
|
||||
elected: true,
|
||||
electedBy: '2º turno',
|
||||
})
|
||||
expect(parseElectedStatus('2º TURNO')).toEqual({ elected: false, electedBy: null })
|
||||
expect(parseElectedStatus('NÃO ELEITO')).toEqual({ elected: false, electedBy: null })
|
||||
expect(parseElectedStatus('#NULO#')).toEqual({ elected: false, electedBy: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('electionCandidateIdentity', () => {
|
||||
it('is stable for equivalent normalized inputs', () => {
|
||||
const a = computeIdentityKey({
|
||||
urnaName: 'Jorge Solla',
|
||||
birthCity: 'Salvador',
|
||||
birthState: 'BA',
|
||||
party: 'PT',
|
||||
})
|
||||
const b = computeIdentityKey({
|
||||
urnaName: 'JORGE SOLLA',
|
||||
birthCity: 'SALVADOR',
|
||||
birthState: 'ba',
|
||||
party: 'pt',
|
||||
})
|
||||
expect(a).toBe(b)
|
||||
expect(a).toHaveLength(64)
|
||||
})
|
||||
|
||||
it('changes when party changes', () => {
|
||||
const base = {
|
||||
urnaName: 'LULA',
|
||||
birthCity: 'GARANHUNS',
|
||||
birthState: 'PE',
|
||||
party: 'PT',
|
||||
}
|
||||
expect(computeIdentityKey(base)).not.toBe(computeIdentityKey({ ...base, party: 'PL' }))
|
||||
})
|
||||
|
||||
it('strips accents in identity parts', () => {
|
||||
expect(normalizeIdentityPart('Jerônimo')).toBe('JERONIMO')
|
||||
})
|
||||
})
|
||||
|
||||
describe('electionResults fixture parse + winners', () => {
|
||||
const built = loadTseFixtureResults()
|
||||
|
||||
it('builds expected vote totals and skips zero-vote rows', () => {
|
||||
expect(built.unknownMunicipalities).toEqual([])
|
||||
expect(built.votes).toHaveLength(TSE_FIXTURE_EXPECTED.voteRowCount)
|
||||
expect(built.tallies).toHaveLength(TSE_FIXTURE_EXPECTED.tallyRowCount)
|
||||
|
||||
const sollaVotes = built.votes
|
||||
.filter(
|
||||
(row) =>
|
||||
row.office === 'deputado_federal' && row.candidateNumber === SOLLA_CANDIDATE_NUMBER,
|
||||
)
|
||||
.reduce((sum, row) => sum + row.votes, 0)
|
||||
expect(sollaVotes).toBe(TSE_FIXTURE_EXPECTED.sollaVotesTotal)
|
||||
})
|
||||
|
||||
it('marks Solla, Lula and Jerônimo as elected', () => {
|
||||
const solla = built.candidates.find(
|
||||
(c) => c.office === 'deputado_federal' && c.candidateNumber === SOLLA_CANDIDATE_NUMBER,
|
||||
)
|
||||
const lula = built.candidates.find(
|
||||
(c) =>
|
||||
c.office === 'presidente' &&
|
||||
c.candidateNumber === LULA_CANDIDATE_NUMBER &&
|
||||
c.turn === '2',
|
||||
)
|
||||
const jeronimo = built.candidates.find(
|
||||
(c) =>
|
||||
c.office === 'governador' &&
|
||||
c.candidateNumber === JERONIMO_CANDIDATE_NUMBER &&
|
||||
c.turn === '2',
|
||||
)
|
||||
|
||||
expect(solla?.elected).toBe(true)
|
||||
expect(lula?.elected).toBe(true)
|
||||
expect(lula?.electedBy).toBe('2º turno')
|
||||
expect(jeronimo?.elected).toBe(true)
|
||||
expect(jeronimo?.electedBy).toBe('2º turno')
|
||||
expect(lula?.state).toBe('BA')
|
||||
})
|
||||
|
||||
it('computes local winners per city+zone', () => {
|
||||
const winners = computeWinnersByScope(built.votes)
|
||||
const z1 = winners.get(winnerKey('38490', 1, 'deputado_federal', '1'))
|
||||
const z2 = winners.get(winnerKey('38490', 2, 'deputado_federal', '1'))
|
||||
expect(z1?.winnerCandidateNumber).toBe(
|
||||
TSE_FIXTURE_EXPECTED.federalWinnerSalvadorZ1.candidateNumber,
|
||||
)
|
||||
expect(z1?.winnerVotes).toBe(TSE_FIXTURE_EXPECTED.federalWinnerSalvadorZ1.votes)
|
||||
expect(z2?.winnerCandidateNumber).toBe(
|
||||
TSE_FIXTURE_EXPECTED.federalWinnerSalvadorZ2.candidateNumber,
|
||||
)
|
||||
expect(z2?.winnerVotes).toBe(TSE_FIXTURE_EXPECTED.federalWinnerSalvadorZ2.votes)
|
||||
|
||||
const merged = mergeTallyWithWinners(built.tallies, winners)
|
||||
const federalZ1 = merged.find(
|
||||
(t) => t.office === 'deputado_federal' && t.zoneNumber === 1 && t.cityCode === '38490',
|
||||
)
|
||||
expect(federalZ1?.winnerCandidateName).toBe('JORGE SOLLA')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue