mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
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:
parent
cc0087fd5f
commit
d14ed67801
10 changed files with 834 additions and 22 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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: —
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
546
scripts/import-projecao.mjs
Normal 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)
|
||||
}
|
||||
72
src/lib/projectionSheetParse.ts
Normal file
72
src/lib/projectionSheetParse.ts
Normal 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'
|
||||
}
|
||||
104
tests/unit/projectionSheetParse.unit.spec.ts
Normal file
104
tests/unit/projectionSheetParse.unit.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue