teqo-backup/src/lib/projectionSheetParse.ts
Francisco Ignácio Fontoura Solla 11d3fcd24c Pass 2 W4e: tipos de fonte unica
- triplo de cenarios: municipalityMapData usa VoteEstimateScenarioFields (2 grafias inline eliminadas)
- MAX_LEADERSHIP_MUNICIPALITIES: Leadership.ts importa de lib/schemas/leadership (const local removida)
- campaignUserInitials: copia divergente do AvatarStack removida; fonte unica em campaignUserProfile (com fallback defensivo)
- tendencia politica: options do select da collection derivam de politicalTrendStatuses + politicalTrendLabels; ProjectionTrendStatus vira alias de PoliticalTrendStatusValue
- MunicipalityStrategyViewModel nomeado e exportado (consumidores param de indexar NonNullable<...['strategy']>); nota de por que NAO deriva do zod
- DynamicFind documentado como a unica grafia aprovada do cast
- stub<T> nos doubles de teste onde a checagem por propriedade e real (campaignUserPhone, campaignAuth, campaignConsent); deep fakes mantem o cast localizado
2026-07-25 15:05:03 -03:00

173 lines
6.5 KiB
TypeScript

/**
* Pure parsers for the campaign projection spreadsheet (E4R).
* Sheet cells → municipality expectedVotes / priority — no Payload, no I/O.
*
* Sheet vocabulary maps onto the estimate scenarios:
* Bom → optimistic, Regular → central (média), Mínimo → pessimistic.
*/
import type { PoliticalTrendStatusValue } from '@/lib/schemas/municipality'
import type { VoteEstimateScenarioFields } from '@/lib/voteEstimate'
/** Parsed sheet triple — all three scenarios present (same keys as expectedVotes). */
export type ProjectionVoteEstimates = Required<
Pick<VoteEstimateScenarioFields, 'pessimistic' | 'central' | 'optimistic'>
>
export type ProjectionPriority = 'alta' | 'normal'
/** Strip Brazilian thousand separators (`10.000` → 10000) and parse an integer. */
export const parseSheetNumber = (raw: string | number | null | undefined): number | null => {
if (raw == null) return null
if (typeof raw === 'number') {
if (!Number.isFinite(raw)) return null
return Math.round(raw)
}
const trimmed = raw.trim()
if (!trimmed) return null
const normalized = trimmed.replace(/\./g, '').replace(/,/g, '.')
if (!/^-?\d+(\.\d+)?$/.test(normalized)) return null
const value = Number(normalized)
if (!Number.isFinite(value)) return null
return Math.round(value)
}
// Accepts `|` or `\` between Bom/Regular/Mínimo (some sheet exports use backslash).
const LABELED_EXPECTATION =
/^Bom\s*:\s*([\d.]+)\s*[|\\]\s*Regular\s*:\s*([\d.]+)\s*[|\\]\s*M[ií]nimo\s*:\s*([\d.]+)\s*$/i
const SLASH_EXPECTATION = /^([\d.]+)\s*\/\s*([\d.]+)\s*\/\s*([\d.]+)\s*$/
export const parseExpectationCell = (
raw: string | null | undefined,
): ProjectionVoteEstimates | null => {
if (raw == null) return null
const trimmed = raw.trim().replace(/\s+/g, ' ')
if (!trimmed) return null
const match = trimmed.match(LABELED_EXPECTATION) ?? trimmed.match(SLASH_EXPECTATION)
if (!match) return null
const optimistic = parseSheetNumber(match[1])
const central = parseSheetNumber(match[2])
const pessimistic = parseSheetNumber(match[3])
if (optimistic == null || central == null || pessimistic == null) return null
return { optimistic, central, pessimistic }
}
/** `alta` → alta; anything else (incl. Baixa / empty) → normal. */
export const mapSheetPriority = (raw: string | null | undefined): ProjectionPriority => {
if (raw == null) return 'normal'
return raw.trim().toLowerCase() === 'alta' ? 'alta' : 'normal'
}
/** Municipality politicalTrend.status values derivable from the sheet SITUAÇÃO column. */
export type ProjectionTrendStatus = PoliticalTrendStatusValue
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 }
}