teqo-backup/scripts/import-projecao.mjs
Francisco Ignácio Fontoura Solla 4806c417c8 Engineering Pass 3: consolidate patterns and lock them with guards.
Fold duplicated campanha access, loaders, list/filter/form shells, and scripts into shared modules; reorganize utilities by domain; stabilize e2e; and document prevention-first audit workflow so the same smells cannot creep back in.
2026-07-29 06:11:29 -03:00

1213 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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.
* - 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.
*
* 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 { randomBytes } from 'node:crypto'
import { existsSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { loadCliEnv } from './lib/cli.mjs'
import { getPayload } from 'payload'
import XLSX from 'xlsx'
import { assertLocalDatabase } from './assert-local-database.mjs'
loadCliEnv()
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 {{ optimistic: number, central: number, pessimistic: number }} ProjectionVoteEstimates
* @typedef {{
* municipalityLabel: string
* regionLabel: string | null
* votes2014: number | null
* votes2018: number | null
* 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
*/
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')
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()
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]),
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]),
})
}
return byKey
}
/**
* 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 readPrioritariaRows = (workbook, normalizeKey) => {
const matrix = sheetMatrix(workbook, PRIORITARIAS)
const { rowIndex, indexByLabel } = findHeaderIndex(matrix, ['MUNICÍPIO'])
const municipalityCol = indexByLabel.get('MUNICÍPIO')
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
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 byKey
}
assertLocalDatabase(
'seed:projecao',
'This script overwrites municipality strategy fields 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,
parseSituationCell,
splitNameCell,
} = await import('../src/lib/projectionSheetParse.ts')
const {
getVoteEstimateOrderViolation,
toVoteEstimateScenarioViewModel,
voteEstimatesEqual,
voteEstimateScenarioLabels,
hasAnyVoteEstimate,
} = await import('../src/lib/voteEstimate.ts')
const { withPayloadTransaction } = await import('../src/utilities/payloadTransaction.ts')
const { slugify } = await import('../src/lib/slug.ts')
/** @param {import('../src/lib/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 prioritariaRows = readPrioritariaRows(canonicalBook, normalizeMunicipalityKey)
const prioritarias = new Set(prioritariaRows.keys())
/** @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,
expectedVotes: true,
advisors: true,
stateDeputies: true,
dobradinhaNotes: true,
nextSteps: true,
strengths: true,
risks: true,
politicalTrend: true,
},
})
/** @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, doc)
}
}
const matched = []
const skippedSalvador = []
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
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
}
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
}
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
}
// --- 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 (expectedVotes && getVoteEstimateOrderViolation(expectedVotes)) {
parseErrors.push({
label: sheetRow.municipalityLabel,
slug: catalogEntry.slug,
raw: `${sheetRow.expectationRaw} (ordem Otimista ≥ Média ≥ Pessimista inválida)`,
})
expectedVotes = null
}
const priority = mapSheetPriority(sheetRow.priorityRaw)
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 (expectedVotes && referenceRows) {
const ref = referenceRows.get(sheetKey)
if (ref) {
const refEstimates = parseExpectationCell(ref.expectationRaw)
const refPriority = mapSheetPriority(ref.priorityRaw)
if (
refEstimates &&
(!voteEstimatesEqual(
toVoteEstimateScenarioViewModel(expectedVotes),
toVoteEstimateScenarioViewModel(refEstimates),
) ||
priority !== refPriority)
) {
axbDiffs.push({
label: sheetRow.municipalityLabel,
a: { expectedVotes, priority },
b: { expectedVotes: refEstimates, priority: refPriority },
})
}
}
}
// --- 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 (estimativas + estratégia; união em relações)'}`,
)
console.log(`Linhas MAPA GERAL (com município): ${canonicalRows.size}`)
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 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.hasStrategy ? ' · colunas de estratégia ignoradas' : ''}`,
)
}
}
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 (estimativa ignorada; estratégia segue) —')
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=${formatEstimates(toVoteEstimateScenarioViewModel(diff.a.expectedVotes))}/${diff.a.priority} · B=${formatEstimates(toVoteEstimateScenarioViewModel(diff.b.expectedVotes))}/${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`)
}
console.log(`\nMunicípios com escrita planejada: ${municipalityPlans.length} de ${matched.length}`)
console.log('Amostra (até 8):')
for (const plan of municipalityPlans.slice(0, 8)) {
console.log(` ${plan.row.doc.slug}: ${plan.fields.join(', ')}`)
}
if (options.dryRun) {
console.log('\n[seed:projecao] Dry-run concluído — nenhuma escrita.\n')
process.exit(0)
}
try {
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: {
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 — ${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`)
process.exit(1)
}