From 85de335cc30be45ce763b367faea2ab4f411aac0 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 15 Sep 2026 08:34:23 -0300 Subject: [PATCH] =?UTF-8?q?fix(C163):=20emendas=20por=20localidade,=20font?= =?UTF-8?q?es=20compactas=20na=20p=C3=A1gina=201=20e=20datas=20pt-BR=20(Re?= =?UTF-8?q?lated=20#1025)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-09-15-c163-fix-relatorio-cidade.md | 1 + scripts/build-city-report.mjs | 1 + scripts/lib/cityReportBlocks.mjs | 14 ++- scripts/lib/cityReportFormat.mjs | 8 +- scripts/lib/cityReportResearch.mjs | 24 ++--- scripts/lib/portalTransparenciaEmendas.mjs | 89 ++++++++++++++----- tests/unit/cityReportBlocks.unit.spec.ts | 21 ++++- tests/unit/cityReportFormat.unit.spec.ts | 2 + tests/unit/cityReportResearch.unit.spec.ts | 16 ++++ .../portalTransparenciaEmendas.unit.spec.ts | 49 ++++++++-- 10 files changed, 181 insertions(+), 44 deletions(-) create mode 100644 docs/changelog/2026-09-15-c163-fix-relatorio-cidade.md diff --git a/docs/changelog/2026-09-15-c163-fix-relatorio-cidade.md b/docs/changelog/2026-09-15-c163-fix-relatorio-cidade.md new file mode 100644 index 00000000..456aadf1 --- /dev/null +++ b/docs/changelog/2026-09-15-c163-fix-relatorio-cidade.md @@ -0,0 +1 @@ +**FIX — C163 relatório de cidade: emendas por município, página 1 e datas (2026-09-15, Related #1025):** quatro defeitos encontrados na primeira geração real (Itamaraju), todos no caminho de leitura/render do relatório, nenhum toca a base. **(1) Emendas oficiais mentiam:** o endpoint `/api-de-dados/emendas` não tem filtro por município (swagger: `codigoEmenda, numeroEmenda, nomeAutor, tipoEmenda, ano, codigoFuncao, codigoSubfuncao, pagina`; `codigoMunicipio` é ignorado) e devolve valores como string pt-BR (`"81.000,00"`), que o `Number()` do módulo zerava. A extração real trouxe as **41 emendas 2023–2026 do autor** (localidade UF/Nacional/Múltiplo) com totais 0 — zero silencioso proibido. Fix: parse pt-BR, filtro por `localidadeDoGasto` casando com o `municipalityName` do snapshot (novo parâmetro do builder) e fail-closed → `status: 'gap'` com URL + motivo quando nenhuma linha é atribuível ao município. **(2) Página 1 estourava com pesquisa real:** a fonte por linha imprimia a URL completa em coluna de ~44mm e a guarda de overflow abortava até com respostas mínimas. Fix: fonte compacta na página 1 (`pesquisa web · data`; URL completa preservada em "Fontes e limites", seção 10) e a dica do KPI de emendas em gap deixou de repetir o motivo longo. **(3) Gap duplicado:** item do checklist declarado como gap pelo agente também virava "Não pesquisado."; agora o gap explícito marca o item como coberto. **(4) Data com um dia a menos:** `sourceDate` "AAAA-MM-DD" era formatado em America/Bahia a partir da meia-noite UTC (06/10 → 05/10); `formatDateBr` mantém a data de calendário para strings date-only. Regressões pino em `tests/unit/{portalTransparenciaEmendas,cityReportBlocks,cityReportResearch,cityReportFormat}.unit.spec.ts`. Verificação: unit 3108/3108, `tsc --noEmit` e eslint verdes. Issue #1025 registra a limitação da fonte e a decisão em aberto (fonte oficial por município × lacuna documentada); runbook/skill ainda prometem o filtro `codigoMunicipio` — atualizar quando a Issue fechar. diff --git a/scripts/build-city-report.mjs b/scripts/build-city-report.mjs index 146ae760..03f43685 100644 --- a/scripts/build-city-report.mjs +++ b/scripts/build-city-report.mjs @@ -116,6 +116,7 @@ const resolveEmendas = async () => { const result = await fetchAuthorEmendas({ years: EMENDAS_YEARS, municipalityCode: snapshot.municipality.ibgeCode, + municipalityName: snapshot.municipality.name, authorName, apiKey: process.env.PORTAL_TRANSPARENCIA_API_KEY ?? null, consultedAt: generatedAt.toISOString(), diff --git a/scripts/lib/cityReportBlocks.mjs b/scripts/lib/cityReportBlocks.mjs index 60d513a9..ea5c2003 100644 --- a/scripts/lib/cityReportBlocks.mjs +++ b/scripts/lib/cityReportBlocks.mjs @@ -63,12 +63,18 @@ const sourceOfficial = (label, url, date) => ({ date: date ?? null, }) +/** + * Página 1 = resumo de uma olhada: a fonte do item entra compacta (pesquisa web + * + data). A URL completa de cada item é preservada em "Fontes e limites" + * (seção 10) — imprimir a URL por linha estoura a página 1 com a pesquisa real + * (ver guarda de overflow do builder). + */ const researchAnswerItem = (research, id) => { const item = researchItemById(research, id) if (!item) return null return { text: item.answer, - source: sourceWeb('Pesquisa web', item.sourceUrl, item.sourceDate), + source: sourceWeb(null, null, item.sourceDate), } } @@ -173,7 +179,9 @@ const buildDeliveredKpis = ({ speeches }, research, emendas) => { value: emendasOk ? formatMoneyCompact(emendas.totals.empenhado) : 'Sem fonte oficial', hint: emendasOk ? `Pago: ${formatMoneyCompact(emendas.totals.pago)} · ${emendas.rows.length} emenda(s)` - : (emendas?.reason ?? null), + : emendas + ? 'sem emenda atribuível ao município' + : null, }, { label: 'Falas no acervo', @@ -223,7 +231,7 @@ const buildRiskCards = ({ leaderships, advisors, conjuncture }, research) => { { title: 'Oposição local', body: opposition - ? `${opposition.text} (fonte: ${opposition.source.url} · ${formatDateBr(opposition.source.date)})` + ? `${opposition.text} (fonte: pesquisa web · ${formatDateBr(opposition.source.date)}; URLs na seção 10)` : 'Sem leitura suficiente — lacuna', }, { diff --git a/scripts/lib/cityReportFormat.mjs b/scripts/lib/cityReportFormat.mjs index 8c8418fc..7b5facea 100644 --- a/scripts/lib/cityReportFormat.mjs +++ b/scripts/lib/cityReportFormat.mjs @@ -52,11 +52,17 @@ export const formatMoneyCompact = (value) => { return `R$ ${formatDecimal(millions)} mi` } +const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/ + export const formatDateBr = (value) => { if (!value) return '—' const date = new Date(value) if (Number.isNaN(date.getTime())) return '—' - return new Intl.DateTimeFormat('pt-BR', { timeZone: 'America/Bahia' }).format(date) + // Date-only strings ("2026-08-17") parse as UTC midnight; formatting them in + // America/Bahia would print the previous day. Keep the calendar date. + const timeZone = + typeof value === 'string' && DATE_ONLY_PATTERN.test(value) ? 'UTC' : 'America/Bahia' + return new Intl.DateTimeFormat('pt-BR', { timeZone }).format(date) } export const formatDateTimeBr = (value) => { diff --git a/scripts/lib/cityReportResearch.mjs b/scripts/lib/cityReportResearch.mjs index 1be2432e..5778971d 100644 --- a/scripts/lib/cityReportResearch.mjs +++ b/scripts/lib/cityReportResearch.mjs @@ -98,6 +98,20 @@ export const normalizeResearchInput = (raw, { now = new Date() } = {}) => { }) } + for (const entry of Array.isArray(raw.gaps) ? raw.gaps : []) { + if (isNonEmptyString(entry?.reason)) { + const id = isNonEmptyString(entry.id) ? entry.id : 'lacuna' + // A gap declared by the agent already accounts for the checklist item — + // otherwise the item would show up twice ("Não pesquisado." + the real gap). + if (checklistById.has(id)) seen.add(id) + gaps.push({ + id, + label: isNonEmptyString(entry.label) ? entry.label : null, + reason: entry.reason.trim(), + }) + } + } + for (const checklistItem of RESEARCH_CHECKLIST) { if (!seen.has(checklistItem.id)) { gaps.push({ id: checklistItem.id, label: checklistItem.label, reason: 'Não pesquisado.' }) @@ -133,16 +147,6 @@ export const normalizeResearchInput = (raw, { now = new Date() } = {}) => { }) } - for (const entry of Array.isArray(raw.gaps) ? raw.gaps : []) { - if (isNonEmptyString(entry?.reason)) { - gaps.push({ - id: isNonEmptyString(entry.id) ? entry.id : 'lacuna', - label: isNonEmptyString(entry.label) ? entry.label : null, - reason: entry.reason.trim(), - }) - } - } - return { municipalitySlug: raw.municipalitySlug.trim(), researchedAt: researchedAt.toISOString(), diff --git a/scripts/lib/portalTransparenciaEmendas.mjs b/scripts/lib/portalTransparenciaEmendas.mjs index 2ed8df1d..4f4f2895 100644 --- a/scripts/lib/portalTransparenciaEmendas.mjs +++ b/scripts/lib/portalTransparenciaEmendas.mjs @@ -22,8 +22,19 @@ const normalizeAuthorName = (value) => export const authorNameMatches = (candidate, expected) => normalizeAuthorName(candidate) === normalizeAuthorName(expected) -const number = (value) => { - const parsed = Number(value) +/** + * The Portal returns amounts as pt-BR strings ("81.000,00"); `Number()` alone + * would zero every row. Numbers stay accepted for replays/fixtures. + */ +const parseAmount = (value) => { + if (typeof value === 'number') return Number.isFinite(value) ? value : 0 + if (typeof value !== 'string') return 0 + const raw = value.trim() + if (raw === '') return 0 + const normalized = raw.includes(',') + ? raw.replace(/\./g, '').replace(',', '.') + : raw.replace(/\./g, '') + const parsed = Number(normalized) return Number.isFinite(parsed) ? parsed : 0 } @@ -35,10 +46,10 @@ export const normalizeEmendaRow = (row) => ({ authorCode: row?.codigoAutor !== undefined ? String(row.codigoAutor) : null, authorName: typeof row?.nomeAutor === 'string' ? row.nomeAutor : null, functionName: typeof row?.nomeFuncao === 'string' ? row.nomeFuncao : null, - empenhado: number(row?.valorEmpenhado), - liquidado: number(row?.valorLiquidado), - pago: number(row?.valorPago), - restoPago: number(row?.valorRestoPago), + empenhado: parseAmount(row?.valorEmpenhado), + liquidado: parseAmount(row?.valorLiquidado), + pago: parseAmount(row?.valorPago), + restoPago: parseAmount(row?.valorRestoPago), }) const rowMunicipalityCode = (row) => { @@ -46,13 +57,38 @@ const rowMunicipalityCode = (row) => { return candidate === undefined || candidate === null ? null : String(candidate) } +const normalizeText = (value) => + String(value ?? '') + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toUpperCase() + .replace(/\s+/g, ' ') + .trim() + +/** + * The `/emendas` endpoint has no municipality parameter (official swagger: + * codigoEmenda, numeroEmenda, nomeAutor, tipoEmenda, ano, codigoFuncao, + * codigoSubfuncao, pagina) and does not expose `codigoMunicipio` per row. The + * only per-city signal is `localidadeDoGasto` ("ITAMARAJU - BA"); rows at UF / + * "Nacional" / "MÚLTIPLO" level are not attributable to the município and are + * dropped (fail-closed) instead of inflating the city total. + */ +const localityMatchesMunicipality = (locality, municipalityName) => { + const normalizedLocality = normalizeText(locality) + const normalizedName = normalizeText(municipalityName) + if (!normalizedLocality || !normalizedName) return false + return ( + normalizedLocality === normalizedName || normalizedLocality.startsWith(`${normalizedName} -`) + ) +} + export const sumEmendas = (rows) => rows.reduce( (totals, row) => ({ - empenhado: totals.empenhado + number(row.empenhado), - liquidado: totals.liquidado + number(row.liquidado), - pago: totals.pago + number(row.pago), - restoPago: totals.restoPago + number(row.restoPago), + empenhado: totals.empenhado + parseAmount(row.empenhado), + liquidado: totals.liquidado + parseAmount(row.liquidado), + pago: totals.pago + parseAmount(row.pago), + restoPago: totals.restoPago + parseAmount(row.restoPago), }), { empenhado: 0, liquidado: 0, pago: 0, restoPago: 0 }, ) @@ -69,14 +105,7 @@ const gapResult = ({ reason, detail = null, sourceUrl, consultedAt, requestCount totals: sumEmendas([]), }) -const fetchAuthorYear = async ({ - fetchImpl, - apiKey, - authorName, - municipalityCode, - year, - maxPages, -}) => { +const fetchAuthorYear = async ({ fetchImpl, apiKey, authorName, year, maxPages }) => { const rows = [] let requests = 0 for (let page = 1; page <= maxPages; page += 1) { @@ -85,7 +114,6 @@ const fetchAuthorYear = async ({ nomeAutor: authorName, pagina: String(page), }) - if (municipalityCode) params.set('codigoMunicipio', String(municipalityCode)) const url = `${EMENDAS_API_URL}?${params.toString()}` requests += 1 const response = await fetchImpl(url, { @@ -138,6 +166,7 @@ const fetchAuthorYear = async ({ * @param {{ * years?: number[], * municipalityCode?: string | null, + * municipalityName?: string | null, * authorName?: string, * apiKey?: string | null, * fetchImpl?: (url: string, init?: { headers?: Record }) => Promise<{ ok: boolean, status: number, json: () => Promise }>, @@ -149,6 +178,7 @@ const fetchAuthorYear = async ({ export const fetchAuthorEmendas = async ({ years = EMENDAS_YEARS, municipalityCode = null, + municipalityName = null, authorName = DEFAULT_AUTHOR_NAME, apiKey = null, fetchImpl = fetch, @@ -174,7 +204,6 @@ export const fetchAuthorEmendas = async ({ fetchImpl, apiKey, authorName, - municipalityCode, year, maxPages, }) @@ -225,10 +254,13 @@ export const fetchAuthorEmendas = async ({ } } + const requestedMunicipality = municipalityCode !== null || municipalityName !== null const rows = matched .filter((row) => { const code = rowMunicipalityCode(row) - return code === null || municipalityCode === null || code === String(municipalityCode) + if (code !== null) return municipalityCode === null || code === String(municipalityCode) + if (!requestedMunicipality) return true + return localityMatchesMunicipality(row?.localidadeDoGasto, municipalityName) }) .map(normalizeEmendaRow) .sort( @@ -236,6 +268,21 @@ export const fetchAuthorEmendas = async ({ (right.year ?? 0) - (left.year ?? 0) || (left.code ?? '').localeCompare(right.code ?? ''), ) + if (rows.length === 0) { + return { + ...gapResult({ + reason: requestedMunicipality + ? `Sem emenda do autor com localidade ${municipalityName ?? municipalityCode} na janela.` + : 'Sem emenda do autor na janela consultada.', + detail: 'API não expõe o município da emenda.', + sourceUrl, + consultedAt, + requestCount, + years, + }), + } + } + return { status: 'ok', reason: null, diff --git a/tests/unit/cityReportBlocks.unit.spec.ts b/tests/unit/cityReportBlocks.unit.spec.ts index e10ce257..1777c5a6 100644 --- a/tests/unit/cityReportBlocks.unit.spec.ts +++ b/tests/unit/cityReportBlocks.unit.spec.ts @@ -218,8 +218,18 @@ const snapshot = { }, } -type ReportItem = { label?: string; text?: string; value?: string; hint?: string | null } -type ReportRow = { label: string; value: string; source?: { url?: string } } +type ReportItem = { + label?: string + text?: string + value?: string + hint?: string | null + url?: string | null +} +type ReportRow = { + label: string + value: string + source?: { url?: string | null; date?: string | null } +} type ReportBlock = { kind: string title?: string @@ -310,12 +320,15 @@ describe('buildCityReport', () => { expect(gaps.body!.join(' ')).toMatch(/Emendas/) }) - it('puts research answers in Quem é quem with their source', () => { + it('puts research answers in Quem é quem with compact sources and URLs in section 10', () => { const grid = report.page1.blocks.find((block) => block.kind === 'grid')! const who = grid.cells![1].blocks[0] const prefeito = who.rows!.find((row) => row.label === 'Prefeito(a)')! expect(prefeito.value).toBe('Resposta de prefeito') - expect(prefeito.source!.url).toBe('https://exemplo.test/prefeito') + expect(prefeito.source!.url).toBeNull() + expect(prefeito.source!.date).toBe('2026-09-10') + const fontes = report.sections.find((section) => section.id === 'fontes')!.blocks[0] + expect(fontes.items!.some((item) => item.url === 'https://exemplo.test/prefeito')).toBe(true) }) it('builds the region panorama from the committed artifact', () => { diff --git a/tests/unit/cityReportFormat.unit.spec.ts b/tests/unit/cityReportFormat.unit.spec.ts index 33088523..dd8c667d 100644 --- a/tests/unit/cityReportFormat.unit.spec.ts +++ b/tests/unit/cityReportFormat.unit.spec.ts @@ -31,6 +31,8 @@ describe('cityReportFormat', () => { it('formats dates in pt-BR', () => { expect(formatDateBr('2026-09-15T12:00:00.000Z')).toBe('15/09/2026') + expect(formatDateBr('2026-08-17')).toBe('17/08/2026') + expect(formatDateBr('2024-10-06')).toBe('06/10/2024') expect(formatDateBr(null)).toBe('—') }) diff --git a/tests/unit/cityReportResearch.unit.spec.ts b/tests/unit/cityReportResearch.unit.spec.ts index da323572..60a8170a 100644 --- a/tests/unit/cityReportResearch.unit.spec.ts +++ b/tests/unit/cityReportResearch.unit.spec.ts @@ -68,6 +68,22 @@ describe('normalizeResearchInput', () => { ) }) + it('treats an explicit gap as researched, without a duplicate "Não pesquisado"', () => { + const research = normalizeResearchInput( + baseResearch({ + items: RESEARCH_CHECKLIST_IDS.filter((id) => id !== 'emendas_web').map((id) => + validItem(id), + ), + gaps: [{ id: 'emendas_web', label: 'Emendas na web', reason: 'Fonte oficial respondeu.' }], + }), + { now }, + ) + const emendasGaps = research.gaps.filter((gap) => gap.id === 'emendas_web') + expect(emendasGaps).toHaveLength(1) + expect(emendasGaps[0].reason).toBe('Fonte oficial respondeu.') + expect(research.gaps.filter((gap) => gap.reason === 'Não pesquisado.')).toHaveLength(0) + }) + it('drops news outside the 90-day window and without source', () => { const research = normalizeResearchInput( baseResearch({ diff --git a/tests/unit/portalTransparenciaEmendas.unit.spec.ts b/tests/unit/portalTransparenciaEmendas.unit.spec.ts index 339b16ad..e74fbbd5 100644 --- a/tests/unit/portalTransparenciaEmendas.unit.spec.ts +++ b/tests/unit/portalTransparenciaEmendas.unit.spec.ts @@ -24,7 +24,7 @@ const row = (overrides: Record = {}) => ({ valorLiquidado: 50000, valorPago: 25000, valorRestoPago: 1000, - codigoMunicipio: '2930709', + localidadeDoGasto: 'Itamaraju - BA', ...overrides, }) @@ -47,6 +47,21 @@ describe('portalTransparenciaEmendas', () => { ) }) + it('parses the pt-BR amount strings returned by the Portal API', () => { + const normalized = normalizeEmendaRow( + row({ + valorEmpenhado: '81.000,00', + valorLiquidado: '0,00', + valorPago: '1.234,56', + valorRestoPago: '1.000.000,01', + }), + ) + expect(normalized.empenhado).toBe(81000) + expect(normalized.liquidado).toBe(0) + expect(normalized.pago).toBe(1234.56) + expect(normalized.restoPago).toBe(1000000.01) + }) + it('sums phases', () => { const totals = sumEmendas([ normalizeEmendaRow(row()), @@ -63,18 +78,23 @@ describe('portalTransparenciaEmendas', () => { expect(result.rows).toEqual([]) }) - it('fetches only the author exact-match and the municipality', async () => { + it('fetches the author exact-match and keeps only the município rows', async () => { const fetchImpl = vi.fn(async (url: string) => { expect(url).toContain('nomeAutor=JORGE+SOLLA') - expect(url).toContain('codigoMunicipio=2930709') + expect(url).not.toContain('codigoMunicipio') expect(url).toContain('ano=2023') if (url.includes('pagina=2')) return jsonResponse([]) - return jsonResponse([row(), row({ nomeAutor: 'JORGE SOLLA NETO', codigoAutor: '9999' })]) + return jsonResponse([ + row(), + row({ codigoEmenda: 'uf', localidadeDoGasto: 'BAHIA (UF)' }), + row({ nomeAutor: 'JORGE SOLLA NETO', codigoAutor: '9999' }), + ]) }) const later = vi.fn(async () => jsonResponse([])) const result = await fetchAuthorEmendas({ years: [2023, 2024], - municipalityCode: '2930709', + municipalityCode: '2915601', + municipalityName: 'Itamaraju', apiKey: 'chave', fetchImpl: (url: string) => (url.includes('ano=2023') ? fetchImpl(url) : later()), }) @@ -84,6 +104,25 @@ describe('portalTransparenciaEmendas', () => { expect(result.requestCount).toBe(3) }) + it('degrades to a gap when no author row carries the município locality', async () => { + const fetchImpl = vi.fn(async () => + jsonResponse([ + row({ localidadeDoGasto: 'BAHIA (UF)' }), + row({ localidadeDoGasto: 'Múltiplo' }), + ]), + ) + const result = await fetchAuthorEmendas({ + years: [2023], + municipalityCode: '2915601', + municipalityName: 'Itamaraju', + apiKey: 'chave', + fetchImpl, + }) + expect(result.status).toBe('gap') + expect(result.reason).toMatch(/localidade Itamaraju/) + expect(result.detail).toMatch(/não expõe o município/) + }) + it('degrades to a gap when two authors share the name', async () => { const fetchImpl = vi.fn(async () => jsonResponse([row(), row({ codigoEmenda: 'x', codigoAutor: '5678' })]),