From d14ed67801a7bb351452adad07ea32aaefccb3e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Fri, 24 Jul 2026 13:13:46 -0300 Subject: [PATCH] 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. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 2 + AGENTS.md | 2 + docs/plans/import-planilha-projecao.md | 37 +- docs/plans/mapa-projecao-municipios.md | 4 +- docs/roadmap.md | 15 +- package.json | 2 + pnpm-lock.yaml | 72 +++ scripts/import-projecao.mjs | 546 ++++++++++++++++++ src/lib/projectionSheetParse.ts | 72 +++ tests/unit/projectionSheetParse.unit.spec.ts | 104 ++++ 10 files changed, 834 insertions(+), 22 deletions(-) create mode 100644 scripts/import-projecao.mjs create mode 100644 src/lib/projectionSheetParse.ts create mode 100644 tests/unit/projectionSheetParse.unit.spec.ts diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index fff2f038..4422d321 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -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`), 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` 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. diff --git a/AGENTS.md b/AGENTS.md index bbc9bbe9..4b518d19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`), 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) diff --git a/docs/plans/import-planilha-projecao.md b/docs/plans/import-planilha-projecao.md index 9833e972..9d6dd029 100644 --- a/docs/plans/import-planilha-projecao.md +++ b/docs/plans/import-planilha-projecao.md @@ -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
(3 formatos + milhar)"] Match["canonicalizeMunicipalityName
+ municipalityCatalog"] Sanity["sanity: votos × artefato
REGIÃO × bahiaTerritories"] - Write["Payload Local API
(voteGoals, priority; só vazios)"] + Write["Payload Local API
(voteGoals, priority; overwrite)"] Report["relatório stdout
(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= 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= 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 ` + 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 diff --git a/docs/plans/mapa-projecao-municipios.md b/docs/plans/mapa-projecao-municipios.md index cfbd3cbb..a80817bc 100644 --- a/docs/plans/mapa-projecao-municipios.md +++ b/docs/plans/mapa-projecao-municipios.md @@ -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: — diff --git a/docs/roadmap.md b/docs/roadmap.md index d0d88b54..76c7f407 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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
zonas de Salvador"] B14n["B14 Município mais próximo"] - E4R["E4R Import planilha
(seed estratégia)"] + E4R["E4R ✓ Import planilha
(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. diff --git a/package.json b/package.json index c807c32a..ede7df0c 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dac67e68..527093e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/scripts/import-projecao.mjs b/scripts/import-projecao.mjs new file mode 100644 index 00000000..fb03f322 --- /dev/null +++ b/scripts/import-projecao.mjs @@ -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= pnpm db:seed:projecao -- --dry-run + * ALLOW_REMOTE_DB=true DATABASE_URL= pnpm db:seed:projecao + * pnpm db:seed:projecao -- --file path/to/newer.xlsx --dry-run + * + * Flags: + * --dry-run Report only (no writes) + * --file Override canonical workbook (default: docs/sheets/Mapa projeção…) + * --reference 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} + */ +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} */ + 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} */ + 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 | 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} */ +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) +} diff --git a/src/lib/projectionSheetParse.ts b/src/lib/projectionSheetParse.ts new file mode 100644 index 00000000..f7b68f9b --- /dev/null +++ b/src/lib/projectionSheetParse.ts @@ -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' +} diff --git a/tests/unit/projectionSheetParse.unit.spec.ts b/tests/unit/projectionSheetParse.unit.spec.ts new file mode 100644 index 00000000..470e1abf --- /dev/null +++ b/tests/unit/projectionSheetParse.unit.spec.ts @@ -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') + }) +})