Import projection-sheet strategy columns into municipalities (E4R fase 2)
- pnpm db:seed:projecao now also imports SITUAÇÃO -> politicalTrend, DOBRADINHAS -> stateDeputy + stateDeputies + dobradinhaNotes, LIDERANÇAS -> name-only contact + leadership (statewide slug dedupe), ASSESSOR RESPONSÁVEL -> campaignUser + advisors (Edizio=coordinator, Solla=candidate, placeholder @planilha.invalid emails), ENCAMINHAMENTOS -> nextSteps, OBSERVAÇÃO -> strengths/risks (curated). PRIORITÁRIAS overrides MAPA GERAL when non-empty; Salvador stays skipped; relationships are unions; re-run is idempotent. - parseSituationCell + splitNameCell (noise/collective/action filters, honorific protection, parenthetical extraction) with unit tests. - Contact.phone optional (migration 20260724_175500_contact_phone_optional); phone invariant hooks skip phone-less contacts; invite flows guard. - candidate role now eligible for municipality/action-plan advisors (eligibleCampaignStaffWhere) per product decision.
This commit is contained in:
parent
c2a1570b0c
commit
0407f02ce2
23 changed files with 1101 additions and 94 deletions
|
|
@ -9,7 +9,7 @@ alwaysApply: false
|
|||
|
||||
- **B15 registrado (2026-07-24):** ordenar `/campanha/municipios` pelo header da coluna — contrato URL `?sort=`/`?dir=`, clique no header (desktop) + select mobile; A11 consome a key `votos`. Plano: [`ordenacao-colunas-lista-municipios.md`](../../../docs/plans/ordenacao-colunas-lista-municipios.md).
|
||||
- **Fill-in registrado (2026-07-24):** seletor **Cenário** de `/campanha/municipios` → fileira de `MunicipalityFilters` (provider envolve filtros+resultados; overview sem seletor). Plano: [`cenario-junto-filtros-municipios.md`](../../../docs/plans/cenario-junto-filtros-municipios.md).
|
||||
- **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 → `expectedVotes` (Bom→otimista, Regular→média, Mínimo→pessimista) + `priority` com **overwrite-always** (re-seed quando chegar planilha nova); Salvador pulado; zero PII; dry-run + runbook. No mesmo dia o grupo duplicado `voteGoals` foi removido do app (ver Decisões). Seed local: arquivo `docs/sheets/Mapa projeção de votos Solla 2026.xlsx`, **189** municípios com estimativas, **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).
|
||||
- **E4R entregue em código + seed em produção (2026-07-24):** `pnpm db:seed:projecao` (`scripts/import-projecao.mjs` + `src/lib/projectionSheetParse.ts`) importa MAPA GERAL canônico → `expectedVotes` (Bom→otimista, Regular→média, Mínimo→pessimista) + `priority` com **overwrite-always**; Salvador pulado; zero PII. No mesmo dia o grupo duplicado `voteGoals` foi removido do app (ver Decisões). Seed local e **produção Neon** (2026-07-24): arquivo canônico `docs/sheets/Mapa projeção de votos Solla 2026.xlsx` (+ referência `Mapa_projecao_votos_Solla_2026.xlsx` só A×B), **189** municípios atualizados, **50** `priority=alta`, Salvador pulado, re-dry-run pós-apply **delta=0**. 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ó `expectedVotes`/`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), **B15 ordenar lista por coluna**. Planos novos: `import-planilha-projecao.md`, `ranking-votos-municipio.md`, `tabela-ti-inicio.md`, `dossie-municipio.md`, `municipio-mais-proximo.md`, `ordenacao-colunas-lista-municipios.md`, `cenario-junto-filtros-municipios.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).
|
||||
|
|
@ -144,6 +144,7 @@ alwaysApply: false
|
|||
- Em 2026-07-19, decisões de produto da Trilha E (mapa de projeção): **sem entidade nova por município** — `electoralNucleus` absorve a camada estratégica (metas, prioridade, dobradinhas) e a visão município-a-município é derivada de núcleos + baseline TSE agregados por `cities[]`; **série histórica 2014/2018 vem do TSE oficial** (seed A3 parametrizado por ano, só dep. federal turno 1 BA), não dos números da planilha; **E4 import da planilha cortado** — dados estratégicos entram manualmente via UI E1+E3 (metas, prioridade, dobradinhas, encaminhamentos); o CRM real (`Contact`/`leadership`) continua pelo fluxo com Consent; tendência (Queda/Mantém/Aumento) é **derivada em leitura** (`computeVoteTrend`, limiares versionados), não persistida.
|
||||
- Em 2026-07-19, insight “oportunidade de virada” absorvido como **Fase 2** de [insight-alavancagem-chapa.md](../../../docs/plans/insight-alavancagem-chapa.md) (A5): majoritários locais de esquerda + (vencedor federal de direita **ou** share da direita nos nominais ≥ limiar); mapa estático de espectro partidário; sem item paralelo.
|
||||
- Em 2026-07-19, **A8 Perfis do eleitorado IBGE registrado:** perfil médio/comum derivado de indicadores municipais do Censo (artefato estático + ponderação por `cities[]`), exibido na aba Eleitorado junto com `voterProfiles` manuais; calculado não sobrescreve o array; sem migration/Consent. Plano: [`docs/plans/perfil-eleitorado-ibge.md`](../../../docs/plans/perfil-eleitorado-ibge.md).
|
||||
- Em 2026-07-24, **E4R fase 2 entregue (decisão do usuário — supera o corte "só números" de E4R):** `pnpm db:seed:projecao` importa também SITUAÇÃO→`politicalTrend` (com nota de proveniência), DOBRADINHAS→`stateDeputy`+`stateDeputies`+`dobradinhaNotes`, LIDERANÇAS→`contact` name-only sem telefone+`leadership` (dedup por slug), ASSESSOR→`campaignUser`+`advisors` (Edizio=coordinator, Solla=candidate; email placeholder `@planilha.invalid`), ENCAMINHAMENTOS→`nextSteps`, OBSERVAÇÃO→forças/riscos curadas; PRIORITÁRIAS sobrepõe MAPA GERAL; Salvador segue pulado (decisão do usuário); relações por união; re-run idempotente. Mudanças de modelo: `Contact.phone` opcional (migration `20260724_175500_contact_phone_optional`) e `candidate` elegível como assessor de município/plano (`eligibleCampaignStaffWhere`). Nomes políticos entraram sem trilha de consentimento — regularizar com a assessoria. Plano: [`docs/plans/import-planilha-projecao.md`](../../../docs/plans/import-planilha-projecao.md).
|
||||
- Fonte: `/Users/francisco.solla/.cursor/plans/núcleos_eleitorais_mvp_7f788d29.plan.md`.
|
||||
|
||||
## Bloqueadores e próximas frentes
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ 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.expectedVotes` + `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` (Bom→`optimistic`, Regular→`central`, Mínimo→`pessimistic`). No mesmo dia, o grupo duplicado `municipality.voteGoals` ("Meta Bom/Regular/Mínimo") foi **removido** — collection, zod, view models, form/card e utilitário `voteGoals.ts` — via migration `20260724_133600_drop_municipality_vote_goals` (backfill metas→estimativas onde a estimativa estava vazia): `expectedVotes` (pessimista/média/otimista) é a única série por cenário do município. Plano: [`docs/plans/import-planilha-projecao.md`](docs/plans/import-planilha-projecao.md).
|
||||
**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.expectedVotes` + `priority` (re-seed quando a mesa manda planilha nova); Salvador pulado; `--dry-run` + guard `ALLOW_REMOTE_DB`; parser puro em `src/lib/projectionSheetParse.ts` (Bom→`optimistic`, Regular→`central`, Mínimo→`pessimistic`). **Fase 2 (mesmo dia, decisão do produto — não é mais zero PII):** o seed também importa as colunas de estratégia, com PRIORITÁRIAS sobrepondo MAPA GERAL quando não-vazia — SITUAÇÃO→`politicalTrend` (QUEDA/MANTÉM/AUMENTO→desfavorável/neutra/favorável, com nota de proveniência), DOBRADINHAS→`stateDeputy`+`stateDeputies` (união; célula crua em `dobradinhaNotes`), LIDERANÇAS→`contact` name-only **sem telefone** + `leadership` (dedup estadual por slug do nome; `Contact.phone` virou opcional via migration `20260724_175500_contact_phone_optional`, UI continua exigindo), ASSESSOR RESPONSÁVEL→`campaignUser`+`advisors` (união; Edizio=coordinator, Solla=candidate — `candidate` agora é elegível como assessor em `eligibleCampaignStaffWhere`; novos usuários com email `<slug>@planilha.invalid` + senha aleatória, sem login até um admin trocar), ENCAMINHAMENTOS→`nextSteps`, OBSERVAÇÃO→`strengths`/`risks` (classificação curada por slug). Relações são união (nunca removem vínculos da UI); re-run idempotente; nomes de atores políticos entraram sem trilha de consentimento — regularizar com a assessoria jurídica. No mesmo dia, o grupo duplicado `municipality.voteGoals` ("Meta Bom/Regular/Mínimo") foi **removido** — collection, zod, view models, form/card e utilitário `voteGoals.ts` — via migration `20260724_133600_drop_municipality_vote_goals` (backfill metas→estimativas onde a estimativa estava vazia): `expectedVotes` (pessimista/média/otimista) é a única série por cenário do município. 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**.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# E4R — Import único da planilha de projeção (seed de estratégia, local e produção)
|
||||
|
||||
Status: entregue em código (2026-07-24)
|
||||
Atualizado em: 2026-07-24 (alvo mudou para `expectedVotes` — "Metas" removidas do app; overwrite-always; SheetJS; seed local 189 estimativas / 50 alta)
|
||||
Status: entregue em código (2026-07-24; fase 2 — colunas de estratégia — no mesmo dia)
|
||||
Atualizado em: 2026-07-24 (fase 2: SITUAÇÃO/DOBRADINHAS/LIDERANÇAS/ASSESSOR/ENCAMINHAMENTOS/OBSERVAÇÃO + override PRIORITÁRIAS; antes: alvo mudou para `expectedVotes`, overwrite-always, SheetJS, seed local 189 estimativas / 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)
|
||||
|
|
@ -11,6 +11,17 @@ Revisão 2026-07-24 (a): idempotência mudou de “só campos vazios” para **s
|
|||
|
||||
Revisão 2026-07-24 (b) — **alvo é `expectedVotes`, não `voteGoals`:** decisão de produto — "Meta Bom/Regular/Mínimo" duplicava o conceito de votos estimados; a campanha trabalha com UMA série por cenário. O grupo `municipality.voteGoals` foi **removido do app** (migration `20260724_133600_drop_municipality_vote_goals`, com backfill metas→estimativas onde estimativa estava vazia) e o seed passou a gravar `municipality.expectedVotes` com o mapeamento **Bom → otimista, Regular → média (`central`), Mínimo → pessimista**. A ordem é validada com `getVoteEstimateOrderViolation` (pessimista ≤ média ≤ otimista) e a UI exibe tudo no card "Votos estimados".
|
||||
|
||||
Revisão 2026-07-24 (c) — **fase 2 entregue: colunas de estratégia** (decisão explícita do produto em 2026-07-24, antecipando o gatilho "lote jurídico" — nomes de atores políticos entram em produção com entidades name-only; regularizar trilha de consentimento com a assessoria depois). O mesmo `pnpm db:seed:projecao` agora também importa, com **PRIORITÁRIAS sobrepondo MAPA GERAL** em SITUAÇÃO/LIDERANÇAS/ASSESSOR quando a célula é não-vazia:
|
||||
|
||||
- **SITUAÇÃO → `municipality.politicalTrend`**: QUEDA→`desfavoravel`, MANTÉM→`neutra`, AUMENTO→`favoravel` (`parseSituationCell`; "NÃO DEFINIDA" não toca o campo). `note` guarda proveniência ("Importado da planilha de projeção (jul/2026) — SITUAÇÃO: …").
|
||||
- **DOBRADINHAS → `stateDeputy` + `municipality.stateDeputies`** (união) e célula crua sempre em `dobradinhaNotes`. O nome do candidato ("Solla") em célula de dobradinha é descartado com relatório.
|
||||
- **LIDERANÇAS → `contact` (name-only, sem telefone) + `leadership`** — dedup estadual por slug do nome (uma pessoa, N municípios; merges listados no dry-run). `Contact.phone` virou **opcional** na collection (migration `20260724_175500_contact_phone_optional`; formulários/zod continuam exigindo) e os hooks de invariante de telefone pulam contatos sem phone.
|
||||
- **ASSESSOR RESPONSÁVEL → `campaignUser` + `municipality.advisors`** (união): "Edizio" → `coordinator`, "Solla"/"Jorge Solla" → `candidate` (o papel `candidate` passou a ser elegível em `eligibleCampaignStaffWhere` — decisão do produto), demais → `advisor`. Usuários novos nascem com email placeholder `<slug>@planilha.invalid` (TLD reservado, não roteável) + senha aleatória — não logam até um admin trocar as credenciais.
|
||||
- **ENCAMINHAMENTOS → `nextSteps`** (overwrite quando não-vazio); **OBSERVAÇÃO → `strengths`/`risks`** com classificação curada por slug (`OBSERVATION_TARGET_BY_SLUG`; 4 células hoje: Brejões→força; Camaçari/Inhambupe/Laje→risco), idempotente por texto exato.
|
||||
- **Parsing de nomes** (`splitNameCell`, puro, com testes): separadores `,`/`;`/`|`/barra/" e "/ponto-de-frase (honoríficos "Dr./Dra./Sr./Sra./Prof./Sec." protegidos); parentéticos extraídos; ruído descartado com relatório (traços, `?`, frases de ação "VER COM…/CONFIRMAR…/Construindo…", coletivos "pessoal do PT/grupo de ACS/Associação…", papéis sem nome "Prefeito/vereadores/Ex-vice…"). Aliases curados no script: staff `mariana→Marianna`, `joao→João Lúcio`, `caio→Caio Cesar`; dobradinha `galo→Marcelino Galo`, `angelo→Ângelo Almeida`, `fatima→Fátima Nunes`, `rowena→Rowenna`, `osni→Osny`. Nome canônico = variante mais longa (desempate: inicial maiúscula → frequência → acentos).
|
||||
- **Semântica**: relações são **união** (nunca removem vínculos feitos na UI); textos definidos na planilha sobrescrevem; célula vazia nunca limpa; re-run é idempotente (delta=0 verificado localmente). Salvador continua **pulado para todas as colunas** (decisão do usuário; estratégia por ZE entra via UI).
|
||||
- Seed local verificado: 416 casadas, 205 municípios com escrita, 26 usuários (24 assessores + Edizio coordinator + Jorge Solla candidate), 40 dobradinhas, 288 lideranças; 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).
|
||||
|
|
@ -37,8 +48,8 @@ Divergências A×B no MAPA GERAL (9 linhas: Brejões, Catu, Iaçu, Ipiaú, Jagua
|
|||
|
||||
## Decisões travadas
|
||||
|
||||
- **v1 importa apenas números/enums (`expectedVotes`, `priority`).** Colunas com nomes (LIDERANÇAS, ASSESSOR RESPONSÁVEL, DOBRADINHAS, ENCAMINHAMENTOS, OBSERVAÇÃO) ficam para fase 2, pós-lote jurídico — nome de ator político é dado pessoal que revela opinião política (LGPD art. 11); destino (CRM `leadership` × nota staff-only) se decide com a assessoria. **Rejeitado:** importar tudo com access staff-only (antecipa exatamente o risco que a Onda 0 segura); campo novo `networkNotes` (schema por dado que ainda não pode entrar).
|
||||
- **SITUAÇÃO e VOTOS não são importados.** Tendência é derivada do TSE (`computeVoteTrend` — decisão E2 mantida); votos históricos vêm de `electionTally`/artefato. A planilha só manda no que é julgamento humano: metas e prioridade. **Rejeitado:** persistir a SITUAÇÃO da planilha (duplicaria a derivação com dado defasado).
|
||||
- ~~**v1 importa apenas números/enums (`expectedVotes`, `priority`).**~~ **Superado pela revisão (c):** o produto decidiu importar as colunas com nomes em 2026-07-24, com entidades name-only e trilha de consentimento a regularizar com a assessoria. (Racional original: nome de ator político é dado pessoal que revela opinião política — LGPD art. 11.)
|
||||
- ~~**SITUAÇÃO e VOTOS não são importados.**~~ **Superado pela revisão (c) para SITUAÇÃO** (vira `politicalTrend`, o campo de julgamento humano da coordenação — a tendência derivada do TSE via `computeVoteTrend` segue intacta no card de baseline). VOTOS históricos continuam fora (vêm de `electionTally`/artefato).
|
||||
- **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.
|
||||
|
|
@ -79,7 +90,7 @@ flowchart LR
|
|||
|
||||
## Não escopo
|
||||
|
||||
- Import recorrente/automático (fora de escopo do roadmap — atualização contínua via UI); colunas com nomes (fase 2 pós-lote jurídico); Salvador por zona/bairro (UI manual; drill de bairro segue como E5 futuro); criação de `leadership`/`Contact` a partir da planilha (CRM real só com Consent).
|
||||
- Import recorrente/automático (fora de escopo do roadmap — atualização contínua via UI); Salvador por zona/bairro (UI manual, inclusive para as colunas de estratégia; drill de bairro segue como E5 futuro). ~~Colunas com nomes / criação de `leadership`+`Contact`~~ entregues na revisão (c).
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,21 @@
|
|||
/**
|
||||
* E4R — Import único da planilha de projeção → municipality.expectedVotes + priority.
|
||||
* E4R — Import da planilha de projeção → municipality (estimativas + colunas de estratégia).
|
||||
*
|
||||
* 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
|
||||
* - Sheet scenarios map Bom → otimista, Regular → média (central), Mínimo → pessimista.
|
||||
* - Writes only numbers/enums (zero PII). Never creates Contact/leadership.
|
||||
* - Always overwrites expectedVotes + priority on matched rows (re-seed when the mesa
|
||||
* sends a newer sheet over an already-populated DB). Does not touch lastUpdateAt.
|
||||
* - Fase 1 (E4R): expectedVotes + priority (overwrite nas linhas casadas).
|
||||
* - Fase 2 (2026-07-24): SITUAÇÃO → politicalTrend; DOBRADINHAS → stateDeputy + stateDeputies
|
||||
* (+ dobradinhaNotes com a célula crua); LIDERANÇAS → contact (sem telefone) + leadership;
|
||||
* ASSESSOR RESPONSÁVEL → campaignUser + municipality.advisors; ENCAMINHAMENTOS → nextSteps;
|
||||
* OBSERVAÇÃO → strengths/risks (classificação curada por slug). A aba PRIORITÁRIAS sobrepõe
|
||||
* SITUAÇÃO/LIDERANÇAS/ASSESSOR quando a célula é não-vazia. Entidades nascem name-only
|
||||
* (usuários sem email/username/credenciais; contatos sem telefone) — ajuste manual depois.
|
||||
* - Relações (advisors, stateDeputies, municípios da liderança) são UNIÃO (nunca removem);
|
||||
* campos de texto definidos na planilha sobrescrevem; nada é limpo quando a célula é vazia.
|
||||
* - Salvador (19 municípios-zona) é pulado para TODAS as colunas (relatório explícito).
|
||||
* - Não toca lastUpdateAt.
|
||||
*
|
||||
* Safety: refuses non-local DATABASE_URL unless ALLOW_REMOTE_DB=true.
|
||||
*
|
||||
|
|
@ -25,6 +33,7 @@
|
|||
* --reference <path> Override reference workbook for A×B diff
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
|
@ -58,7 +67,18 @@ const SALVADOR_CITY = 'Salvador'
|
|||
* votes2022: number | null
|
||||
* expectationRaw: string | null
|
||||
* priorityRaw: string | null
|
||||
* situationRaw: string | null
|
||||
* dobradinhasRaw: string | null
|
||||
* leadershipsRaw: string | null
|
||||
* advisorsRaw: string | null
|
||||
* nextStepsRaw: string | null
|
||||
* observationRaw: string | null
|
||||
* }} ProjectionSheetRow
|
||||
* @typedef {{
|
||||
* situationRaw: string | null
|
||||
* leadershipsRaw: string | null
|
||||
* advisorsRaw: string | null
|
||||
* }} PrioritariaRow
|
||||
* @typedef {{ optimistic?: number | null, central?: number | null, pessimistic?: number | null } | null | undefined} DbExpectedVotes
|
||||
*/
|
||||
|
||||
|
|
@ -165,6 +185,12 @@ const readMapaGeralRows = (workbook, parseSheetNumber, normalizeKey) => {
|
|||
const votes2014Col = indexByLabel.get('VOTOS 2014')
|
||||
const votes2018Col = indexByLabel.get('VOTOS 2018')
|
||||
const votes2022Col = indexByLabel.get('VOTOS 2022')
|
||||
const situationCol = indexByLabel.get('SITUAÇÃO')
|
||||
const dobradinhasCol = indexByLabel.get('DOBRADINHAS')
|
||||
const leadershipsCol = indexByLabel.get('LIDERANÇAS')
|
||||
const advisorsCol = indexByLabel.get('ASSESSOR RESPONSÁVEL')
|
||||
const nextStepsCol = indexByLabel.get('ENCAMINHAMENTOS')
|
||||
const observationCol = indexByLabel.get('OBSERVAÇÃO')
|
||||
|
||||
/** @type {Map<string, ProjectionSheetRow>} */
|
||||
const byKey = new Map()
|
||||
|
|
@ -186,6 +212,12 @@ const readMapaGeralRows = (workbook, parseSheetNumber, normalizeKey) => {
|
|||
votes2022: parseSheetNumber(row[votes2022Col]),
|
||||
expectationRaw: cellString(row[expectationCol]),
|
||||
priorityRaw: cellString(row[priorityCol]),
|
||||
situationRaw: situationCol === undefined ? null : cellString(row[situationCol]),
|
||||
dobradinhasRaw: dobradinhasCol === undefined ? null : cellString(row[dobradinhasCol]),
|
||||
leadershipsRaw: leadershipsCol === undefined ? null : cellString(row[leadershipsCol]),
|
||||
advisorsRaw: advisorsCol === undefined ? null : cellString(row[advisorsCol]),
|
||||
nextStepsRaw: nextStepsCol === undefined ? null : cellString(row[nextStepsCol]),
|
||||
observationRaw: observationCol === undefined ? null : cellString(row[observationCol]),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -193,30 +225,40 @@ const readMapaGeralRows = (workbook, parseSheetNumber, normalizeKey) => {
|
|||
}
|
||||
|
||||
/**
|
||||
* PRIORITÁRIAS rows override MAPA GERAL for SITUAÇÃO/LIDERANÇAS/ASSESSOR when non-empty.
|
||||
* @param {import('xlsx').WorkBook} workbook
|
||||
* @param {(value: string) => string} normalizeKey
|
||||
* @returns {Map<string, PrioritariaRow>}
|
||||
*/
|
||||
const readPrioritariaNames = (workbook, normalizeKey) => {
|
||||
const readPrioritariaRows = (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()
|
||||
const situationCol = indexByLabel.get('SITUAÇÃO')
|
||||
const leadershipsCol = indexByLabel.get('LIDERANÇAS')
|
||||
const advisorsCol = indexByLabel.get('ASSESSOR RESPONSÁVEL')
|
||||
|
||||
/** @type {Map<string, PrioritariaRow>} */
|
||||
const byKey = new Map()
|
||||
|
||||
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))
|
||||
byKey.set(normalizeKey(label), {
|
||||
situationRaw: situationCol === undefined ? null : cellString(row[situationCol]),
|
||||
leadershipsRaw: leadershipsCol === undefined ? null : cellString(row[leadershipsCol]),
|
||||
advisorsRaw: advisorsCol === undefined ? null : cellString(row[advisorsCol]),
|
||||
})
|
||||
}
|
||||
|
||||
return names
|
||||
return byKey
|
||||
}
|
||||
|
||||
assertLocalDatabase(
|
||||
'seed:projecao',
|
||||
'This script overwrites municipality expectedVotes/priority from the projection sheet.',
|
||||
'This script overwrites municipality strategy fields from the projection sheet.',
|
||||
)
|
||||
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
|
|
@ -232,9 +274,8 @@ const { canonicalizeMunicipalityName, UnknownMunicipalityError, normalizeMunicip
|
|||
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 { parseExpectationCell, mapSheetPriority, parseSheetNumber, parseSituationCell, splitNameCell } =
|
||||
await import('../src/lib/projectionSheetParse.ts')
|
||||
const {
|
||||
getVoteEstimateOrderViolation,
|
||||
toVoteEstimateScenarioViewModel,
|
||||
|
|
@ -243,15 +284,131 @@ const {
|
|||
hasAnyVoteEstimate,
|
||||
} = await import('../src/utilities/voteEstimate.ts')
|
||||
const { withPayloadTransaction } = await import('../src/utilities/payloadTransaction.ts')
|
||||
const { slugify } = await import('../src/utilities/slug.ts')
|
||||
|
||||
/** @param {import('../src/utilities/voteEstimate.ts').VoteEstimateScenarioViewModel} estimates */
|
||||
const formatEstimates = (estimates) => {
|
||||
if (!hasAnyVoteEstimate(estimates)) return '(vazio)'
|
||||
return `${voteEstimateScenarioLabels.optimistic}:${estimates.optimistic ?? '—'} | ${voteEstimateScenarioLabels.central}:${estimates.central ?? '—'} | ${voteEstimateScenarioLabels.pessimistic}:${estimates.pessimistic ?? '—'}`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Curated name resolution (adjust here when the mesa sends corrections)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Sheet spelling variants → canonical staff slug ("Edizio" é o CG; "Solla" é o candidato). */
|
||||
const STAFF_SLUG_ALIASES = {
|
||||
mariana: 'marianna',
|
||||
joao: 'joao-lucio',
|
||||
caio: 'caio-cesar',
|
||||
solla: 'jorge-solla',
|
||||
}
|
||||
const COORDINATOR_SLUG = 'edizio'
|
||||
const CANDIDATE_SLUG = 'jorge-solla'
|
||||
const CANDIDATE_DISPLAY_NAME = 'Jorge Solla'
|
||||
|
||||
/** Sheet spelling variants → canonical state-deputy slug. */
|
||||
const DEPUTY_SLUG_ALIASES = {
|
||||
galo: 'marcelino-galo',
|
||||
angelo: 'angelo-almeida',
|
||||
fatima: 'fatima-nunes',
|
||||
rowena: 'rowenna',
|
||||
osni: 'osny',
|
||||
}
|
||||
|
||||
/** The candidate himself shows up in DOBRADINHAS/LIDERANÇAS cells — never an entity there. */
|
||||
const CANDIDATE_NAME_SLUGS = new Set(['solla', 'sola', 'jorge-solla'])
|
||||
|
||||
/**
|
||||
* OBSERVAÇÃO → strengths ("Forças") or risks ("Riscos"), curated per municipality slug
|
||||
* (only 4 filled cells in the current sheet; classify new ones here when they appear).
|
||||
*/
|
||||
const OBSERVATION_TARGET_BY_SLUG = {
|
||||
brejoes: 'strengths', // "Prefeito se aproximou de Solla" — sinal positivo
|
||||
camacari: 'risks', // "Resolver demanda de Mutakani" — pendência aberta
|
||||
inhambupe: 'risks', // articulação com ex-prefeito ainda em construção
|
||||
laje: 'risks', // diálogo a acompanhar
|
||||
}
|
||||
|
||||
const trendNoteFor = (situationRaw) =>
|
||||
`Importado da planilha de projeção (jul/2026) — SITUAÇÃO: ${situationRaw}`
|
||||
|
||||
const staffSlugFor = (name) => {
|
||||
const slug = slugify(name)
|
||||
return STAFF_SLUG_ALIASES[slug] ?? slug
|
||||
}
|
||||
|
||||
const deputySlugFor = (name) => {
|
||||
const slug = slugify(name)
|
||||
return DEPUTY_SLUG_ALIASES[slug] ?? slug
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {{ variants: Map<string, number>, municipalityLabels: Set<string>, municipalityIds: Set<number>, cities: Set<string> }} RosterEntry
|
||||
*/
|
||||
|
||||
/** @returns {RosterEntry} */
|
||||
const emptyRosterEntry = () => ({
|
||||
variants: new Map(),
|
||||
municipalityLabels: new Set(),
|
||||
municipalityIds: new Set(),
|
||||
cities: new Set(),
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {Map<string, RosterEntry>} roster
|
||||
* @param {string} slug
|
||||
* @param {string} rawName
|
||||
* @param {{ label: string, id: number, city: string }} municipality
|
||||
*/
|
||||
const addRosterEntry = (roster, slug, rawName, municipality) => {
|
||||
let entry = roster.get(slug)
|
||||
if (!entry) {
|
||||
entry = emptyRosterEntry()
|
||||
roster.set(slug, entry)
|
||||
}
|
||||
entry.variants.set(rawName, (entry.variants.get(rawName) ?? 0) + 1)
|
||||
entry.municipalityLabels.add(municipality.label)
|
||||
entry.municipalityIds.add(municipality.id)
|
||||
entry.cities.add(municipality.city)
|
||||
}
|
||||
|
||||
/**
|
||||
* Longest variant wins (full names beat nicknames); ties break by capitalized first
|
||||
* letter, then frequency, then accented spelling, then first-seen.
|
||||
*/
|
||||
const canonicalRosterName = (entry) => {
|
||||
const score = (name, count) => [
|
||||
name.length,
|
||||
/^\p{Lu}/u.test(name) ? 1 : 0,
|
||||
count,
|
||||
(name.match(/[À-ÿ]/g) ?? []).length,
|
||||
]
|
||||
const beats = (a, b) => {
|
||||
for (let i = 0; i < a.length; i += 1) {
|
||||
if (a[i] !== b[i]) return a[i] > b[i]
|
||||
}
|
||||
return false // full tie → keep the first-seen variant
|
||||
}
|
||||
let best = null
|
||||
let bestScore = null
|
||||
for (const [name, count] of entry.variants) {
|
||||
const nameScore = score(name, count)
|
||||
if (best === null || beats(nameScore, bestScore)) {
|
||||
best = name
|
||||
bestScore = nameScore
|
||||
}
|
||||
}
|
||||
return best ?? ''
|
||||
}
|
||||
|
||||
const formatVariants = (entry) =>
|
||||
[...entry.variants.entries()].map(([name, count]) => `${name}×${count}`).join(', ')
|
||||
|
||||
const canonicalBook = XLSX.readFile(options.file, { cellDates: false })
|
||||
const canonicalRows = readMapaGeralRows(canonicalBook, parseSheetNumber, normalizeMunicipalityKey)
|
||||
const prioritarias = readPrioritariaNames(canonicalBook, normalizeMunicipalityKey)
|
||||
const prioritariaRows = readPrioritariaRows(canonicalBook, normalizeMunicipalityKey)
|
||||
const prioritarias = new Set(prioritariaRows.keys())
|
||||
|
||||
/** @type {Map<string, ProjectionSheetRow> | null} */
|
||||
let referenceRows = null
|
||||
|
|
@ -292,19 +449,21 @@ const municipalityDocs = await payload.find({
|
|||
kind: true,
|
||||
priority: true,
|
||||
expectedVotes: true,
|
||||
advisors: true,
|
||||
stateDeputies: true,
|
||||
dobradinhaNotes: true,
|
||||
nextSteps: true,
|
||||
strengths: true,
|
||||
risks: true,
|
||||
politicalTrend: true,
|
||||
},
|
||||
})
|
||||
|
||||
/** @type {Map<string, { id: number, slug: string, priority: string | null, expectedVotes: DbExpectedVotes }>} */
|
||||
/** @type {Map<string, (typeof municipalityDocs.docs)[number]>} */
|
||||
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,
|
||||
expectedVotes: doc.expectedVotes ?? null,
|
||||
})
|
||||
docsBySlug.set(doc.slug, doc)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -314,6 +473,19 @@ const unmatched = []
|
|||
const parseErrors = []
|
||||
const warnings = []
|
||||
const axbDiffs = []
|
||||
/** @type {Array<{ label: string, column: string, segment: string }>} */
|
||||
const skippedSegments = []
|
||||
/** @type {Array<{ label: string, column: string, prior: string, geral: string }>} */
|
||||
const prioritariaOverrides = []
|
||||
|
||||
/** @type {Map<string, RosterEntry>} */
|
||||
const staffRoster = new Map()
|
||||
/** @type {Map<string, RosterEntry>} */
|
||||
const deputyRoster = new Map()
|
||||
/** @type {Map<string, RosterEntry>} */
|
||||
const leadershipRoster = new Map()
|
||||
|
||||
const normalizeCellComparison = (value) => value.replace(/\s+/g, ' ').trim().toLowerCase()
|
||||
|
||||
for (const sheetRow of canonicalRows.values()) {
|
||||
let city
|
||||
|
|
@ -330,11 +502,23 @@ for (const sheetRow of canonicalRows.values()) {
|
|||
throw error
|
||||
}
|
||||
|
||||
const sheetKey = normalizeMunicipalityKey(sheetRow.municipalityLabel)
|
||||
const prioritariaRow = prioritariaRows.get(sheetKey) ?? null
|
||||
|
||||
if (city === SALVADOR_CITY) {
|
||||
skippedSalvador.push({
|
||||
label: sheetRow.municipalityLabel,
|
||||
expectationRaw: sheetRow.expectationRaw,
|
||||
priorityRaw: sheetRow.priorityRaw,
|
||||
hasStrategy: Boolean(
|
||||
sheetRow.situationRaw ||
|
||||
sheetRow.dobradinhasRaw ||
|
||||
sheetRow.leadershipsRaw ||
|
||||
sheetRow.advisorsRaw ||
|
||||
sheetRow.nextStepsRaw ||
|
||||
sheetRow.observationRaw ||
|
||||
prioritariaRow,
|
||||
),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
|
@ -360,29 +544,25 @@ for (const sheetRow of canonicalRows.values()) {
|
|||
continue
|
||||
}
|
||||
|
||||
const expectedVotes = parseExpectationCell(sheetRow.expectationRaw)
|
||||
if (!expectedVotes) {
|
||||
if (sheetRow.expectationRaw) {
|
||||
parseErrors.push({
|
||||
label: sheetRow.municipalityLabel,
|
||||
slug: catalogEntry.slug,
|
||||
raw: sheetRow.expectationRaw,
|
||||
})
|
||||
}
|
||||
continue
|
||||
// --- Estimativas (fase 1, comportamento preservado) -----------------------
|
||||
let expectedVotes = parseExpectationCell(sheetRow.expectationRaw)
|
||||
if (!expectedVotes && sheetRow.expectationRaw) {
|
||||
parseErrors.push({
|
||||
label: sheetRow.municipalityLabel,
|
||||
slug: catalogEntry.slug,
|
||||
raw: sheetRow.expectationRaw,
|
||||
})
|
||||
}
|
||||
|
||||
if (getVoteEstimateOrderViolation(expectedVotes)) {
|
||||
if (expectedVotes && getVoteEstimateOrderViolation(expectedVotes)) {
|
||||
parseErrors.push({
|
||||
label: sheetRow.municipalityLabel,
|
||||
slug: catalogEntry.slug,
|
||||
raw: `${sheetRow.expectationRaw} (ordem Otimista ≥ Média ≥ Pessimista inválida)`,
|
||||
})
|
||||
continue
|
||||
expectedVotes = null
|
||||
}
|
||||
|
||||
const priority = mapSheetPriority(sheetRow.priorityRaw)
|
||||
const sheetKey = normalizeMunicipalityKey(sheetRow.municipalityLabel)
|
||||
|
||||
if (prioritarias.has(sheetKey) && priority !== 'alta') {
|
||||
warnings.push(
|
||||
|
|
@ -418,7 +598,7 @@ for (const sheetRow of canonicalRows.values()) {
|
|||
)
|
||||
}
|
||||
|
||||
if (referenceRows) {
|
||||
if (expectedVotes && referenceRows) {
|
||||
const ref = referenceRows.get(sheetKey)
|
||||
if (ref) {
|
||||
const refEstimates = parseExpectationCell(ref.expectationRaw)
|
||||
|
|
@ -440,29 +620,423 @@ for (const sheetRow of canonicalRows.values()) {
|
|||
}
|
||||
}
|
||||
|
||||
matched.push({ doc, expectedVotes, priority })
|
||||
// --- Estratégia (fase 2) ---------------------------------------------------
|
||||
const resolveOverride = (column, priorRaw, geralRaw) => {
|
||||
if (priorRaw != null && geralRaw != null) {
|
||||
if (normalizeCellComparison(priorRaw) !== normalizeCellComparison(geralRaw)) {
|
||||
prioritariaOverrides.push({
|
||||
label: sheetRow.municipalityLabel,
|
||||
column,
|
||||
prior: priorRaw,
|
||||
geral: geralRaw,
|
||||
})
|
||||
}
|
||||
}
|
||||
return priorRaw ?? geralRaw
|
||||
}
|
||||
|
||||
const situationRaw = resolveOverride(
|
||||
'SITUAÇÃO',
|
||||
prioritariaRow?.situationRaw ?? null,
|
||||
sheetRow.situationRaw,
|
||||
)
|
||||
const leadershipsRaw = resolveOverride(
|
||||
'LIDERANÇAS',
|
||||
prioritariaRow?.leadershipsRaw ?? null,
|
||||
sheetRow.leadershipsRaw,
|
||||
)
|
||||
const advisorsRaw = resolveOverride(
|
||||
'ASSESSOR RESPONSÁVEL',
|
||||
prioritariaRow?.advisorsRaw ?? null,
|
||||
sheetRow.advisorsRaw,
|
||||
)
|
||||
|
||||
const trendStatus = parseSituationCell(situationRaw)
|
||||
|
||||
const municipalityRef = { label: sheetRow.municipalityLabel, id: doc.id, city }
|
||||
|
||||
const advisorsSplit = splitNameCell(advisorsRaw)
|
||||
const advisorSlugs = []
|
||||
for (const name of advisorsSplit.names) {
|
||||
const slug = staffSlugFor(name)
|
||||
if (!advisorSlugs.includes(slug)) advisorSlugs.push(slug)
|
||||
addRosterEntry(staffRoster, slug, name, municipalityRef)
|
||||
}
|
||||
for (const segment of advisorsSplit.skipped) {
|
||||
skippedSegments.push({ label: sheetRow.municipalityLabel, column: 'ASSESSOR', segment })
|
||||
}
|
||||
|
||||
const deputiesSplit = splitNameCell(sheetRow.dobradinhasRaw)
|
||||
const deputySlugs = []
|
||||
for (const name of deputiesSplit.names) {
|
||||
const slug = deputySlugFor(name)
|
||||
if (CANDIDATE_NAME_SLUGS.has(slug)) {
|
||||
skippedSegments.push({
|
||||
label: sheetRow.municipalityLabel,
|
||||
column: 'DOBRADINHAS',
|
||||
segment: `${name} (candidato — não vira dobradinha)`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (!deputySlugs.includes(slug)) deputySlugs.push(slug)
|
||||
addRosterEntry(deputyRoster, slug, name, municipalityRef)
|
||||
}
|
||||
for (const segment of deputiesSplit.skipped) {
|
||||
skippedSegments.push({ label: sheetRow.municipalityLabel, column: 'DOBRADINHAS', segment })
|
||||
}
|
||||
|
||||
const leadershipsSplit = splitNameCell(leadershipsRaw)
|
||||
for (const name of leadershipsSplit.names) {
|
||||
const slug = slugify(name)
|
||||
if (CANDIDATE_NAME_SLUGS.has(slug)) {
|
||||
skippedSegments.push({
|
||||
label: sheetRow.municipalityLabel,
|
||||
column: 'LIDERANÇAS',
|
||||
segment: `${name} (candidato — não vira liderança)`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
addRosterEntry(leadershipRoster, slug, name, municipalityRef)
|
||||
}
|
||||
for (const segment of leadershipsSplit.skipped) {
|
||||
skippedSegments.push({ label: sheetRow.municipalityLabel, column: 'LIDERANÇAS', segment })
|
||||
}
|
||||
|
||||
const observationTarget = sheetRow.observationRaw
|
||||
? (OBSERVATION_TARGET_BY_SLUG[catalogEntry.slug] ?? null)
|
||||
: null
|
||||
if (sheetRow.observationRaw && !observationTarget) {
|
||||
warnings.push(
|
||||
`OBSERVAÇÃO sem classificação Forças/Riscos para ${sheetRow.municipalityLabel} (${catalogEntry.slug}) — adicione em OBSERVATION_TARGET_BY_SLUG: ${JSON.stringify(sheetRow.observationRaw)}`,
|
||||
)
|
||||
}
|
||||
|
||||
matched.push({
|
||||
doc,
|
||||
label: sheetRow.municipalityLabel,
|
||||
expectedVotes,
|
||||
priority,
|
||||
trendStatus,
|
||||
situationRaw,
|
||||
advisorSlugs,
|
||||
deputySlugs,
|
||||
dobradinhasRaw: sheetRow.dobradinhasRaw,
|
||||
nextStepsRaw: sheetRow.nextStepsRaw,
|
||||
observationRaw: sheetRow.observationRaw,
|
||||
observationTarget,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entity plans (matched against the current database, created inside the tx)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const existingUsers = await payload.find({
|
||||
collection: 'campaignUser',
|
||||
depth: 0,
|
||||
limit: 5000,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
select: { name: true, role: true },
|
||||
})
|
||||
|
||||
const usersBySlug = new Map()
|
||||
for (const user of existingUsers.docs) {
|
||||
const slug = slugify(String(user.name ?? ''))
|
||||
if (!slug) continue
|
||||
if (usersBySlug.has(slug)) {
|
||||
warnings.push(
|
||||
`Usuários duplicados com o nome "${user.name}" no banco — usando o id ${usersBySlug.get(slug).id} para o match.`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
usersBySlug.set(slug, user)
|
||||
}
|
||||
const existingCoordinators = existingUsers.docs.filter((user) => user.role === 'coordinator')
|
||||
const existingCandidates = existingUsers.docs.filter((user) => user.role === 'candidate')
|
||||
|
||||
const staffPlans = new Map()
|
||||
for (const [slug, entry] of staffRoster) {
|
||||
const role =
|
||||
slug === COORDINATOR_SLUG ? 'coordinator' : slug === CANDIDATE_SLUG ? 'candidate' : 'advisor'
|
||||
const name = slug === CANDIDATE_SLUG ? CANDIDATE_DISPLAY_NAME : canonicalRosterName(entry)
|
||||
let existing = usersBySlug.get(slug) ?? null
|
||||
if (existing && existing.role === 'leader') {
|
||||
warnings.push(
|
||||
`Usuário existente "${existing.name}" (id ${existing.id}) tem papel Liderança — não pode ser assessor; será criado um novo usuário "${name}".`,
|
||||
)
|
||||
existing = null
|
||||
}
|
||||
if (!existing && role === 'coordinator' && existingCoordinators.length === 1) {
|
||||
existing = existingCoordinators[0]
|
||||
}
|
||||
if (!existing && role === 'candidate' && existingCandidates.length === 1) {
|
||||
existing = existingCandidates[0]
|
||||
}
|
||||
staffPlans.set(slug, {
|
||||
slug,
|
||||
name,
|
||||
role,
|
||||
existing,
|
||||
userId: existing ? existing.id : null,
|
||||
entry,
|
||||
})
|
||||
}
|
||||
|
||||
const existingDeputies = await payload.find({
|
||||
collection: 'stateDeputy',
|
||||
depth: 0,
|
||||
limit: 5000,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
select: { name: true, slug: true },
|
||||
})
|
||||
const deputiesBySlug = new Map(existingDeputies.docs.map((deputy) => [deputy.slug, deputy]))
|
||||
|
||||
const deputyPlans = new Map()
|
||||
for (const [slug, entry] of deputyRoster) {
|
||||
const existing = deputiesBySlug.get(slug) ?? null
|
||||
deputyPlans.set(slug, {
|
||||
slug,
|
||||
name: canonicalRosterName(entry),
|
||||
existing,
|
||||
deputyId: existing ? existing.id : null,
|
||||
entry,
|
||||
})
|
||||
}
|
||||
|
||||
const existingLeaderships = await payload.find({
|
||||
collection: 'leadership',
|
||||
depth: 1,
|
||||
limit: 5000,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
select: { contact: true, municipalities: true },
|
||||
})
|
||||
|
||||
const relationId = (value) => (typeof value === 'object' && value !== null ? value.id : value)
|
||||
|
||||
const leadershipsByContactSlug = new Map()
|
||||
for (const leadership of existingLeaderships.docs) {
|
||||
const contact = leadership.contact
|
||||
const contactName = typeof contact === 'object' && contact !== null ? contact.name : null
|
||||
if (typeof contactName !== 'string') continue
|
||||
const slug = slugify(contactName)
|
||||
if (!slug || leadershipsByContactSlug.has(slug)) continue
|
||||
leadershipsByContactSlug.set(slug, {
|
||||
id: leadership.id,
|
||||
contactName,
|
||||
municipalityIds: (leadership.municipalities ?? []).map(relationId).filter((id) => id != null),
|
||||
})
|
||||
}
|
||||
|
||||
const leadershipPlans = new Map()
|
||||
for (const [slug, entry] of leadershipRoster) {
|
||||
const existing = leadershipsByContactSlug.get(slug) ?? null
|
||||
const municipalityIds = [...entry.municipalityIds]
|
||||
const missingIds = existing
|
||||
? municipalityIds.filter((id) => !existing.municipalityIds.includes(id))
|
||||
: municipalityIds
|
||||
leadershipPlans.set(slug, {
|
||||
slug,
|
||||
name: canonicalRosterName(entry),
|
||||
existing,
|
||||
municipalityIds,
|
||||
missingIds,
|
||||
city: entry.cities.size === 1 ? [...entry.cities][0] : null,
|
||||
entry,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Municipality write plans (delta vs current DB state)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const uniqueUnion = (current, additions) => {
|
||||
const merged = [...new Set([...current, ...additions])]
|
||||
return merged.length === current.length ? null : merged
|
||||
}
|
||||
|
||||
/** Builds the municipality update payload for a matched row (null = nothing to write). */
|
||||
const buildMunicipalityData = (row) => {
|
||||
const data = {}
|
||||
|
||||
if (row.expectedVotes) {
|
||||
const currentEstimates = toVoteEstimateScenarioViewModel(row.doc.expectedVotes)
|
||||
const nextEstimates = toVoteEstimateScenarioViewModel(row.expectedVotes)
|
||||
const currentPriority = row.doc.priority === 'alta' ? 'alta' : 'normal'
|
||||
if (!voteEstimatesEqual(currentEstimates, nextEstimates) || currentPriority !== row.priority) {
|
||||
data.expectedVotes = row.expectedVotes
|
||||
data.priority = row.priority
|
||||
}
|
||||
}
|
||||
|
||||
if (row.trendStatus) {
|
||||
const note = trendNoteFor(row.situationRaw)
|
||||
const currentTrend = row.doc.politicalTrend ?? {}
|
||||
if (currentTrend.status !== row.trendStatus || (currentTrend.note ?? '') !== note) {
|
||||
data.politicalTrend = { status: row.trendStatus, note }
|
||||
}
|
||||
}
|
||||
|
||||
if (row.advisorSlugs.length > 0) {
|
||||
const advisorIds = row.advisorSlugs
|
||||
.map((slug) => staffPlans.get(slug)?.userId)
|
||||
.filter((id) => id != null)
|
||||
const merged = uniqueUnion(row.doc.advisors ?? [], advisorIds)
|
||||
if (merged) data.advisors = merged
|
||||
}
|
||||
|
||||
if (row.deputySlugs.length > 0) {
|
||||
const deputyIds = row.deputySlugs
|
||||
.map((slug) => deputyPlans.get(slug)?.deputyId)
|
||||
.filter((id) => id != null)
|
||||
const merged = uniqueUnion(row.doc.stateDeputies ?? [], deputyIds)
|
||||
if (merged) data.stateDeputies = merged
|
||||
}
|
||||
|
||||
if (row.dobradinhasRaw && row.dobradinhasRaw !== (row.doc.dobradinhaNotes ?? '')) {
|
||||
data.dobradinhaNotes = row.dobradinhasRaw
|
||||
}
|
||||
|
||||
if (row.nextStepsRaw && row.nextStepsRaw !== (row.doc.nextSteps ?? '')) {
|
||||
data.nextSteps = row.nextStepsRaw
|
||||
}
|
||||
|
||||
if (row.observationTarget && row.observationRaw) {
|
||||
const current = row.doc[row.observationTarget] ?? []
|
||||
const text = row.observationRaw.trim()
|
||||
if (!current.some((item) => (item.text ?? '').trim() === text)) {
|
||||
data[row.observationTarget] = [
|
||||
...current.map((item) => ({ id: item.id, text: item.text })),
|
||||
{ text },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(data).length > 0 ? data : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-transaction dry-run delta: relationship ids for entities that will only exist after
|
||||
* creation are unknown here, so "will add" is inferred from the plans instead.
|
||||
*/
|
||||
const planSummaryFor = (row) => {
|
||||
const fields = []
|
||||
const data = buildMunicipalityData(row)
|
||||
if (data?.expectedVotes) fields.push('estimativas/prioridade')
|
||||
if (data?.politicalTrend) fields.push('tendência')
|
||||
const advisorCreates = row.advisorSlugs.some((slug) => staffPlans.get(slug)?.userId == null)
|
||||
if (data?.advisors || advisorCreates) fields.push('assessores')
|
||||
const deputyCreates = row.deputySlugs.some((slug) => deputyPlans.get(slug)?.deputyId == null)
|
||||
if (data?.stateDeputies || deputyCreates) fields.push('dobradinhas')
|
||||
if (data?.dobradinhaNotes) fields.push('dobradinhas (notas)')
|
||||
if (data?.nextSteps) fields.push('encaminhamentos')
|
||||
if (data?.strengths) fields.push('forças')
|
||||
if (data?.risks) fields.push('riscos')
|
||||
return fields
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Report
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const staffToCreate = [...staffPlans.values()].filter((plan) => plan.userId == null)
|
||||
const staffMatched = [...staffPlans.values()].filter((plan) => plan.userId != null)
|
||||
const deputiesToCreate = [...deputyPlans.values()].filter((plan) => plan.deputyId == null)
|
||||
const leadershipsToCreate = [...leadershipPlans.values()].filter((plan) => !plan.existing)
|
||||
const leadershipsToLink = [...leadershipPlans.values()].filter(
|
||||
(plan) => plan.existing && plan.missingIds.length > 0,
|
||||
)
|
||||
|
||||
const estimateRows = matched.filter((row) => row.expectedVotes)
|
||||
const trendRows = matched.filter((row) => row.trendStatus)
|
||||
const municipalityPlans = matched
|
||||
.map((row) => ({ row, fields: planSummaryFor(row) }))
|
||||
.filter((plan) => plan.fields.length > 0)
|
||||
|
||||
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 expectedVotes+priority)'}`,
|
||||
`Modo: ${options.dryRun ? 'DRY-RUN (sem escrita)' : 'ESCRITA (estimativas + estratégia; união em relações)'}`,
|
||||
)
|
||||
console.log(`Linhas MAPA GERAL (com município): ${canonicalRows.size}`)
|
||||
console.log(`Casadas com EXPECTATIVA parseável: ${matched.length}`)
|
||||
console.log(`Casadas: ${matched.length} · com EXPECTATIVA parseável: ${estimateRows.length}`)
|
||||
console.log(`Com SITUAÇÃO definida: ${trendRows.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}`)
|
||||
console.log(
|
||||
`Assessores: ${staffPlans.size} (criar ${staffToCreate.length}, casados ${staffMatched.length})`,
|
||||
)
|
||||
console.log(
|
||||
`Dobradinhas: ${deputyPlans.size} (criar ${deputiesToCreate.length}, casadas ${deputyPlans.size - deputiesToCreate.length})`,
|
||||
)
|
||||
console.log(
|
||||
`Lideranças: ${leadershipPlans.size} (criar ${leadershipsToCreate.length}, vincular municípios em existentes ${leadershipsToLink.length})`,
|
||||
)
|
||||
console.log(`Segmentos descartados nas células de nomes: ${skippedSegments.length}`)
|
||||
console.log(`Overrides PRIORITÁRIAS ≠ MAPA GERAL: ${prioritariaOverrides.length}`)
|
||||
|
||||
if (staffPlans.size > 0) {
|
||||
console.log('\n— Assessores (coluna ASSESSOR RESPONSÁVEL) —')
|
||||
for (const plan of [...staffPlans.values()].sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const status = plan.existing
|
||||
? `casa com usuário existente #${plan.existing.id} (${plan.existing.name}, ${plan.existing.role})`
|
||||
: `criar (papel ${plan.role}, sem credenciais)`
|
||||
const variants = plan.entry.variants.size > 1 ? ` · variantes: ${formatVariants(plan.entry)}` : ''
|
||||
console.log(` ${plan.name}: ${status} · ${plan.entry.municipalityIds.size} município(s)${variants}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (deputyPlans.size > 0) {
|
||||
console.log('\n— Dobradinhas (deputados estaduais) —')
|
||||
for (const plan of [...deputyPlans.values()].sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const status = plan.existing ? `casa com dobradinha existente #${plan.existing.id}` : 'criar'
|
||||
const variants = plan.entry.variants.size > 1 ? ` · variantes: ${formatVariants(plan.entry)}` : ''
|
||||
console.log(` ${plan.name}: ${status} · ${plan.entry.municipalityIds.size} município(s)${variants}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (leadershipPlans.size > 0) {
|
||||
console.log('\n— Lideranças (nome-only; contato sem telefone) —')
|
||||
const multiMunicipality = [...leadershipPlans.values()].filter(
|
||||
(plan) => plan.municipalityIds.length > 1,
|
||||
)
|
||||
console.log(
|
||||
` ${leadershipsToCreate.length} nova(s), ${leadershipsToLink.length} existente(s) ganham município(s); ${multiMunicipality.length} com mais de um município (merge por nome — revisar):`,
|
||||
)
|
||||
for (const plan of multiMunicipality.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
console.log(` ${plan.name}: ${[...plan.entry.municipalityLabels].join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (prioritariaOverrides.length > 0) {
|
||||
console.log('\n— PRIORITÁRIAS sobrepõe MAPA GERAL (aplica-se a PRIORITÁRIAS) —')
|
||||
for (const override of prioritariaOverrides) {
|
||||
console.log(
|
||||
` ${override.label} · ${override.column}: ${JSON.stringify(override.prior)} (era ${JSON.stringify(override.geral)})`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (skippedSegments.length > 0) {
|
||||
console.log('\n— Segmentos descartados (notas/coletivos/incertezas — permanecem só na planilha) —')
|
||||
for (const skipped of skippedSegments.slice(0, 80)) {
|
||||
console.log(` [${skipped.label}] ${skipped.column}: ${JSON.stringify(skipped.segment)}`)
|
||||
}
|
||||
if (skippedSegments.length > 80) console.log(` … +${skippedSegments.length - 80} mais`)
|
||||
}
|
||||
|
||||
if (skippedSalvador.length > 0) {
|
||||
console.log('\n— Salvador (pulado; estimativas por ZE via UI) —')
|
||||
console.log('\n— Salvador (pulado; estimativas e estratégia por ZE via UI) —')
|
||||
for (const row of skippedSalvador) {
|
||||
console.log(
|
||||
` ${row.label}: ${row.expectationRaw ?? '(sem expectativa)'} · prioridade=${row.priorityRaw ?? '(vazia)'}`,
|
||||
` ${row.label}: ${row.expectationRaw ?? '(sem expectativa)'} · prioridade=${row.priorityRaw ?? '(vazia)'}${row.hasStrategy ? ' · colunas de estratégia ignoradas' : ''}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -476,7 +1050,7 @@ if (unmatched.length > 0) {
|
|||
}
|
||||
|
||||
if (parseErrors.length > 0) {
|
||||
console.log('\n— EXPECTATIVA não parseada (linha ignorada) —')
|
||||
console.log('\n— EXPECTATIVA não parseada (estimativa ignorada; estratégia segue) —')
|
||||
for (const row of parseErrors.slice(0, 30)) {
|
||||
console.log(` ${row.label} (${row.slug}): ${JSON.stringify(row.raw)}`)
|
||||
}
|
||||
|
|
@ -500,21 +1074,10 @@ if (warnings.length > 0) {
|
|||
if (warnings.length > 60) console.log(` … +${warnings.length - 60} mais`)
|
||||
}
|
||||
|
||||
const changing = matched.filter((row) => {
|
||||
const currentEstimates = toVoteEstimateScenarioViewModel(row.doc.expectedVotes)
|
||||
const nextEstimates = toVoteEstimateScenarioViewModel(row.expectedVotes)
|
||||
const currentPriority = row.doc.priority === 'alta' ? 'alta' : 'normal'
|
||||
return !voteEstimatesEqual(currentEstimates, nextEstimates) || currentPriority !== row.priority
|
||||
})
|
||||
|
||||
console.log(`\nEscritas planejadas: ${matched.length} (delta vs DB: ${changing.length})`)
|
||||
console.log(`\nMunicípios com escrita planejada: ${municipalityPlans.length} de ${matched.length}`)
|
||||
console.log('Amostra (até 8):')
|
||||
for (const row of matched.slice(0, 8)) {
|
||||
console.log(
|
||||
` ${row.doc.slug}: ${formatEstimates(toVoteEstimateScenarioViewModel(row.expectedVotes))} · priority=${row.priority} (DB era ${formatEstimates(
|
||||
toVoteEstimateScenarioViewModel(row.doc.expectedVotes),
|
||||
)}/${row.doc.priority ?? 'normal'})`,
|
||||
)
|
||||
for (const plan of municipalityPlans.slice(0, 8)) {
|
||||
console.log(` ${plan.row.doc.slug}: ${plan.fields.join(', ')}`)
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
|
|
@ -523,23 +1086,108 @@ if (options.dryRun) {
|
|||
}
|
||||
|
||||
try {
|
||||
await withPayloadTransaction(payload, async ({ req }) => {
|
||||
for (const row of matched) {
|
||||
await payload.update({
|
||||
collection: 'municipality',
|
||||
id: row.doc.id,
|
||||
const totals = await withPayloadTransaction(payload, async ({ req }) => {
|
||||
let usersCreated = 0
|
||||
for (const plan of staffPlans.values()) {
|
||||
if (plan.userId != null) continue
|
||||
const created = await payload.create({
|
||||
collection: 'campaignUser',
|
||||
data: {
|
||||
expectedVotes: row.expectedVotes,
|
||||
priority: row.priority,
|
||||
name: plan.name,
|
||||
role: plan.role,
|
||||
// Payload requires an email or username on auth creates. The reserved
|
||||
// ".invalid" TLD guarantees the address never routes; the random password
|
||||
// is never shared — an admin swaps in real credentials later.
|
||||
email: `${plan.slug}@planilha.invalid`,
|
||||
password: randomBytes(24).toString('base64url'),
|
||||
},
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
plan.userId = created.id
|
||||
usersCreated += 1
|
||||
}
|
||||
|
||||
let deputiesCreated = 0
|
||||
for (const plan of deputyPlans.values()) {
|
||||
if (plan.deputyId != null) continue
|
||||
const created = await payload.create({
|
||||
collection: 'stateDeputy',
|
||||
data: { name: plan.name },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
plan.deputyId = created.id
|
||||
deputiesCreated += 1
|
||||
}
|
||||
|
||||
let leadershipsCreated = 0
|
||||
let leadershipsLinked = 0
|
||||
for (const plan of leadershipPlans.values()) {
|
||||
if (plan.existing) {
|
||||
if (plan.missingIds.length === 0) continue
|
||||
await payload.update({
|
||||
collection: 'leadership',
|
||||
id: plan.existing.id,
|
||||
data: {
|
||||
municipalities: [...plan.existing.municipalityIds, ...plan.missingIds],
|
||||
},
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
leadershipsLinked += 1
|
||||
continue
|
||||
}
|
||||
const contact = await payload.create({
|
||||
collection: 'contact',
|
||||
data: {
|
||||
name: plan.name,
|
||||
state: 'BA',
|
||||
city: plan.city,
|
||||
},
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
await payload.create({
|
||||
collection: 'leadership',
|
||||
data: {
|
||||
contact: contact.id,
|
||||
municipalities: plan.municipalityIds,
|
||||
},
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
leadershipsCreated += 1
|
||||
}
|
||||
|
||||
let municipalitiesUpdated = 0
|
||||
for (const row of matched) {
|
||||
const data = buildMunicipalityData(row)
|
||||
if (!data) continue
|
||||
await payload.update({
|
||||
collection: 'municipality',
|
||||
id: row.doc.id,
|
||||
data,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
municipalitiesUpdated += 1
|
||||
}
|
||||
|
||||
return { usersCreated, deputiesCreated, leadershipsCreated, leadershipsLinked, municipalitiesUpdated }
|
||||
})
|
||||
|
||||
console.log(`\n[seed:projecao] OK — ${matched.length} município(s) atualizados (overwrite).\n`)
|
||||
console.log(
|
||||
`\n[seed:projecao] OK — ${totals.municipalitiesUpdated} município(s) atualizados; ` +
|
||||
`${totals.usersCreated} usuário(s), ${totals.deputiesCreated} dobradinha(s), ` +
|
||||
`${totals.leadershipsCreated} liderança(s) criados; ${totals.leadershipsLinked} liderança(s) existentes vinculadas.\n`,
|
||||
)
|
||||
process.exit(0)
|
||||
} catch (error) {
|
||||
console.error(`\n[seed:projecao] ${error instanceof Error ? error.message : String(error)}\n`)
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export const searchActionPlanContactOptions = async (
|
|||
overrideAccess: false,
|
||||
})
|
||||
|
||||
return result.docs.map(({ id, name, phone }) => ({ id, name, phone }))
|
||||
return result.docs.map(({ id, name, phone }) => ({ id, name, phone: phone ?? null }))
|
||||
}
|
||||
|
||||
export const searchActionPlanLeadershipOptionsAction = async (
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ const validateActionPlanAdvisors: CollectionBeforeValidateHook = async ({ data,
|
|||
})
|
||||
|
||||
if (eligibleAdvisors.docs.length !== advisorIDs.length) {
|
||||
throw new APIError('Cada responsável deve ter papel de Coordenador Geral ou Assessor.', 400)
|
||||
throw new APIError('Cada responsável deve ter papel de Coordenador Geral, Assessor ou Candidato.', 400)
|
||||
}
|
||||
|
||||
return data
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ const enforceUniqueContactPhone: CollectionBeforeChangeHook = async ({
|
|||
}
|
||||
|
||||
const phone = String(data.phone ?? originalDoc?.phone ?? '')
|
||||
// Phone-less contacts (e.g. name-only leadership imports) have no uniqueness to enforce.
|
||||
if (!phone) return data
|
||||
const oldPhone =
|
||||
operation === 'update' && typeof originalDoc?.phone === 'string'
|
||||
? originalDoc.phone
|
||||
|
|
@ -72,6 +74,11 @@ export const Contact: CollectionConfig = {
|
|||
({ data }) => {
|
||||
if (!data || data.phone === undefined) return data
|
||||
|
||||
if (data.phone === null || data.phone === '') {
|
||||
data.phone = null
|
||||
return data
|
||||
}
|
||||
|
||||
const input = String(data.phone)
|
||||
if (/^\d{11}$/.test(input)) {
|
||||
if (!/^[1-9]{2}9\d{8}$/.test(input)) {
|
||||
|
|
@ -112,7 +119,9 @@ export const Contact: CollectionConfig = {
|
|||
label: 'Celular',
|
||||
minLength: 11,
|
||||
maxLength: 11,
|
||||
required: true,
|
||||
// Optional at the collection level so name-only records (e.g. leaderships imported
|
||||
// from the projection sheet) can exist; UI flows still require it via zod schemas.
|
||||
required: false,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ const validateMunicipalityAdvisors: CollectionBeforeValidateHook = async ({ data
|
|||
})
|
||||
|
||||
if (eligibleAdvisors.docs.length !== advisorIDs.length) {
|
||||
throw new APIError('Cada assessor deve ter papel de Coordenador Geral ou Assessor.', 400)
|
||||
throw new APIError('Cada assessor deve ter papel de Coordenador Geral, Assessor ou Candidato.', 400)
|
||||
}
|
||||
|
||||
return data
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { isContactSearchQueryReady } from '@/lib/contactSearchQuery'
|
|||
export type ContactComboboxOption = {
|
||||
id: number
|
||||
name: string
|
||||
phone: string
|
||||
phone: string | null
|
||||
}
|
||||
|
||||
type ContactComboboxProps = {
|
||||
|
|
@ -21,7 +21,7 @@ type ContactComboboxProps = {
|
|||
}
|
||||
|
||||
const contactLabel = (contact: ContactComboboxOption): string =>
|
||||
`${contact.name} · ${contact.phone}`
|
||||
contact.phone ? `${contact.name} · ${contact.phone}` : contact.name
|
||||
|
||||
export const ContactCombobox = ({
|
||||
name,
|
||||
|
|
|
|||
|
|
@ -63,3 +63,110 @@ export const mapSheetPriority = (raw: string | null | undefined): ProjectionPrio
|
|||
if (raw == null) return 'normal'
|
||||
return raw.trim().toLowerCase() === 'alta' ? 'alta' : 'normal'
|
||||
}
|
||||
|
||||
/** Municipality politicalTrend.status values derivable from the sheet SITUAÇÃO column. */
|
||||
export type ProjectionTrendStatus = 'favoravel' | 'neutra' | 'desfavoravel'
|
||||
|
||||
const stripDiacritics = (value: string): string =>
|
||||
value.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||||
|
||||
/**
|
||||
* SITUAÇÃO cell → politicalTrend.status.
|
||||
* MAPA GERAL uses long emoji labels ("🔴 QUEDA DE VOTOS — Requer ação imediata");
|
||||
* PRIORITÁRIAS uses bare labels ("QUEDA" | "MANTÉM" | "AUMENTO").
|
||||
* "NÃO DEFINIDA", footers, and anything else → null (leave the municipality untouched).
|
||||
*/
|
||||
export const parseSituationCell = (
|
||||
raw: string | null | undefined,
|
||||
): ProjectionTrendStatus | null => {
|
||||
if (raw == null) return null
|
||||
const normalized = stripDiacritics(raw).toUpperCase()
|
||||
if (/\bQUEDA\b/.test(normalized)) return 'desfavoravel'
|
||||
if (/\bMANTEM\b/.test(normalized)) return 'neutra'
|
||||
if (/\bAUMENTO\b/.test(normalized)) return 'favoravel'
|
||||
return null
|
||||
}
|
||||
|
||||
export type NameCellSplit = {
|
||||
/** Segments that look like an individual's name (may carry a descriptor, e.g. "Amilton do PT"). */
|
||||
names: string[]
|
||||
/** Segments dropped as notes/collectives/uncertainty — surfaced in the dry-run report. */
|
||||
skipped: string[]
|
||||
}
|
||||
|
||||
// Honorific abbreviations whose trailing period must not act as a sentence separator.
|
||||
const HONORIFIC_ABBREVIATION = /\b(dr|dra|sr|sra|prof|profa|sec)\./gi
|
||||
const HONORIFIC_PLACEHOLDER = '\u0000'
|
||||
|
||||
/**
|
||||
* Note/collective segment detectors, evaluated on the accent-stripped lowercase segment.
|
||||
* A match means "this is not an individual's name" — the segment goes to `skipped`.
|
||||
*/
|
||||
const NOTE_SEGMENT_PATTERNS: RegExp[] = [
|
||||
/^-+$/, // placeholder dash
|
||||
/\?/, // uncertainty markers ("Alex?", "vice?", "?")
|
||||
// Leading action verbs ("VER COM VILMA", "CONFIRMAR COM JÚLIO", "Construindo Julio", "com Goiano")
|
||||
/^(ver|falar|confirmar|aguardar|articulando|construindo|resgatar|tratar|definir|cobrar|com)\b/,
|
||||
/\b(ver|falar) com\b/, // embedded notes ("Amauri - VER COM VÉU")
|
||||
/-\s*(ver|cobrar|definir|confirmar|aguardar|tratar)\b/, // "Diego Pita - COBRAR IONÁ"
|
||||
/\bvai\b/, // "SOLLA VAI INFORMAR", "Solla vai ver"
|
||||
/^a definir\b/, // "A definir"
|
||||
/^candidato d/, // "candidato do Prefeito"
|
||||
// Collectives / relationships / roles without an individual's name
|
||||
/^(pessoal|pesoal|grupo|novo nucleo|associacao|sindicato|sindasesb|csol|presidente)\b/,
|
||||
/^(pessoa|irma|sogro|moca|assessora?|vereadora?) de\b/,
|
||||
/^estadual do\b/,
|
||||
/^gerente d/,
|
||||
/^indigenas?$/,
|
||||
/^ex[- ]?(vice|prefeit)/,
|
||||
/^([oa] )?(vereadora?|vereadores|prefeit[oa]|vice)$/,
|
||||
/^sec\.? saude$/,
|
||||
]
|
||||
|
||||
const isNoteSegment = (segment: string): boolean => {
|
||||
const normalized = stripDiacritics(segment).toLowerCase()
|
||||
return NOTE_SEGMENT_PATTERNS.some((pattern) => pattern.test(normalized))
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a DOBRADINHAS / LIDERANÇAS / ASSESSOR RESPONSÁVEL cell into individual names.
|
||||
* Separators: comma, slash, semicolon, newline, " e ", and sentence periods (honorifics
|
||||
* like "Dr. Paulo" are protected). Parentheticals are extracted into `skipped` before
|
||||
* splitting; note-like segments (action phrases, collectives, "?"-marked) are skipped.
|
||||
*/
|
||||
export const splitNameCell = (raw: string | null | undefined): NameCellSplit => {
|
||||
const names: string[] = []
|
||||
const skipped: string[] = []
|
||||
const cell = raw?.trim()
|
||||
if (!cell) return { names, skipped }
|
||||
|
||||
const withoutParentheticals = cell.replace(/\([^)]*\)/g, (match) => {
|
||||
skipped.push(match.trim())
|
||||
return ' '
|
||||
})
|
||||
|
||||
const protectedCell = withoutParentheticals.replace(
|
||||
HONORIFIC_ABBREVIATION,
|
||||
`$1${HONORIFIC_PLACEHOLDER}`,
|
||||
)
|
||||
|
||||
const segments = protectedCell.split(/(?:[,/;|\n\r]|\s+e\s+|\.\s+|\.$)+/i)
|
||||
|
||||
for (const segment of segments) {
|
||||
const cleaned = segment.replaceAll(HONORIFIC_PLACEHOLDER, '.').replace(/\s+/g, ' ').trim()
|
||||
if (!cleaned) continue
|
||||
if (isNoteSegment(cleaned)) {
|
||||
skipped.push(cleaned)
|
||||
continue
|
||||
}
|
||||
// Stray edge punctuation from sheet typos ("Júlio-"); internal descriptors survive.
|
||||
const name = cleaned.replace(/^[-–—\s]+/, '').replace(/[-–—\s]+$/, '')
|
||||
if (!name) {
|
||||
skipped.push(cleaned)
|
||||
continue
|
||||
}
|
||||
names.push(name)
|
||||
}
|
||||
|
||||
return { names, skipped }
|
||||
}
|
||||
|
|
|
|||
20
src/migrations/20260724_175500_contact_phone_optional.ts
Normal file
20
src/migrations/20260724_175500_contact_phone_optional.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
/**
|
||||
* Makes contact.phone nullable so name-only records can exist (e.g. leaderships
|
||||
* imported from the projection sheet, E4R fase 2). UI flows keep requiring the
|
||||
* phone via their zod schemas; only the collection/database constraint relaxes.
|
||||
* Idempotent: DROP NOT NULL is a no-op when the column is already nullable.
|
||||
*/
|
||||
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "contact" ALTER COLUMN "phone" DROP NOT NULL;
|
||||
`)
|
||||
}
|
||||
|
||||
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
||||
// Restoring NOT NULL fails if phone-less rows exist — remove them first by hand.
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "contact" ALTER COLUMN "phone" SET NOT NULL;
|
||||
`)
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ import * as migration_20260723_200000_remodel_municipalities from './20260723_20
|
|||
import * as migration_20260723_201000_add_state_deputy from './20260723_201000_add_state_deputy'
|
||||
import * as migration_20260723_202000_reconcile_municipality_remodel from './20260723_202000_reconcile_municipality_remodel'
|
||||
import * as migration_20260724_133600_drop_municipality_vote_goals from './20260724_133600_drop_municipality_vote_goals'
|
||||
import * as migration_20260724_175500_contact_phone_optional from './20260724_175500_contact_phone_optional'
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -156,4 +157,9 @@ export const migrations = [
|
|||
down: migration_20260724_133600_drop_municipality_vote_goals.down,
|
||||
name: '20260724_133600_drop_municipality_vote_goals',
|
||||
},
|
||||
{
|
||||
up: migration_20260724_175500_contact_phone_optional.up,
|
||||
down: migration_20260724_175500_contact_phone_optional.down,
|
||||
name: '20260724_175500_contact_phone_optional',
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -353,7 +353,7 @@ export interface Contact {
|
|||
id: number;
|
||||
name: string;
|
||||
email?: string | null;
|
||||
phone: string;
|
||||
phone?: string | null;
|
||||
gender?: ('feminino' | 'masculino' | 'outro' | 'nao_informado') | null;
|
||||
state:
|
||||
| 'AC'
|
||||
|
|
|
|||
|
|
@ -57,9 +57,17 @@ export const isCampaignStaff = (user: CampaignActor): boolean =>
|
|||
isCampaignUser(user) &&
|
||||
(user.role === 'coordinator' || user.role === 'advisor' || user.role === 'candidate')
|
||||
|
||||
/** Eligible relationship targets for advisor assignments (municipality / action plan). */
|
||||
/**
|
||||
* Eligible relationship targets for advisor assignments (municipality / action plan).
|
||||
* The candidate is included: the projection sheet lists him as the responsible contact
|
||||
* for some municipalities (decision 2026-07-24).
|
||||
*/
|
||||
export const eligibleCampaignStaffWhere: Where = {
|
||||
or: [{ role: { equals: 'coordinator' } }, { role: { equals: 'advisor' } }],
|
||||
or: [
|
||||
{ role: { equals: 'coordinator' } },
|
||||
{ role: { equals: 'advisor' } },
|
||||
{ role: { equals: 'candidate' } },
|
||||
],
|
||||
}
|
||||
|
||||
export const getFreshCampaignUser = async (
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ export const actionPlanFormSelect = {
|
|||
export type ActionPlanFormTaskViewModel = {
|
||||
id: string | null
|
||||
title: string
|
||||
responsible: { id: number; name: string; phone: string } | null
|
||||
responsible: { id: number; name: string; phone: string | null } | null
|
||||
due: string | null
|
||||
done: boolean
|
||||
}
|
||||
|
|
@ -124,7 +124,7 @@ export type ActionPlanFormViewModel = {
|
|||
locality: string | null
|
||||
organizationIDs: number[]
|
||||
advisorIDs: number[]
|
||||
responsible: { id: number; name: string; phone: string } | null
|
||||
responsible: { id: number; name: string; phone: string | null } | null
|
||||
leadership: { id: number; label: string } | null
|
||||
tasks: ActionPlanFormTaskViewModel[]
|
||||
}
|
||||
|
|
@ -150,7 +150,11 @@ export const toActionPlanFormViewModel = (plan: ActionPlan): ActionPlanFormViewM
|
|||
organizationIDs: relationshipIds(plan.organizations),
|
||||
advisorIDs: relationshipIds(plan.advisors),
|
||||
responsible: isPopulatedRelationship<Contact>(plan.responsible)
|
||||
? { id: plan.responsible.id, name: plan.responsible.name, phone: plan.responsible.phone }
|
||||
? {
|
||||
id: plan.responsible.id,
|
||||
name: plan.responsible.name,
|
||||
phone: plan.responsible.phone ?? null,
|
||||
}
|
||||
: null,
|
||||
leadership: (() => {
|
||||
const leadershipId = relationshipId(plan.leadership)
|
||||
|
|
@ -168,7 +172,11 @@ export const toActionPlanFormViewModel = (plan: ActionPlan): ActionPlanFormViewM
|
|||
id: task.id ?? null,
|
||||
title: task.title,
|
||||
responsible: isPopulatedRelationship<Contact>(task.responsible)
|
||||
? { id: task.responsible.id, name: task.responsible.name, phone: task.responsible.phone }
|
||||
? {
|
||||
id: task.responsible.id,
|
||||
name: task.responsible.name,
|
||||
phone: task.responsible.phone ?? null,
|
||||
}
|
||||
: null,
|
||||
due: task.due ?? null,
|
||||
done: Boolean(task.done),
|
||||
|
|
|
|||
|
|
@ -82,6 +82,9 @@ export const createCampaignInviteForActor = async (
|
|||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
if (!contact.phone) {
|
||||
throw new Error('Cadastre o celular da liderança antes de gerar o convite.')
|
||||
}
|
||||
const inviteUrl = `${inviteBaseURL}/campanha/convite/${generated.token}`
|
||||
return {
|
||||
inviteUrl,
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ export const getCampaignInvitePageData = async (
|
|||
kind: invite.kind,
|
||||
profile: {
|
||||
name: contact.name,
|
||||
phone: contact.phone,
|
||||
phone: contact.phone ?? '',
|
||||
email: contact.email ?? null,
|
||||
gender: contact.gender ?? null,
|
||||
sector: leadership.sector ?? null,
|
||||
|
|
|
|||
|
|
@ -132,7 +132,9 @@ export const redeemCampaignInviteAutofillRecord = async (
|
|||
await acquireCampaignInviteAccountLocks(
|
||||
payload,
|
||||
req,
|
||||
contactPhoneLockKeys([originalContact.phone, data.phone]),
|
||||
contactPhoneLockKeys(
|
||||
[originalContact.phone, data.phone].filter((phone): phone is string => Boolean(phone)),
|
||||
),
|
||||
)
|
||||
await assertContactPhoneAvailable(
|
||||
payload,
|
||||
|
|
@ -208,7 +210,9 @@ export const redeemCampaignInviteLoginRecord = async (
|
|||
})
|
||||
let account = await findReusableLeadershipAccount(payload, req, leadership)
|
||||
await acquireCampaignInviteAccountLocks(payload, req, [
|
||||
...contactPhoneLockKeys([originalContact.phone, data.phone]),
|
||||
...contactPhoneLockKeys(
|
||||
[originalContact.phone, data.phone].filter((phone): phone is string => Boolean(phone)),
|
||||
),
|
||||
...(account?.username ? [`account-username:${account.username}`] : []),
|
||||
`account-username:${data.phone}`,
|
||||
...(account ? [`invite-redemption-user:${account.id}`] : []),
|
||||
|
|
|
|||
|
|
@ -569,7 +569,7 @@ export class CampaignFixtures {
|
|||
return consent
|
||||
}
|
||||
|
||||
async createContact(input: ContactInput = {}): Promise<Contact> {
|
||||
async createContact(input: ContactInput = {}): Promise<Contact & { phone: string }> {
|
||||
const contact = await this.rootPayload.create({
|
||||
collection: 'contact',
|
||||
data: {
|
||||
|
|
@ -582,7 +582,12 @@ export class CampaignFixtures {
|
|||
depth: 0,
|
||||
})
|
||||
this.own('contact', contact)
|
||||
return contact
|
||||
// Contact.phone is optional at the collection level (name-only imports), but
|
||||
// every fixture contact carries one — narrow so specs can rely on it.
|
||||
if (typeof contact.phone !== 'string') {
|
||||
throw new Error('Fixture contacts must always carry a phone.')
|
||||
}
|
||||
return { ...contact, phone: contact.phone }
|
||||
}
|
||||
|
||||
/** Assign advisors to a seeded municipality (tracked for reset on cleanup). */
|
||||
|
|
|
|||
|
|
@ -1127,7 +1127,7 @@ describe('campaign invite domain', () => {
|
|||
await expect(
|
||||
actions.redeemCampaignInviteAutofill({
|
||||
...submissions[rejectedIndex]!,
|
||||
phone: contacts[rejectedIndex]!.phone,
|
||||
phone: contacts[rejectedIndex]!.phone!,
|
||||
}),
|
||||
).resolves.toEqual({ ok: true })
|
||||
},
|
||||
|
|
|
|||
|
|
@ -359,7 +359,7 @@ describe('campaign invite UI contracts', () => {
|
|||
const fixture = await createPublicInviteScenario('login')
|
||||
const formData = new FormData()
|
||||
formData.set('name', fixture.contact.name)
|
||||
formData.set('phone', fixture.contact.phone)
|
||||
formData.set('phone', fixture.contact.phone!)
|
||||
formData.set('email', fixture.contact.email ?? '')
|
||||
formData.set('password', 'senha-segura')
|
||||
formData.set('passwordConfirmation', 'senha-diferente')
|
||||
|
|
@ -382,7 +382,7 @@ describe('campaign invite UI contracts', () => {
|
|||
const fixture = await createPublicInviteScenario('login')
|
||||
const formData = new FormData()
|
||||
formData.set('name', fixture.contact.name)
|
||||
formData.set('phone', fixture.contact.phone)
|
||||
formData.set('phone', fixture.contact.phone!)
|
||||
formData.set('email', fixture.contact.email ?? '')
|
||||
formData.set('password', ' senha-segura ')
|
||||
formData.set('passwordConfirmation', 'senha-segura')
|
||||
|
|
@ -406,7 +406,7 @@ describe('campaign invite UI contracts', () => {
|
|||
const fixture = await createPublicInviteScenario('autopreenchimento')
|
||||
const formData = new FormData()
|
||||
formData.set('name', 'Maria Atualizada')
|
||||
formData.set('phone', fixture.contact.phone)
|
||||
formData.set('phone', fixture.contact.phone!)
|
||||
formData.set('email', fixture.contact.email ?? '')
|
||||
formData.set('gender', 'feminino')
|
||||
formData.set('sector', 'comunitario')
|
||||
|
|
|
|||
|
|
@ -251,13 +251,24 @@ describe('municipality catalog seed and per-role access', () => {
|
|||
}),
|
||||
).rejects.toThrow()
|
||||
|
||||
// The candidate is eligible: the projection sheet lists him as responsible
|
||||
// for some municipalities (decision 2026-07-24).
|
||||
const candidate = await fixtures.createCampaignUser('candidate')
|
||||
const assigned = await assignMunicipalityAdvisorsRecord(payload, coordinator, {
|
||||
municipality: municipality.id,
|
||||
advisors: [advisor.id],
|
||||
advisors: [advisor.id, candidate.id],
|
||||
})
|
||||
fixtures.touchMunicipality(municipality.id)
|
||||
expect(
|
||||
assigned.advisors?.map((entry) => (typeof entry === 'number' ? entry : entry.id)),
|
||||
).toEqual([advisor.id, candidate.id])
|
||||
|
||||
const advisorOnly = await assignMunicipalityAdvisorsRecord(payload, coordinator, {
|
||||
municipality: municipality.id,
|
||||
advisors: [advisor.id],
|
||||
})
|
||||
expect(
|
||||
advisorOnly.advisors?.map((entry) => (typeof entry === 'number' ? entry : entry.id)),
|
||||
).toEqual([advisor.id])
|
||||
|
||||
const scope = await getAccessibleMunicipalityIds(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import {
|
|||
mapSheetPriority,
|
||||
parseExpectationCell,
|
||||
parseSheetNumber,
|
||||
parseSituationCell,
|
||||
splitNameCell,
|
||||
} from '@/lib/projectionSheetParse'
|
||||
|
||||
describe('parseSheetNumber', () => {
|
||||
|
|
@ -102,3 +104,167 @@ describe('mapSheetPriority', () => {
|
|||
expect(mapSheetPriority('média')).toBe('normal')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseSituationCell', () => {
|
||||
it('maps the long MAPA GERAL labels with emoji', () => {
|
||||
expect(parseSituationCell('🔴 QUEDA DE VOTOS — Requer ação imediata')).toBe('desfavoravel')
|
||||
expect(parseSituationCell('🟡 MANTÉM VOTAÇÃO — Acompanhar de perto')).toBe('neutra')
|
||||
expect(parseSituationCell('🟢 AUMENTO DE VOTOS — Consolidar e ampliar')).toBe('favoravel')
|
||||
})
|
||||
|
||||
it('maps the bare PRIORITÁRIAS labels', () => {
|
||||
expect(parseSituationCell('QUEDA')).toBe('desfavoravel')
|
||||
expect(parseSituationCell('MANTÉM')).toBe('neutra')
|
||||
expect(parseSituationCell('AUMENTO')).toBe('favoravel')
|
||||
})
|
||||
|
||||
it('returns null for undefined situations and footers', () => {
|
||||
expect(parseSituationCell('NÃO DEFINIDA')).toBeNull()
|
||||
expect(parseSituationCell('TOTAL GERAL')).toBeNull()
|
||||
expect(parseSituationCell('')).toBeNull()
|
||||
expect(parseSituationCell(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitNameCell', () => {
|
||||
it('splits on commas, slashes, and " e "', () => {
|
||||
expect(splitNameCell('Mário, Romilson, Diquinha e Jamile')).toEqual({
|
||||
names: ['Mário', 'Romilson', 'Diquinha', 'Jamile'],
|
||||
skipped: [],
|
||||
})
|
||||
expect(splitNameCell('Júlio-').names).toEqual(['Júlio'])
|
||||
expect(splitNameCell('Roberto/Oseas e Ademário')).toEqual({
|
||||
names: ['Roberto', 'Oseas', 'Ademário'],
|
||||
skipped: [],
|
||||
})
|
||||
expect(splitNameCell('Radiovaldo e Júlio e Ludimila')).toEqual({
|
||||
names: ['Radiovaldo', 'Júlio', 'Ludimila'],
|
||||
skipped: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps honorifics and descriptors as part of the name', () => {
|
||||
expect(splitNameCell('Pedro Melo, Vera, Dr. Paulo e Yago').names).toEqual([
|
||||
'Pedro Melo',
|
||||
'Vera',
|
||||
'Dr. Paulo',
|
||||
'Yago',
|
||||
])
|
||||
expect(splitNameCell('Emiran, Amilton do PT e Moça de KK')).toEqual({
|
||||
names: ['Emiran', 'Amilton do PT'],
|
||||
skipped: ['Moça de KK'],
|
||||
})
|
||||
expect(splitNameCell('Markão, Sandrinho, Prefeito e Eleni Sec Saúde')).toEqual({
|
||||
names: ['Markão', 'Sandrinho', 'Eleni Sec Saúde'],
|
||||
skipped: ['Prefeito'],
|
||||
})
|
||||
})
|
||||
|
||||
it('extracts parentheticals before splitting', () => {
|
||||
expect(splitNameCell('Cintia/Fernando/ (shaulin e davi terra ) e Silvio')).toEqual({
|
||||
names: ['Cintia', 'Fernando', 'Silvio'],
|
||||
skipped: ['(shaulin e davi terra )'],
|
||||
})
|
||||
expect(splitNameCell('Lucy, Edizio e Marianna (com Jamile)')).toEqual({
|
||||
names: ['Lucy', 'Edizio', 'Marianna'],
|
||||
skipped: ['(com Jamile)'],
|
||||
})
|
||||
})
|
||||
|
||||
it('drops uncertainty markers and placeholder dashes', () => {
|
||||
expect(splitNameCell('Alex? e Júlio')).toEqual({ names: ['Júlio'], skipped: ['Alex?'] })
|
||||
expect(splitNameCell('Panfile, Eliomar, vice?')).toEqual({
|
||||
names: ['Panfile', 'Eliomar'],
|
||||
skipped: ['vice?'],
|
||||
})
|
||||
expect(splitNameCell('-')).toEqual({ names: [], skipped: ['-'] })
|
||||
expect(splitNameCell('?')).toEqual({ names: [], skipped: ['?'] })
|
||||
expect(splitNameCell(null)).toEqual({ names: [], skipped: [] })
|
||||
})
|
||||
|
||||
it('drops action phrases and collectives', () => {
|
||||
expect(splitNameCell('VER COM VILMA').names).toEqual([])
|
||||
expect(splitNameCell('CONFIRMAR COM JÚLIO').names).toEqual([])
|
||||
expect(splitNameCell('AGUARDAR DEFINIÇÃO DE CIBELE').names).toEqual([])
|
||||
expect(splitNameCell('SOLLA VAI INFORMAR').names).toEqual([])
|
||||
expect(splitNameCell('Solla vai ver').names).toEqual([])
|
||||
expect(splitNameCell('Construindo pra trocar Galo por Julio ou Rosemberg').names).toEqual([])
|
||||
expect(splitNameCell('Júlio e Estadual do Prefeito')).toEqual({
|
||||
names: ['Júlio'],
|
||||
skipped: ['Estadual do Prefeito'],
|
||||
})
|
||||
expect(splitNameCell('A definir').names).toEqual([])
|
||||
expect(splitNameCell('candidato do Prefeito').names).toEqual([])
|
||||
expect(splitNameCell('Chico e vereador')).toEqual({
|
||||
names: ['Chico'],
|
||||
skipped: ['vereador'],
|
||||
})
|
||||
expect(splitNameCell('TRATAR COM ADEMAR DELGADO').names).toEqual([])
|
||||
expect(splitNameCell('Burica | DEFINIR ALYSON')).toEqual({
|
||||
names: ['Burica'],
|
||||
skipped: ['DEFINIR ALYSON'],
|
||||
})
|
||||
expect(splitNameCell('Diego Pita - COBRAR IONÁ').names).toEqual([])
|
||||
expect(splitNameCell('Ex-vice prefeito').names).toEqual([])
|
||||
expect(splitNameCell('o Prefeito').names).toEqual([])
|
||||
expect(splitNameCell('Assessor de Fatima Nunes').names).toEqual([])
|
||||
expect(splitNameCell('Presidente do PT e Netinho presidente do PT')).toEqual({
|
||||
names: ['Netinho presidente do PT'],
|
||||
skipped: ['Presidente do PT'],
|
||||
})
|
||||
expect(splitNameCell('Meiriane ex prefeita').names).toEqual(['Meiriane ex prefeita'])
|
||||
expect(splitNameCell('Rogério e grupo de ACS, Rodrigo ')).toEqual({
|
||||
names: ['Rogério', 'Rodrigo'],
|
||||
skipped: ['grupo de ACS'],
|
||||
})
|
||||
expect(splitNameCell('Dr. Ruy e pesoal do PT e Alfredo Boasorte')).toEqual({
|
||||
names: ['Dr. Ruy', 'Alfredo Boasorte'],
|
||||
skipped: ['pesoal do PT'],
|
||||
})
|
||||
expect(splitNameCell('Sec. Saúde, Rosival Presidente do PT, Associação de mulheres')).toEqual({
|
||||
names: ['Rosival Presidente do PT'],
|
||||
skipped: ['Sec. Saúde', 'Associação de mulheres'],
|
||||
})
|
||||
expect(splitNameCell('Adiel e irmã de Nagib, pessoal do Circo (Oseas) e Pessoal do Roque')).toEqual(
|
||||
{
|
||||
names: ['Adiel'],
|
||||
skipped: ['(Oseas)', 'irmã de Nagib', 'pessoal do Circo', 'Pessoal do Roque'],
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('splits sentence periods while keeping honorific abbreviations intact', () => {
|
||||
expect(splitNameCell('Lucy. Falar com Fabio e com Goiano')).toEqual({
|
||||
names: ['Lucy'],
|
||||
skipped: ['Falar com Fabio', 'com Goiano'],
|
||||
})
|
||||
expect(splitNameCell('Telma, Breno e Nem do Caipe, Luis campineira.').names).toEqual([
|
||||
'Telma',
|
||||
'Breno',
|
||||
'Nem do Caipe',
|
||||
'Luis campineira',
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps compound entries with embedded descriptors', () => {
|
||||
expect(
|
||||
splitNameCell(
|
||||
'David Terra, Geysa e Ana Fraga, Adalicio - Sindicato dos Trabalhadores Rurais e sogro de Matheus, Fernando de Cairu, Jaci Perninha',
|
||||
),
|
||||
).toEqual({
|
||||
names: [
|
||||
'David Terra',
|
||||
'Geysa',
|
||||
'Ana Fraga',
|
||||
'Adalicio - Sindicato dos Trabalhadores Rurais',
|
||||
'Fernando de Cairu',
|
||||
'Jaci Perninha',
|
||||
],
|
||||
skipped: ['sogro de Matheus'],
|
||||
})
|
||||
expect(splitNameCell('Getúlio e vereadores')).toEqual({
|
||||
names: ['Getúlio'],
|
||||
skipped: ['vereadores'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue