Ship E4R: always-overwrite seed of projection spreadsheet into voteGoals and priority.

One-shot CLI (`pnpm db:seed:projecao`) matches MAPA GERAL to municipalities, skips Salvador, and re-seeds when a newer sheet arrives; dry-run + docs/roadmap updated.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-24 13:13:46 -03:00
parent cc0087fd5f
commit d14ed67801
10 changed files with 834 additions and 22 deletions

View file

@ -7,6 +7,7 @@ alwaysApply: false
## Status
- **E4R entregue em código (2026-07-24):** `pnpm db:seed:projecao` (`scripts/import-projecao.mjs` + `src/lib/projectionSheetParse.ts`) importa MAPA GERAL canônico → `voteGoals`/`priority` com **overwrite-always** (re-seed quando chegar planilha nova); Salvador pulado; zero PII; dry-run + runbook. Seed local: arquivo `docs/sheets/Mapa projeção de votos Solla 2026.xlsx`, **189** municípios com metas, **50** `priority=alta`, re-run delta=0. Produção: após smoke, `ALLOW_REMOTE_DB=true … pnpm db:seed:projecao -- --dry-run` → apply. Plano: [`import-planilha-projecao.md`](../../../docs/plans/import-planilha-projecao.md).
- **Sync roadmap × sessão de campo (2026-07-24):** os achados da sessão real do CG (2026-07-23, `docs/CUSTOMER.md`) foram incorporados ao roadmap e aos planos de inteligência — âncora da mesa = **% da própria votação** (A11 entrega a lente; E9/E10/B13 expõem ao lado de LQ/captura); piso 150 mil resolve a meta inicial de E8; O6 vira **E16 dossiê do município** + gap **G11 emendas por município**; O7 confirma C12 não cortável; E13 rebaixado na fila de corte ("perna" é a restrição dominante); gatilho de D3 reformulado (deltas do ZAP não absorvidos pelo fluxo sede-digita/C12); FD2 absorvido por R6; O8 "ilhas isoladas" em A validar. **Decisão de produto: E4 reaberto como E4R** — seed único via script das planilhas de `docs/sheets/` (arquivo com espaços = canônico de estratégia; underscore = referência Salvador; v1 só `voteGoals`/`priority`, sem PII; Salvador pulado com relatório). Novos itens: **E4R** (janela 1, não cortável), **A11**, **E17 tabela TI no Início** (pedido do candidato; primeira fatia de E12), **B14 município mais próximo** (geolocalização → atalho no Início staff; prompt 1×/sessão; client-only). Planos novos: `import-planilha-projecao.md`, `ranking-votos-municipio.md`, `tabela-ti-inicio.md`, `dossie-municipio.md`, `municipio-mais-proximo.md`.
- **DEPLOY EM PRODUÇÃO REALIZADO (2026-07-23).** A cadeia destrutiva da remodelagem (`20260721_020109_remodel_municipalities` → `20260723_200000_remodel_municipalities` + `20260723_202000_reconcile_municipality_remodel`) foi aplicada em produção pelos builds Vercel de 2026-07-23 (deploys production READY dos commits `a7b8f1b`/`c7a1b74`/`59868a9`). **Este item supersede toda menção "deploy pendente" nas entradas abaixo.** Pendências operacionais: smoke pós-deploy + onboarding do time (Onda 0 §2/§4 em [`docs/roadmap.md`](../../../docs/roadmap.md)); hold de PII real segue no lote jurídico.
- **Hardening de engenharia entregue e em produção (2026-07-23, Fases 0–6):** (0) access lockdown — `users`/`signature`/`subscription`/`consent` admin-only, escrita de CMS (`post`/`tag`/`media`/`petition`) admin-only, gates de rota do leader em `demandas*`/`planos*`, `createLeaderSupporter` com escopo correto, `candidate` tratado como staff irrestrito em todas as superfícies; (1) tooling gate — knip no CI (`.github/workflows/ci.yml`, mirror GitHub), `pnpm lint --max-warnings=0`, ban `as never` (helpers `hookFilledCreateData`/`stub<T>`), tsconfig consolidado + `noUnusedLocals`, ~12 módulos mortos deletados; (2) caching — artefato TSE commitado (`pnpm build:election-aggregates` → `src/lib/electionAggregates/`, loader `bahiaElectionAggregates.ts`), `unstable_cache` com tag `election-tse` nos loaders de eleições, `loadMunicipalityScope` (React `cache()`) dedupe dashboard+mapa+lista; (3) pending honesto — bug de revalidação do toggle de tarefa, `CampaignListPendingBoundary` esmaecendo resultados, transição no compare do mapa, mapa do dashboard + abas do detalhe em Suspense; (4) state scoping — hover do mapa isolado em `MunicipalityMapSelection`, provider de cenário estreitado, `LeaderContactForm` extraído como ilha cliente; (5) estrutura — `campaignAccess` dividido em `src/utilities/access/*` (re-export preservado), `supporterImport.ts` separado, `runStaffEntityMutation` + `runCampaignRedirectFormAction` dedupando twins e 4 ladders de formActions, rename `PLAZA_*`→`MUNICIPALITY_*` concluído; (6) docs + regra `.cursor/rules/engineering-standards.mdc`. Tracker: [`docs/IMPROVE-CODE-QUALITY-PLAN.md`](../../../docs/IMPROVE-CODE-QUALITY-PLAN.md) · ledger: [`docs/TECH-DEBT.md`](../../../docs/TECH-DEBT.md) · mapa de testes: [`docs/TESTING.md`](../../../docs/TESTING.md).
@ -74,6 +75,7 @@ alwaysApply: false
## Decisões
- Em 2026-07-24, **E4R overwrite-always:** `pnpm db:seed:projecao` sempre sobrescreve `voteGoals` + `priority` nas linhas casadas (planilha nova sobre DB já populado). **Rejeitado:** empty-only / `--overwrite` opt-in (quebraria o re-seed). Dry-run continua obrigatório antes de produção.
- Em 2026-07-23, **access explícito em toda collection (hardening Fase 0):** o default do Payload ("qualquer autenticado pode CRUD") é tratado como bug; toda collection declara `access` explícito — `users`/`signature`/`subscription`/`consent` só Payload admin (`payloadAdminOnly`), escrita de CMS admin-only com leitura pública preservada. JWTs de `campaignUser` não operam mais essas collections via `/api`. **Rejeitado:** confiar no default do Payload; RBAC só na camada de UI.
- Em 2026-07-23, **padrões de engenharia vigentes (hardening Fases 1–6):** zero lint warnings (`--max-warnings=0`); `as never` banido (helpers tipados `hookFilledCreateData` / `stub<T>` nos testes); knip no CI com files/dependencies em nível de erro; dead code se apaga imediatamente após qualquer mudança; convenção `lib/` (dado estático + lógica pura) vs `utilities/` (runtime Payload/Next); caching ladder (React `cache()` → `unstable_cache`+tag → artefato commitado). Regra sempre aplicada: [`.cursor/rules/engineering-standards.mdc`](../engineering-standards.mdc); débitos: [`docs/TECH-DEBT.md`](../../../docs/TECH-DEBT.md).
- Em 2026-07-23, **auto-save sem botão Salvar:** campos de edição devem persistir automaticamente sempre que for seguro (change de select, blur ou debounce curto); sem botão “Salvar” em edição in-context / campo único. Submit explícito só para formulários multi-campo atômicos, nota/confirmação ou consentimento. Princípio 4 de [`PRODUCT.md`](../../../PRODUCT.md); regra Cursor [`.cursor/rules/campanha-edit-where-you-see.mdc`](../campanha-edit-where-you-see.mdc). **Rejeitado / supersede:** preferência B9 por Enter + botão Salvar em quick-edit; Save “por precaução” em Popover.

View file

@ -61,6 +61,8 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.
**Recently resolved (2026-07-23):** **Engineering-standards hardening** (tracker: [`docs/IMPROVE-CODE-QUALITY-PLAN.md`](docs/IMPROVE-CODE-QUALITY-PLAN.md); ledger: [`docs/TECH-DEBT.md`](docs/TECH-DEBT.md); safety net: [`docs/TESTING.md`](docs/TESTING.md); standards rule: `.cursor/rules/engineering-standards.mdc` — READ IT before coding). Phases: (0) explicit `access` on every collection — `users`/`signature`/`subscription`/`consent` admin-only, CMS writes admin-only, leader route gates on `demandas*`/`planos*`, `createLeaderSupporter` scope fixed, `candidate` treated as unrestricted staff consistently; (1) tooling gate — knip in CI (`.github/workflows/ci.yml` on the GitHub mirror), `pnpm lint --max-warnings=0`, `as never` banned (typed helpers `hookFilledCreateData` / tests' `stub<T>`), tsconfig include consolidated + `noUnusedLocals`, ~12 dead modules deleted; (2) caching — committed TSE aggregate artifact (`pnpm build:election-aggregates` → `src/lib/electionAggregates/`, loader `bahiaElectionAggregates.ts`), `loadMunicipalityScope` React `cache()` dedup, `unstable_cache` + `election-tse` tag on elections-tab loaders; (3) "Feel the action" gaps closed — task-toggle revalidation bug, shared `CampaignListPendingBoundary` dimming results, map-compare transition, dashboard map + detail tabs stream behind Suspense; (4) state scoping — map hover isolated in `MunicipalityMapSelection`, scenario provider narrowed, `LeaderContactsPanel` server-rendered with a client form island; (5) structure — `campaignAccess` split into `src/utilities/access/*` (re-exported), supporter actions split (`supporterImport.ts`), `runStaffEntityMutation` + `runCampaignRedirectFormAction` dedupe the org/dobradinha twins and 4 formActions ladders, Plaza→Municipality identifier rename finished.
**Recently resolved (2026-07-24):** **E4R** Import único da planilha de projeção — `pnpm db:seed:projecao` (`scripts/import-projecao.mjs`) lê o xlsx canônico em `docs/sheets/`, faz match via `canonicalizeMunicipalityName` + `municipalityCatalog`, e **sempre sobrescreve** `municipality.voteGoals` + `priority` (re-seed quando a mesa manda planilha nova); Salvador pulado; zero PII; `--dry-run` + guard `ALLOW_REMOTE_DB`; parser puro em `src/lib/projectionSheetParse.ts`. Plano: [`docs/plans/import-planilha-projecao.md`](docs/plans/import-planilha-projecao.md).
**Recently resolved (2026-07-23):** **A10** Cenários de estimativa (pessimista/média/otimista) — `votePledge.estimatedVotes` e `municipality.expectedVotes` viram groups (`pessimistic|central|optimistic`); migration `20260723_124200_add_vote_estimate_scenarios` (backfill scalar→`central`); agregação por cenário em `votePledgeData` com default `central`; mapa 2026 com `values2026ByScenario` + seletor local; lista/overview/dashboard com média + faixa secundária; liderança continua com um `declaredVotes` sem ver estimativas. Plano: [`docs/plans/cenarios-estimativa-votos.md`](docs/plans/cenarios-estimativa-votos.md). Desbloqueia **E8**.
## Posts & Tags (news / publications)

View file

@ -1,12 +1,14 @@
# E4R — Import único da planilha de projeção (seed de estratégia, local e produção)
Status: rascunho (aprovado como item em 2026-07-24 — reabre o corte E4 por decisão de produto)
Atualizado em: 2026-07-24
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Demais itens abertos, E4R; Onda 0 §4 — seed do onboarding)
Status: entregue em código (2026-07-24)
Atualizado em: 2026-07-24 (overwrite-always; SheetJS; seed local 189 metas / 50 alta)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (E4R)
Impeccable: n/a — script/CLI sem superfície de UI (relatório em stdout)
Appetite: ~1 dia eng (parser + mapeamento + dry-run + runbook de produção)
Responsável: —
Revisão 2026-07-24: idempotência mudou de “só campos vazios” para **sempre sobrescrever** `voteGoals`/`priority` nas linhas casadas — a planilha sobe de novo quando a mesa manda versão mais nova sobre DB já populado. Entrega: `pnpm db:seed:projecao`, parser em `src/lib/projectionSheetParse.ts`, script `scripts/import-projecao.mjs`. Seed local verificado: 189 municípios com metas parseáveis, 50 `priority=alta`, Salvador pulado, re-run delta=0.
## Contexto
A sessão real com o Coordenador Geral (2026-07-23 — [CUSTOMER.md](../CUSTOMER.md)) confirmou O5 com força máxima: a planilha de projeção é a fonte de verdade da mesa ("eu acordo todos os dias lendo o nosso mapa") e ele se comprometeu a enviá-la — as versões mais recentes já estão versionadas em `docs/sheets/`. O corte E4 (2026-07-19, [mapa-projecao-municipios.md](mapa-projecao-municipios.md): "dados estratégicos via UI, sem script") foi decidido **antes** dessa evidência; preencher ~240 municípios com metas à mão na semana de onboarding não acontece, e o Big Hire depende de "seeded picture" (CUSTOMER.md). Decisão de produto de 2026-07-24: **seed único e assistido via script**; import automático/recorrente continua fora de escopo (atualização contínua segue via UI — o produto é o registro vivo, não espelho de planilha).
@ -28,7 +30,7 @@ Divergências A×B no MAPA GERAL (9 linhas: Brejões, Catu, Iaçu, Ipiaú, Jagua
- Parser tolerante aos 3 formatos observados de EXPECTATIVA: `Bom: 350 | Regular: 250 | Minimo: 150` (com/sem espaços), `Bom:350`, `800/500/400`; separador de milhar `10.000`.
- Matching de nome via `canonicalizeMunicipalityName` + catálogo (`municipalityCatalog.ts`); sanity check duplo: VOTOS 2014/18/22 da planilha × `bahiaElectionAggregates` (valida o match), REGIÃO × `bahiaTerritories` (diverge = warning, não bloqueia).
- `--dry-run` com relatório completo: linhas casadas/não casadas, campos que seriam escritos, 9 divergências A×B, Salvador pulado, totais.
- Idempotente: por default escreve **só campos vazios** (`voteGoals` sem valor, `priority` ausente); `--overwrite` para re-runs conscientes.
- **Sempre sobrescreve** `voteGoals` + `priority` em toda linha casada (a planilha é a fonte da estratégia naquele momento; re-run com arquivo novo ou `--file` substitui o que já estiver no banco). Não toca outros campos nem `lastUpdateAt`.
- Runbook de produção documentado no próprio script (header) e neste plano.
## Decisões travadas
@ -38,13 +40,15 @@ Divergências A×B no MAPA GERAL (9 linhas: Brejões, Catu, Iaçu, Ipiaú, Jagua
- **Salvador (linha única, metas 30k/27k/25k) é pulado com relatório explícito.** O catálogo tem 19 Municípios-zona e a planilha não distribui; inventar rateio viola "não estimar o que não tem dado" (falsa precisão). Metas de zona entram via UI pelo coordenador. **Rejeitado:** rateio proporcional ao voto por zona (número inventado com cara de número).
- **Arquivo A é o canônico de estratégia; B é referência.** Mais recente nos 9 conflitos e corroborado pela entrevista (Jaguaquara). O script aceita `--file` para apontar planilha nova se chegar versão mais atual.
- **`priority`: `alta` → `alta`; `Baixa`/vazio → `normal`.** Cross-check com a aba PRIORITÁRIAS (conjunto alfabético de 50, não ranking): prioritária sem `alta` no MAPA GERAL = warning no relatório.
- **Escrita via Payload Local API em transação única** (`withPayloadTransaction`-equivalente de CLI), `overrideAccess: true` (processo CLI, mesmo padrão de `seed-tse-results.mjs`); guarda de banco da família dos seeds (`assertLocalDatabase`; produção só com `ALLOW_REMOTE_DB=true`).
- **i18n e naming:** identificadores em inglês (`importProjectionSheet`, `parseExpectationCell`, `--dry-run`/`--overwrite`/`--file`); relatório em pt-BR.
- **Escrita via Payload Local API em transação única** (`withPayloadTransaction`), `overrideAccess: true` (processo CLI, mesmo padrão dos seeds); guarda de banco da família dos seeds (`assertLocalDatabase`; produção só com `ALLOW_REMOTE_DB=true`).
- **Overwrite sempre na escrita:** cada linha casada do MAPA GERAL grava `voteGoals` + `priority` da planilha por cima do valor atual. **Rejeitado:** empty-only (quebraria o re-seed quando a mesa manda planilha atualizada). **Rejeitado:** merge por célula / “só se diferente” como gate — o dry-run já mostra o delta; a escrita é a declaração consciente após revisão.
- **Não atualizar `lastUpdateAt`.** Seed/re-seed não é sinal de campo; frescor (E9/G8) não pode nascer inflado.
- **Parser xlsx:** SheetJS CE (`xlsx`) como devDependency (knip entry `scripts/*.mjs`).
- **i18n e naming:** identificadores em inglês (`importProjectionSheet`, `parseExpectationCell`, `--dry-run`/`--file`); relatório em pt-BR.
## Questões em aberto
- **Parser xlsx: dependência ou zip+XML mínimo?** Opções: devDependency SheetJS CE (`xlsx`, Apache-2.0) | parser próprio (zipfile + `fast-xml-parser`/DOM, ~80 linhas para o subset usado). **Recomendação:** SheetJS CE como devDependency — formato consolidado, menos código nosso; remover se incomodar no knip (é usado só pelo script).
- **Atualizar `lastUpdateAt` dos municípios seedados?** **Recomendação:** não — seed não é sinal de campo; frescor (E9/G8) não pode nascer inflado.
_(nenhuma — SheetJS e lastUpdateAt fechados 2026-07-24; overwrite-always fechado na implementação.)_
## Abordagem proposta
@ -54,7 +58,7 @@ flowchart LR
Parse["parseExpectationCell<br/>(3 formatos + milhar)"]
Match["canonicalizeMunicipalityName<br/>+ municipalityCatalog"]
Sanity["sanity: votos × artefato<br/>REGIÃO × bahiaTerritories"]
Write["Payload Local API<br/>(voteGoals, priority; só vazios)"]
Write["Payload Local API<br/>(voteGoals, priority; overwrite)"]
Report["relatório stdout<br/>(dry-run = só relatório)"]
XLSX --> Parse --> Match --> Sanity --> Write --> Report
Sanity -.warnings.-> Report
@ -62,9 +66,9 @@ flowchart LR
### Runbook (ordem obrigatória)
1. Local: `pnpm db:start` → `node scripts/import-projecao.mjs --dry-run` → revisar relatório → rodar sem `--dry-run` → conferir 3 municípios na UI (`/campanha/municipios`).
2. Produção (após smoke pós-deploy): `ALLOW_REMOTE_DB=true DATABASE_URL=<prod> node scripts/import-projecao.mjs --dry-run` → revisar → aplicar. Sem revalidate (páginas de campanha são dinâmicas).
3. Registrar no notebook a data do seed e o arquivo usado (proveniência).
1. Local: `pnpm db:start` → `pnpm db:seed:projecao -- --dry-run` → revisar relatório → `pnpm db:seed:projecao` (sobrescreve) → conferir 3 municípios na UI (`/campanha/municipios`).
2. Produção (após smoke pós-deploy, ou quando chegar planilha nova): `ALLOW_REMOTE_DB=true DATABASE_URL=<prod> pnpm db:seed:projecao -- --dry-run` → revisar → aplicar (sem `--dry-run`). Sem revalidate (páginas de campanha são dinâmicas).
3. Registrar no notebook a data do seed e o arquivo usado (proveniência). Planilha nova: `--file <path>` + dry-run + apply.
## Dependências
@ -85,7 +89,14 @@ flowchart LR
- **Fase 2 (colunas com nomes).** Gatilho: lote jurídico final + decisão de destino com a assessoria.
- **Metas de Salvador por zona via planilha.** Gatilho: coordenação produzir metas por ZE (hoje não existem em nenhuma aba).
- **`--file` apontando planilha atualizada.** Gatilho: coordenador enviar versão mais nova que as de `docs/sheets/` (o script já nasce parametrizado; o gatilho é só o re-run consciente com `--overwrite`).
- **`--file` apontando planilha atualizada.** Gatilho: coordenador enviar versão mais nova que as de `docs/sheets/` (o script já nasce parametrizado; o gatilho é o re-run consciente após dry-run — a escrita já sobrescreve).
- **Batch SQL no lugar de ~189 `payload.update` sequenciais.** Gatilho: re-seed remoto (Neon) sentir-se lento; hoje Local API + overwrite-always cabem no appetite de seed one-shot. Helpers bulk existentes são insert-oriented (`supporterImportBulk`).
## Já resolvido no simplify (não reabrir)
- Chaves de Map via `normalizeMunicipalityKey` (não NFD local fraco)
- `asCompleteGoals` / drop `sheetRow` morto / ordem Bom≥Regular≥Mínimo via `getVoteGoalsOrderViolation`
- `--reference` explícito falha fechado; A×B só com B parseável
## Referências

View file

@ -1,7 +1,7 @@
# Mapa de projeção de votos por município (equiparar e superar as planilhas 2026)
Status: E1, E2 e E3 implementados e mesclados em `main` (2026-07-19); **E4 cortado** (2026-07-19 — preenchimento manual via E1+E3) e **reaberto como E4R em 2026-07-24** (decisão de produto pós-sessão de campo, evidência O5 — seed único via script: [import-planilha-projecao.md](import-planilha-projecao.md)); E5 pendente
Atualizado em: 2026-07-19 (revisão entrega E1+E3 — marcar como entregues no roadmap; access E1/E3 sem `canSetDerivedNucleusField`; escrita via `canUpdateElectoralNucleus` + selects/VMs; **E4 cortado** — decisão de produto: sem `pnpm db:seed:mapa`, dados via UI manual)
Status: E1, E2 e E3 implementados e mesclados em `main` (2026-07-19); **E4 cortado** (2026-07-19) e **reaberto/entregue como E4R em 2026-07-24** ([import-planilha-projecao.md](import-planilha-projecao.md) — `pnpm db:seed:projecao`); E5 pendente
Atualizado em: 2026-07-24 (E4R entregue — seed overwrite-always)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha E — itens E1–E5, "Ciclo 3")
Responsável: —

View file

@ -1,6 +1,6 @@
# Roadmap — Teqo
Atualizado em: 2026-07-24 (B14 município mais próximo por geolocalização no Início; janela 1 vigente — convenções até 05/08; foco: smoke pós-deploy, E4R→A11/E17 na semana de onboarding, R6, E8)
Atualizado em: 2026-07-24 (E4R entregue em código — seed local; B14 município mais próximo no roadmap; janela 1: smoke pós-deploy, A11/E17, R6, E8 — [CUSTOMER.md](CUSTOMER.md))
Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc).
@ -31,7 +31,7 @@ Textos provisórios de Consent + `/privacidade` auto-provisionados ([onda-0.md](
1. **Lote jurídico único** _(externo)_ — textos finais (substituem provisórios) + base LGPD art. 11: `lideranca-autopreenchimento`, `apoiador-cadastro`, `apoiador-intencao-voto`, `campanha-notificacoes-push`, **`campanha-whatsapp-canal`** (D3 — armazenamento/processamento do canal interno staff↔lideranças; se o lote já tiver fechado sem esta chave, entra no próximo passe com a assessoria — não abrir rodada jurídica só por ela), Aviso de Privacidade, avaliação RIPD.
2. **Smoke pós-deploy** _(desbloqueado — deploy da remodelagem aplicado em produção em 2026-07-23)_ — `NEXT_PUBLIC_SITE_URL` HTTPS, login `/campanha`, município de teste; checklist no AGENTS.md.
3. **Ativação com dados reais** assim que (1) liberar — lideranças/apoiadores reais e import em massa.
4. **Onboarding do time** — usuários `coordinator`/`advisor`, primeiros municípios assumidos, treino de campo; **seed da planilha de prioridades via E4R** (estratégia sem PII: metas/prioridade — [plano](plans/import-planilha-projecao.md)); rede/lideranças (nomes) só após o lote jurídico.
4. **Onboarding do time** — usuários `coordinator`/`advisor`, primeiros municípios assumidos, treino de campo; **seed da planilha de prioridades via E4R** (engenharia pronta — `pnpm db:seed:projecao`; aplicar em produção após smoke — [plano](plans/import-planilha-projecao.md)); rede/lideranças (nomes) só após o lote jurídico.
**O0+** (escala/DRY pós-Onda 0) não bloqueia jurídico nem smoke fictício — [plano](plans/escala-dry-pos-onda0.md).
@ -45,6 +45,7 @@ Textos provisórios de Consent + `/privacidade` auto-provisionados ([onda-0.md](
- **A10 (2026-07-23)** — cenários pessimista/média/otimista em `votePledge.estimatedVotes` e `municipality.expectedVotes`; agregação por cenário (default média); seletor no mapa/overview; liderança segue com um `declaredVotes` ([plano](plans/cenarios-estimativa-votos.md)). Desbloqueou **E8**.
- **Admin Payload (2026-07-23)** — export CSV de assinaturas e contatos ([plano](plans/exportar-csv-assinaturas.md)).
- **Hardening de engenharia (2026-07-23, Fases 0–6)** — access lockdown (PII/CMS admin-only + gates de rota do leader), tooling gate (knip no CI, `--max-warnings=0`, ban `as never`), artefato TSE commitado + cache (`bahiaElectionAggregates`, `election-tse`, `loadMunicipalityScope`), pending honesto + Suspense streaming, state scoping, splits (`src/utilities/access/*`, `supporterImport.ts`, shells DRY). Tracker: [IMPROVE-CODE-QUALITY-PLAN.md](IMPROVE-CODE-QUALITY-PLAN.md) · ledger: [TECH-DEBT.md](TECH-DEBT.md) · mapa de testes: [TESTING.md](TESTING.md).
- **E4R (2026-07-24)** — import único da planilha de projeção → `municipality.voteGoals` + `priority` (`pnpm db:seed:projecao`, always-overwrite, dry-run + runbook; Salvador pulado; zero PII). Plano: [import-planilha-projecao.md](plans/import-planilha-projecao.md). Seed local verificado (189 metas / 50 alta); produção após smoke.
## Próximos — Campanha (`/campanha`)
@ -68,7 +69,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
### Demais itens abertos
- **E4R** import único da planilha de projeção (`docs/sheets/`) → `voteGoals`/`priority` (reabre o corte E4 — decisão de produto 2026-07-24 + evidência O5: a planilha é a fonte de verdade da mesa) · script idempotente com dry-run + runbook de produção · Janela 1 · **não cortável** (seed do Big Hire; insumo de E8/A11/E17) · [plano](plans/import-planilha-projecao.md)
- ~~**E4R** import único da planilha de projeção~~ — **entregue 2026-07-24** (`pnpm db:seed:projecao`; overwrite-always; [plano](plans/import-planilha-projecao.md))
- **A11** posição em votos do município (rank absoluto + % da própria votação) + ordenação da lista por votação · a lente de prioridade atual da mesa (sessão 2026-07-23) · ~0,5–1d · Janela 1–2 · E9 absorve como coluna/ordenação da fila · [plano](plans/ranking-votos-municipio.md)
- **E17** tabela comparativa dos Territórios de Identidade no Início (pedido do candidato) · somas/razões apenas, Metropolitano decomposto (salvaguardas MAUP) · ~1d · Janela 1–2 · primeira fatia de E12 · [plano](plans/tabela-ti-inicio.md)
- **A6** dobradinha 2026 automática quando o TSE publicar candidaturas · gatilho externo: pós-15/08 · camada de insight sobre o registro operacional `stateDeputy` (M4) · alimenta **E13** · [plano](plans/insight-dobradinha-2026.md)
@ -113,7 +114,7 @@ flowchart TD
D4 -.thread.-> D5
B8F2["B8 F2 Polígonos<br/>zonas de Salvador"]
B14n["B14 Município mais próximo"]
E4R["E4R Import planilha<br/>(seed estratégia)"]
E4R["E4R ✓ Import planilha<br/>(seed estratégia)"]
A11n["A11 Posição em votos"]
E17n["E17 Tabela TI no Início"]
@ -154,11 +155,11 @@ flowchart TD
B8F2 -.ZE Salvador.-> B14n
```
Paralelizáveis agora: **E4R → A11/E17** (semana de onboarding, sem deps), **E8** (A10 ✓ e remodelagem em produção), **C12** (paralelo a E8), **E16** (compõe o existente), **B14** (atalho geo no Início; soft B8 F2 só para ZE Salvador), fill-ins (O0+, RS+). **D3** só após smoke + folga e só se o fluxo sede-digita (C12) não absorver os deltas do ZAP (não compete com E8/E9).
Paralelizáveis agora: **A11/E17** (semana de onboarding; E4R ✓ seedou o quadro), **E8** (A10 ✓ e remodelagem em produção), **C12** (paralelo a E8), **E16** (compõe o existente), **B14** (atalho geo no Início; soft B8 F2 só para ZE Salvador), fill-ins (O0+, RS+). **D3** só após smoke + folga e só se o fluxo sede-digita (C12) não absorver os deltas do ZAP (não compete com E8/E9).
### Sequência por janela (só pendentes)
**Janela 1 — agora → 05/08 (convenções):** smoke pós-deploy em produção + onboarding do time (Onda 0 §2/§4), com **E4R → A11/E17** na semana de onboarding (quadro seedado + a lente da mesa prontos quando o time logar); gate de adoção: sinal Little Hire — ≥1 update espontâneo até 30/07, cobrança da tabela dispensada (planilhas já em `docs/sheets/`) — acompanhamento em [IMPROVE-APP-PLAN.md](IMPROVE-APP-PLAN.md); **R6** critique/polish; **E8** pode começar em paralelo; **B14** (atalho geo) se sobrar folga de campo no onboarding; Onda 0 jurídica em paralelo (externa).
**Janela 1 — agora → 05/08 (convenções):** smoke pós-deploy em produção + onboarding do time (Onda 0 §2/§4); **E4R ✓** em código (aplicar `pnpm db:seed:projecao` em produção após smoke); **A11/E17** na semana de onboarding; gate de adoção: sinal Little Hire — ≥1 update espontâneo até 30/07, cobrança da tabela dispensada (planilhas já em `docs/sheets/`) — acompanhamento em [IMPROVE-APP-PLAN.md](IMPROVE-APP-PLAN.md); **R6** critique/polish; **E8** pode começar em paralelo; **B14** (atalho geo) se sobrar folga de campo no onboarding; Onda 0 jurídica em paralelo (externa).
**Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; **E8** conta da cadeira → **E9** fila de alocação, com **C12** registro-fundação em paralelo (migrations cedo, longe do congelamento); **E16** dossiê do município (pedido O6 — compõe o existente, melhora com E8); D2 se sobrar folga.
@ -168,7 +169,7 @@ Paralelizáveis agora: **E4R → A11/E17** (semana de onboarding, sem deps), **E
### Cortes seguros / não cortáveis
**Não cortáveis:** Onda 0 (jurídico/Consent); **E4R** seed da planilha (Big Hire — o quadro nasce vazio sem ele); C2 dados reais; assimetria declarado×estimado (relação de campo); **E8**+**E9**+**C12** (a conta da cadeira, a fila e o registro ex-ante são o mínimo de "inteligência, não planilha" — e C12 é irrecuperável se não registrar durante a campanha).
**Não cortáveis:** Onda 0 (jurídico/Consent); ~~**E4R** seed da planilha~~ (entregue 2026-07-24 — ainda aplicar em produção após smoke); C2 dados reais; assimetria declarado×estimado (relação de campo); **E8**+**E9**+**C12** (a conta da cadeira, a fila e o registro ex-ante são o mínimo de "inteligência, não planilha" — e C12 é irrecuperável se não registrar durante a campanha).
**Cortes seguros** (se o prazo apertar, nesta ordem): **D5** inbox→rascunhos (manter registro manual + `wa.me`); **D4** envio bridge (manter `wa.me`); **D3** fundação do canal (atalho `wa.me` continua); **E12** camada TI (rollup manual por lista; **E17** já dá a leitura regional básica no Início); **E13** planejador de giros (rebaixado na fila de corte em 2026-07-24: "perna"/agenda é a restrição dominante nomeada em campo — cortar só depois de E12; agenda segue manual com J-A/J-B como guia); **E15** backtest (pós-eleição por definição — cortar = perder o aprendizado 2030); **E11** motor v1 (manter fila E9 sem sugestões); **B13** símbolo proporcional (manter quantis/LQ como escala); **E14** níveis (manter `priority` alta/normal); **B8 F2** polígonos (mapa continua agregado no município; manter F1 bairros); **D2** push (adiar); **A6**; **B14** município mais próximo (lista/busca e Recentes continuam); **A11**/**E17** (baratos e sem deps — cortar só em último caso); **E16** dossiê (último dos extras — pedido explícito de campo); débitos/fill-ins.

View file

@ -28,6 +28,7 @@
"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",
"db:seed:consent": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-consent.mjs",
"db:seed:projecao": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/import-projecao.mjs",
"build:demographics": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-demographics.mjs",
"build:election-aggregates": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/build-election-aggregates.mjs",
"build:geometries": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-geometries.mjs",
@ -130,6 +131,7 @@
"typescript-7": "npm:typescript@7.0.2",
"vite-tsconfig-paths": "6.0.5",
"vitest": "4.0.18",
"xlsx": "^0.18.5",
"yauzl": "^3.4.0"
},
"engines": {

View file

@ -228,6 +228,9 @@ importers:
vitest:
specifier: 4.0.18
version: 4.0.18(@types/node@22.19.9)(happy-dom@20.8.3)(jiti@2.7.0)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.10(@types/node@22.19.9)(@typescript/typescript6@6.0.2))(sass@1.77.4)(tsx@4.21.0)(yaml@2.9.0)
xlsx:
specifier: ^0.18.5
version: 0.18.5
yauzl:
specifier: ^3.4.0
version: 3.4.0
@ -3530,6 +3533,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
adler-32@1.3.1:
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
engines: {node: '>=0.8'}
agent-base@7.1.4:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
@ -3746,6 +3753,10 @@ packages:
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
cfb@1.2.2:
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
engines: {node: '>=0.8'}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
@ -3816,6 +3827,10 @@ packages:
code-block-writer@13.0.3:
resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==}
codepage@1.15.0:
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
engines: {node: '>=0.8'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@ -3893,6 +3908,11 @@ packages:
typescript:
optional: true
crc-32@1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'}
hasBin: true
croner@9.1.0:
resolution: {integrity: sha512-p9nwwR4qyT5W996vBZhdvBCnMhicY5ytZkR4D1Xj0wuTDEiMnjwR57Q3RXYY/s0EpX6Ay3vgIcfaR+ewGHsi+g==}
engines: {node: '>=18.0'}
@ -4577,6 +4597,10 @@ packages:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
frac@1.1.2:
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
engines: {node: '>=0.8'}
fresh@2.0.0:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
@ -6311,6 +6335,10 @@ packages:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
ssf@0.11.2:
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
engines: {node: '>=0.8'}
stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
@ -6900,10 +6928,18 @@ packages:
engines: {node: '>=8'}
hasBin: true
wmf@1.0.2:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
engines: {node: '>=0.8'}
word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
word@0.3.0:
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
engines: {node: '>=0.8'}
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
@ -6931,6 +6967,11 @@ packages:
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
engines: {node: '>=20'}
xlsx@0.18.5:
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
engines: {node: '>=0.8'}
hasBin: true
xml-name-validator@5.0.0:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
@ -10133,6 +10174,8 @@ snapshots:
acorn@8.16.0: {}
adler-32@1.3.1: {}
agent-base@7.1.4: {}
ajv-formats@3.0.1(ajv@8.17.1):
@ -10383,6 +10426,11 @@ snapshots:
ccount@2.0.1: {}
cfb@1.2.2:
dependencies:
adler-32: 1.3.1
crc-32: 1.2.2
chai@6.2.2: {}
chalk@4.1.2:
@ -10452,6 +10500,8 @@ snapshots:
code-block-writer@13.0.3: {}
codepage@1.15.0: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@ -10518,6 +10568,8 @@ snapshots:
optionalDependencies:
typescript: '@typescript/typescript6@6.0.2'
crc-32@1.2.2: {}
croner@9.1.0: {}
cross-env@7.0.3:
@ -11353,6 +11405,8 @@ snapshots:
forwarded@0.2.0: {}
frac@1.1.2: {}
fresh@2.0.0: {}
fs-extra@11.3.4:
@ -13439,6 +13493,10 @@ snapshots:
split2@4.2.0: {}
ssf@0.11.2:
dependencies:
frac: 1.1.2
stable-hash@0.0.5: {}
stackback@0.0.2: {}
@ -14054,8 +14112,12 @@ snapshots:
siginfo: 2.0.0
stackback: 0.0.2
wmf@1.0.2: {}
word-wrap@1.2.5: {}
word@0.3.0: {}
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
@ -14077,6 +14139,16 @@ snapshots:
is-wsl: 3.1.1
powershell-utils: 0.1.0
xlsx@0.18.5:
dependencies:
adler-32: 1.3.1
cfb: 1.2.2
codepage: 1.15.0
crc-32: 1.2.2
ssf: 0.11.2
wmf: 1.0.2
word: 0.3.0
xml-name-validator@5.0.0: {}
xmlchars@2.2.0: {}

546
scripts/import-projecao.mjs Normal file
View file

@ -0,0 +1,546 @@
/**
* E4R — Import único da planilha de projeção → municipality.voteGoals + priority.
*
* Provenance:
* - Canonical strategy file: docs/sheets/Mapa projeção de votos Solla 2026.xlsx (MAPA GERAL)
* - Reference (Salvador / A×B diff): docs/sheets/Mapa_projecao_votos_Solla_2026.xlsx
* - Writes only numbers/enums (zero PII). Never creates Contact/leadership.
* - Always overwrites voteGoals + priority on matched rows (re-seed when the mesa
* sends a newer sheet over an already-populated DB). Does not touch lastUpdateAt.
*
* Safety: refuses non-local DATABASE_URL unless ALLOW_REMOTE_DB=true.
*
* Runbook:
* pnpm db:start
* pnpm db:seed:projecao -- --dry-run
* pnpm db:seed:projecao
* ALLOW_REMOTE_DB=true DATABASE_URL=<prod> pnpm db:seed:projecao -- --dry-run
* ALLOW_REMOTE_DB=true DATABASE_URL=<prod> pnpm db:seed:projecao
* pnpm db:seed:projecao -- --file path/to/newer.xlsx --dry-run
*
* Flags:
* --dry-run Report only (no writes)
* --file <path> Override canonical workbook (default: docs/sheets/Mapa projeção…)
* --reference <path> Override reference workbook for A×B diff
*/
import { existsSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { config as loadEnv } from 'dotenv'
import { getPayload } from 'payload'
import XLSX from 'xlsx'
import { assertLocalDatabase } from './assert-local-database.mjs'
loadEnv({ path: '.env.local' })
loadEnv({ path: '.env' })
const __dirname = dirname(fileURLToPath(import.meta.url))
const repoRoot = join(__dirname, '..')
const DEFAULT_CANONICAL = join(repoRoot, 'docs/sheets/Mapa projeção de votos Solla 2026.xlsx')
const DEFAULT_REFERENCE = join(repoRoot, 'docs/sheets/Mapa_projecao_votos_Solla_2026.xlsx')
const MAPA_GERAL = 'MAPA GERAL'
const PRIORITARIAS = 'PRIORITÁRIAS'
const SALVADOR_CITY = 'Salvador'
/**
* @typedef {{ good: number, regular: number, minimum: number }} ProjectionVoteGoals
* @typedef {{
* municipalityLabel: string
* regionLabel: string | null
* votes2014: number | null
* votes2018: number | null
* votes2022: number | null
* expectationRaw: string | null
* priorityRaw: string | null
* }} ProjectionSheetRow
* @typedef {{ good?: number | null, regular?: number | null, minimum?: number | null } | null | undefined} DbVoteGoals
*/
const parseArgs = (argv) => {
const options = {
dryRun: false,
file: DEFAULT_CANONICAL,
reference: DEFAULT_REFERENCE,
referenceExplicit: false,
}
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]
if (arg === '--') continue
if (arg === '--dry-run') {
options.dryRun = true
continue
}
if (arg === '--file') {
const next = argv[i + 1]
if (!next || next.startsWith('--')) {
throw new Error('--file requer um caminho')
}
options.file = resolve(next)
i += 1
continue
}
if (arg === '--reference') {
const next = argv[i + 1]
if (!next || next.startsWith('--')) {
throw new Error('--reference requer um caminho')
}
options.reference = resolve(next)
options.referenceExplicit = true
i += 1
continue
}
throw new Error(`Flag desconhecida: ${arg}`)
}
return options
}
const cellString = (value) => {
if (value == null) return null
if (typeof value === 'string') {
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
return String(value).trim() || null
}
/** @param {import('xlsx').WorkBook} workbook */
const sheetMatrix = (workbook, sheetName) => {
const sheet = workbook.Sheets[sheetName]
if (!sheet) {
throw new Error(`Aba "${sheetName}" não encontrada no workbook`)
}
return XLSX.utils.sheet_to_json(sheet, {
header: 1,
defval: null,
raw: true,
blankrows: false,
})
}
const findHeaderIndex = (matrix, required) => {
for (let i = 0; i < matrix.length; i += 1) {
const row = matrix[i]
if (!Array.isArray(row)) continue
const labels = row.map((cell) => cellString(cell)?.toUpperCase() ?? '')
if (required.every((label) => labels.includes(label.toUpperCase()))) {
const indexByLabel = new Map()
for (let col = 0; col < row.length; col += 1) {
const label = cellString(row[col])
if (label) indexByLabel.set(label.toUpperCase(), col)
}
return { rowIndex: i, indexByLabel }
}
}
throw new Error(`Cabeçalho não encontrado (precisa de: ${required.join(', ')})`)
}
/**
* @param {import('xlsx').WorkBook} workbook
* @param {(raw: unknown) => number | null} parseSheetNumber
* @param {(value: string) => string} normalizeKey
* @returns {Map<string, ProjectionSheetRow>}
*/
const readMapaGeralRows = (workbook, parseSheetNumber, normalizeKey) => {
const matrix = sheetMatrix(workbook, MAPA_GERAL)
const { rowIndex, indexByLabel } = findHeaderIndex(matrix, [
'MUNICÍPIO',
'PRIORIDADE',
'REGIÃO',
'EXPECTATIVA 2026',
])
const municipalityCol = indexByLabel.get('MUNICÍPIO')
const priorityCol = indexByLabel.get('PRIORIDADE')
const regionCol = indexByLabel.get('REGIÃO')
const expectationCol = indexByLabel.get('EXPECTATIVA 2026')
const votes2014Col = indexByLabel.get('VOTOS 2014')
const votes2018Col = indexByLabel.get('VOTOS 2018')
const votes2022Col = indexByLabel.get('VOTOS 2022')
/** @type {Map<string, ProjectionSheetRow>} */
const byKey = new Map()
for (let i = rowIndex + 1; i < matrix.length; i += 1) {
const row = matrix[i]
if (!Array.isArray(row)) continue
const municipalityLabel = cellString(row[municipalityCol])
if (!municipalityLabel) continue
if (municipalityLabel.toUpperCase() === 'MUNICÍPIO') continue
// Footer/summary rows sometimes land in the MUNICÍPIO column (e.g. "Alta: 43 | Média: …").
if (municipalityLabel.includes('|') || /^alta\s*:/i.test(municipalityLabel)) continue
byKey.set(normalizeKey(municipalityLabel), {
municipalityLabel,
regionLabel: cellString(row[regionCol]),
votes2014: parseSheetNumber(row[votes2014Col]),
votes2018: parseSheetNumber(row[votes2018Col]),
votes2022: parseSheetNumber(row[votes2022Col]),
expectationRaw: cellString(row[expectationCol]),
priorityRaw: cellString(row[priorityCol]),
})
}
return byKey
}
/**
* @param {import('xlsx').WorkBook} workbook
* @param {(value: string) => string} normalizeKey
*/
const readPrioritariaNames = (workbook, normalizeKey) => {
const matrix = sheetMatrix(workbook, PRIORITARIAS)
const { rowIndex, indexByLabel } = findHeaderIndex(matrix, ['MUNICÍPIO'])
const municipalityCol = indexByLabel.get('MUNICÍPIO')
/** @type {Set<string>} */
const names = new Set()
for (let i = rowIndex + 1; i < matrix.length; i += 1) {
const row = matrix[i]
if (!Array.isArray(row)) continue
const label = cellString(row[municipalityCol])
if (!label || label.toUpperCase() === 'MUNICÍPIO') continue
names.add(normalizeKey(label))
}
return names
}
/** @param {DbVoteGoals} raw */
const asCompleteGoals = (raw) => {
if (
raw &&
typeof raw.good === 'number' &&
typeof raw.regular === 'number' &&
typeof raw.minimum === 'number'
) {
return { good: raw.good, regular: raw.regular, minimum: raw.minimum }
}
return null
}
/** @param {ProjectionVoteGoals | null} a @param {ProjectionVoteGoals | null} b */
const goalsEqual = (a, b) =>
a != null && b != null && a.good === b.good && a.regular === b.regular && a.minimum === b.minimum
/** @param {ProjectionVoteGoals | null} goals */
const formatGoals = (goals) =>
goals ? `Bom:${goals.good} | Regular:${goals.regular} | Minimo:${goals.minimum}` : '(vazio)'
assertLocalDatabase(
'seed:projecao',
'This script overwrites municipality voteGoals/priority from the projection sheet.',
)
const options = parseArgs(process.argv.slice(2))
if (!existsSync(options.file)) {
console.error(`\n[seed:projecao] Arquivo canônico não encontrado: ${options.file}\n`)
process.exit(1)
}
const config = (await import('../src/payload.config.ts')).default
const { canonicalizeMunicipalityName, UnknownMunicipalityError, normalizeMunicipalityKey } =
await import('../src/lib/electionResults.ts')
const { municipalityCatalogEntriesForCity } = await import('../src/lib/municipalityCatalog.ts')
const { getMunicipalityFederalBaseline } = await import('../src/lib/bahiaElectionAggregates.ts')
const { territoryForCity } = await import('../src/lib/bahiaTerritories.ts')
const { parseExpectationCell, mapSheetPriority, parseSheetNumber } = await import(
'../src/lib/projectionSheetParse.ts'
)
const { getVoteGoalsOrderViolation } = await import('../src/utilities/voteGoals.ts')
const { withPayloadTransaction } = await import('../src/utilities/payloadTransaction.ts')
const canonicalBook = XLSX.readFile(options.file, { cellDates: false })
const canonicalRows = readMapaGeralRows(canonicalBook, parseSheetNumber, normalizeMunicipalityKey)
const prioritarias = readPrioritariaNames(canonicalBook, normalizeMunicipalityKey)
/** @type {Map<string, ProjectionSheetRow> | null} */
let referenceRows = null
const referencePath = options.reference
const referenceSameAsCanonical = resolve(referencePath) === resolve(options.file)
if (!referenceSameAsCanonical && existsSync(referencePath)) {
try {
referenceRows = readMapaGeralRows(
XLSX.readFile(referencePath, { cellDates: false }),
parseSheetNumber,
normalizeMunicipalityKey,
)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (options.referenceExplicit) {
console.error(`\n[seed:projecao] Falha ao ler --reference (${referencePath}): ${message}\n`)
process.exit(1)
}
console.warn(`[seed:projecao] Planilha de referência ignorada (${referencePath}): ${message}`)
}
} else if (options.referenceExplicit && !existsSync(referencePath)) {
console.error(`\n[seed:projecao] --reference não encontrado: ${referencePath}\n`)
process.exit(1)
}
const payload = await getPayload({ config })
const municipalityDocs = await payload.find({
collection: 'municipality',
depth: 0,
limit: 1000,
pagination: false,
overrideAccess: true,
select: {
slug: true,
name: true,
city: true,
kind: true,
priority: true,
voteGoals: true,
},
})
/** @type {Map<string, { id: number, slug: string, priority: string | null, voteGoals: DbVoteGoals }>} */
const docsBySlug = new Map()
for (const doc of municipalityDocs.docs) {
if (typeof doc.slug === 'string') {
docsBySlug.set(doc.slug, {
id: doc.id,
slug: doc.slug,
priority: doc.priority ?? null,
voteGoals: doc.voteGoals ?? null,
})
}
}
const matched = []
const skippedSalvador = []
const unmatched = []
const parseErrors = []
const warnings = []
const axbDiffs = []
for (const sheetRow of canonicalRows.values()) {
let city
try {
city = canonicalizeMunicipalityName(sheetRow.municipalityLabel)
} catch (error) {
if (error instanceof UnknownMunicipalityError) {
unmatched.push({
label: sheetRow.municipalityLabel,
reason: `nome sem mapeamento canônico: "${sheetRow.municipalityLabel}"`,
})
continue
}
throw error
}
if (city === SALVADOR_CITY) {
skippedSalvador.push({
label: sheetRow.municipalityLabel,
expectationRaw: sheetRow.expectationRaw,
priorityRaw: sheetRow.priorityRaw,
})
continue
}
const catalogEntries = municipalityCatalogEntriesForCity(city).filter(
(entry) => entry.kind === 'municipio',
)
if (catalogEntries.length !== 1) {
unmatched.push({
label: sheetRow.municipalityLabel,
reason: `catálogo retornou ${catalogEntries.length} município(s) para "${city}"`,
})
continue
}
const catalogEntry = catalogEntries[0]
const doc = docsBySlug.get(catalogEntry.slug)
if (!doc) {
unmatched.push({
label: sheetRow.municipalityLabel,
reason: `município "${catalogEntry.slug}" não existe no banco (rode as migrations/seed do catálogo)`,
})
continue
}
const voteGoals = parseExpectationCell(sheetRow.expectationRaw)
if (!voteGoals) {
if (sheetRow.expectationRaw) {
parseErrors.push({
label: sheetRow.municipalityLabel,
slug: catalogEntry.slug,
raw: sheetRow.expectationRaw,
})
}
continue
}
if (getVoteGoalsOrderViolation(voteGoals)) {
parseErrors.push({
label: sheetRow.municipalityLabel,
slug: catalogEntry.slug,
raw: `${sheetRow.expectationRaw} (ordem Bom ≥ Regular ≥ Mínimo inválida)`,
})
continue
}
const priority = mapSheetPriority(sheetRow.priorityRaw)
const sheetKey = normalizeMunicipalityKey(sheetRow.municipalityLabel)
if (prioritarias.has(sheetKey) && priority !== 'alta') {
warnings.push(
`Prioritária "${sheetRow.municipalityLabel}" sem PRIORIDADE=alta no MAPA GERAL (ficará "${priority}").`,
)
}
const baseline = getMunicipalityFederalBaseline(catalogEntry.slug)
for (const [year, sheetVotes] of [
['2014', sheetRow.votes2014],
['2018', sheetRow.votes2018],
['2022', sheetRow.votes2022],
]) {
if (sheetVotes == null) continue
const artifactVotes = baseline.votesByYear[year] ?? 0
if (artifactVotes !== sheetVotes) {
warnings.push(
`Votos ${year} divergem em ${sheetRow.municipalityLabel} (${catalogEntry.slug}): planilha=${sheetVotes}, artefato=${artifactVotes}.`,
)
}
}
const territory = territoryForCity(city)
if (sheetRow.regionLabel && territory) {
if (normalizeMunicipalityKey(sheetRow.regionLabel) !== normalizeMunicipalityKey(territory)) {
warnings.push(
`REGIÃO diverge em ${sheetRow.municipalityLabel}: planilha="${sheetRow.regionLabel}", catálogo="${territory}".`,
)
}
} else if (sheetRow.regionLabel && !territory) {
warnings.push(
`REGIÃO da planilha sem TI no catálogo para ${sheetRow.municipalityLabel}: "${sheetRow.regionLabel}".`,
)
}
if (referenceRows) {
const ref = referenceRows.get(sheetKey)
if (ref) {
const refGoals = parseExpectationCell(ref.expectationRaw)
const refPriority = mapSheetPriority(ref.priorityRaw)
if (refGoals && (!goalsEqual(voteGoals, refGoals) || priority !== refPriority)) {
axbDiffs.push({
label: sheetRow.municipalityLabel,
a: { voteGoals, priority },
b: { voteGoals: refGoals, priority: refPriority },
})
}
}
}
matched.push({ doc, voteGoals, priority })
}
console.log('\n=== E4R import projeção — relatório ===')
console.log(`Arquivo canônico: ${options.file}`)
if (referenceRows) console.log(`Arquivo referência: ${options.reference}`)
console.log(`Modo: ${options.dryRun ? 'DRY-RUN (sem escrita)' : 'ESCRITA (overwrite voteGoals+priority)'}`)
console.log(`Linhas MAPA GERAL (com município): ${canonicalRows.size}`)
console.log(`Casadas com EXPECTATIVA parseável: ${matched.length}`)
console.log(`Salvador pulado: ${skippedSalvador.length}`)
console.log(`Não casadas: ${unmatched.length}`)
console.log(`EXPECTATIVA ilegível: ${parseErrors.length}`)
console.log(`Avisos: ${warnings.length}`)
console.log(`Divergências A×B: ${axbDiffs.length}`)
console.log(`Prioritárias na aba: ${prioritarias.size}`)
if (skippedSalvador.length > 0) {
console.log('\n— Salvador (pulado; metas por ZE via UI) —')
for (const row of skippedSalvador) {
console.log(
` ${row.label}: ${row.expectationRaw ?? '(sem expectativa)'} · prioridade=${row.priorityRaw ?? '(vazia)'}`,
)
}
}
if (unmatched.length > 0) {
console.log('\n— Não casadas —')
for (const row of unmatched.slice(0, 40)) {
console.log(` ${row.label}: ${row.reason}`)
}
if (unmatched.length > 40) console.log(` … +${unmatched.length - 40} mais`)
}
if (parseErrors.length > 0) {
console.log('\n— EXPECTATIVA não parseada (linha ignorada) —')
for (const row of parseErrors.slice(0, 30)) {
console.log(` ${row.label} (${row.slug}): ${JSON.stringify(row.raw)}`)
}
if (parseErrors.length > 30) console.log(` … +${parseErrors.length - 30} mais`)
}
if (axbDiffs.length > 0) {
console.log('\n— Divergências A (canônico) × B (referência) — importa-se A —')
for (const diff of axbDiffs) {
console.log(
` ${diff.label}: A=${formatGoals(diff.a.voteGoals)}/${diff.a.priority} · B=${formatGoals(diff.b.voteGoals)}/${diff.b.priority}`,
)
}
}
if (warnings.length > 0) {
console.log('\n— Avisos (não bloqueiam) —')
for (const warning of warnings.slice(0, 60)) {
console.log(` ${warning}`)
}
if (warnings.length > 60) console.log(` … +${warnings.length - 60} mais`)
}
const changing = matched.filter((row) => {
const currentGoals = asCompleteGoals(row.doc.voteGoals)
const currentPriority = row.doc.priority === 'alta' ? 'alta' : 'normal'
return !goalsEqual(currentGoals, row.voteGoals) || currentPriority !== row.priority
})
console.log(`\nEscritas planejadas: ${matched.length} (delta vs DB: ${changing.length})`)
console.log('Amostra (até 8):')
for (const row of matched.slice(0, 8)) {
console.log(
` ${row.doc.slug}: ${formatGoals(row.voteGoals)} · priority=${row.priority} (DB era ${formatGoals(
asCompleteGoals(row.doc.voteGoals),
)}/${row.doc.priority ?? 'normal'})`,
)
}
if (options.dryRun) {
console.log('\n[seed:projecao] Dry-run concluído — nenhuma escrita.\n')
process.exit(0)
}
try {
await withPayloadTransaction(payload, async ({ req }) => {
for (const row of matched) {
await payload.update({
collection: 'municipality',
id: row.doc.id,
data: {
voteGoals: row.voteGoals,
priority: row.priority,
},
depth: 0,
overrideAccess: true,
req,
})
}
})
console.log(`\n[seed:projecao] OK — ${matched.length} município(s) atualizados (overwrite).\n`)
process.exit(0)
} catch (error) {
console.error(`\n[seed:projecao] ${error instanceof Error ? error.message : String(error)}\n`)
process.exit(1)
}

View file

@ -0,0 +1,72 @@
/**
* Pure parsers for the campaign projection spreadsheet (E4R).
* Sheet cells → voteGoals / priority — no Payload, no I/O.
*/
export type ProjectionVoteGoals = {
good: number
regular: number
minimum: number
}
export type ProjectionPriority = 'alta' | 'normal'
/** Strip Brazilian thousand separators (`10.000` → 10000) and parse an integer. */
export const parseSheetNumber = (raw: string | number | null | undefined): number | null => {
if (raw == null) return null
if (typeof raw === 'number') {
if (!Number.isFinite(raw)) return null
return Math.round(raw)
}
const trimmed = raw.trim()
if (!trimmed) return null
const normalized = trimmed.replace(/\./g, '').replace(/,/g, '.')
if (!/^-?\d+(\.\d+)?$/.test(normalized)) return null
const value = Number(normalized)
if (!Number.isFinite(value)) return null
return Math.round(value)
}
// Accepts `|` or `\` between Bom/Regular/Mínimo (some sheet exports use backslash).
const LABELED_EXPECTATION =
/^Bom\s*:\s*([\d.]+)\s*[|\\]\s*Regular\s*:\s*([\d.]+)\s*[|\\]\s*M[ií]nimo\s*:\s*([\d.]+)\s*$/i
const SLASH_EXPECTATION = /^([\d.]+)\s*\/\s*([\d.]+)\s*\/\s*([\d.]+)\s*$/
export const parseExpectationCell = (
raw: string | null | undefined,
): ProjectionVoteGoals | null => {
if (raw == null) return null
const trimmed = raw.trim().replace(/\s+/g, ' ')
if (!trimmed) return null
const labeled = trimmed.match(LABELED_EXPECTATION)
if (labeled) {
const good = parseSheetNumber(labeled[1])
const regular = parseSheetNumber(labeled[2])
const minimum = parseSheetNumber(labeled[3])
if (good == null || regular == null || minimum == null) return null
return { good, regular, minimum }
}
const slash = trimmed.match(SLASH_EXPECTATION)
if (slash) {
const good = parseSheetNumber(slash[1])
const regular = parseSheetNumber(slash[2])
const minimum = parseSheetNumber(slash[3])
if (good == null || regular == null || minimum == null) return null
return { good, regular, minimum }
}
return null
}
/** `alta` → alta; anything else (incl. Baixa / empty) → normal. */
export const mapSheetPriority = (raw: string | null | undefined): ProjectionPriority => {
if (raw == null) return 'normal'
return raw.trim().toLowerCase() === 'alta' ? 'alta' : 'normal'
}

View file

@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest'
import {
mapSheetPriority,
parseExpectationCell,
parseSheetNumber,
} from '@/lib/projectionSheetParse'
describe('parseSheetNumber', () => {
it('parses Brazilian thousand separators', () => {
expect(parseSheetNumber('10.000')).toBe(10000)
expect(parseSheetNumber('1.400')).toBe(1400)
expect(parseSheetNumber(350)).toBe(350)
})
it('returns null for empty or non-numeric', () => {
expect(parseSheetNumber('')).toBeNull()
expect(parseSheetNumber(null)).toBeNull()
expect(parseSheetNumber('n/a')).toBeNull()
})
})
describe('parseExpectationCell', () => {
it('parses labeled pipe format with and without spaces', () => {
expect(parseExpectationCell('Bom:200 | Regular: 150 | Minimo: 100')).toEqual({
good: 200,
regular: 150,
minimum: 100,
})
expect(parseExpectationCell('Bom: 300 | Regular: 200 | Minimo: 100')).toEqual({
good: 300,
regular: 200,
minimum: 100,
})
expect(parseExpectationCell('Bom: 200| Regular: 150| Minimo: 100')).toEqual({
good: 200,
regular: 150,
minimum: 100,
})
})
it('parses thousand separators inside labeled cells', () => {
expect(parseExpectationCell('Bom: 2.000 | Regular: 1.500 | Minimo: 1.000')).toEqual({
good: 2000,
regular: 1500,
minimum: 1000,
})
expect(parseExpectationCell('Bom: 10.000 | Regular: 8.000 | Minimo: 6.000')).toEqual({
good: 10000,
regular: 8000,
minimum: 6000,
})
})
it('accepts Mínimo with accent', () => {
expect(parseExpectationCell('Bom: 100 | Regular: 80 | Mínimo: 50')).toEqual({
good: 100,
regular: 80,
minimum: 50,
})
})
it('parses slash format', () => {
expect(parseExpectationCell('800/500/400')).toEqual({
good: 800,
regular: 500,
minimum: 400,
})
expect(parseExpectationCell('300 /200 /100')).toEqual({
good: 300,
regular: 200,
minimum: 100,
})
})
it('tolerates backslash separators from sheet export glitches', () => {
expect(parseExpectationCell('Bom:300 \\Regular:150\\ mínimo: 50')).toEqual({
good: 300,
regular: 150,
minimum: 50,
})
})
it('returns null for empty or unrecognized cells', () => {
expect(parseExpectationCell(null)).toBeNull()
expect(parseExpectationCell('')).toBeNull()
expect(parseExpectationCell('Bom: 50 / regular: 30 / otmo: 100')).toBeNull()
})
})
describe('mapSheetPriority', () => {
it('maps alta to alta regardless of case', () => {
expect(mapSheetPriority('alta')).toBe('alta')
expect(mapSheetPriority('Alta')).toBe('alta')
expect(mapSheetPriority(' ALTA ')).toBe('alta')
})
it('maps Baixa, empty, and unknown to normal', () => {
expect(mapSheetPriority('Baixa')).toBe('normal')
expect(mapSheetPriority('')).toBe('normal')
expect(mapSheetPriority(null)).toBe('normal')
expect(mapSheetPriority('média')).toBe('normal')
})
})