14 KiB
Context
LerNotaFiscal persiste hoje uma data completa (purchase_date TEXT NOT NULL, ISO YYYY-MM-DD) em fiscal_documents, e purchase_date TEXT (nullable) em detected_documents (staging). O acesso a dados é sqlite3 puro sem ORM/migrations (app/database.py): o schema vive numa string SCHEMA rodada via executescript() em toda abertura de conexão, e o único precedente de evolução de schema é aditivo (ALTER TABLE ... ADD COLUMN, guardado por PRAGMA table_info, usado para categoria_id). Nunca houve remoção/rebuild de coluna neste projeto.
O campo de data hoje passa por: extração IA (app/ai_extraction.py, prompt pedindo "data_compra" completo) ou fallback OCR/regex (lernotafiscal/extraction.py, DATE_RE dd/mm/aaaa); normalização central em app/dates.py (resolve_purchase_date, com fallback "1º dia do mês corrente" quando ilegível); persistência/CRUD (create_fiscal, update_fiscal, confirm_batch, update_staged); agregação (monthly_totals via substr(purchase_date,1,7)); filtro/ordenação por intervalo (_fiscal_where, FISCAL_SORT_COLUMNS); e exibição (format_br_date, coluna "Data" em dashboard.html/documents_list.html, <input type="date"> em staging.html/document_form.html).
Esta mudança substitui esse conceito por competência (mês/ano), refletindo que o dia nunca foi de fato relevante para o negócio (o Dashboard já converte para mês na exibição, e o fallback já assume "mês corrente" quando a data é ilegível).
Goals / Non-Goals
Goals:
- Persistir
mes(1-12) eano(ex. 2026) como colunas nativas inteiras emfiscal_documents(obrigatórias) edetected_documents(opcionais, comopurchase_dateera), substituindopurchase_datepor completo. - Exigir confirmação explícita de "Competência: Mês/Ano" via dois
<select>(Mês: Jan-Dez; Ano: 2026-2030) na tela de revisão, antes de permitir confirmar o lote — mesmo em documentos onde a IA/OCR já sugeriu um valor. - Preservar a capacidade de filtrar/ordenar/agrupar por período no Dashboard e em
/documents, agora em granularidade de mês em vez de dia. - Migrar dados existentes (backfill
mes/anoa partir dopurchase_dateatual) sem perda de informação de competência.
Non-Goals:
- Não se propõe manter o dia da compra em lugar nenhum (nem como campo oculto/auditoria) — é descartado deliberadamente.
- Não se propõe suportar competências fora do intervalo 2026-2030 no seletor (ver Open Questions sobre extensibilidade do range de anos).
- Não se propõe alterar a lógica de categorização (
categorization.py) — confirmado que não usa data/competência. - Não se propõe migrar
lernotafiscal/db.py(módulo legado, não usado pelo fluxo atual além da constantePREVIEW_DIR).
Decisions
1. Duas colunas inteiras (mes INTEGER, ano INTEGER), não uma única coluna competencia TEXT (YYYY-MM).
Racional: o pedido explícito é "criar campos: mes e ano", e os <select> da UI (mês por nome, ano por valor) mapeiam naturalmente para dois valores inteiros independentes, evitando parsing de string em toda leitura. Comparações/ordenação também ficam mais simples com inteiros (ORDER BY ano, mes) do que com substr/LIKE em texto. Alternativa considerada: coluna única competencia TEXT no formato YYYY-MM (mais parecida com o substr(purchase_date,1,7) já usado hoje) — rejeitada por divergir do pedido explícito e por exigir parsing de string em toda query de filtro/ordenação por ano isolado.
2. IA e OCR continuam extraindo uma data completa do texto/imagem; o descarte do dia acontece na camada de normalização, não no prompt/regex.
Racional: documentos fiscais reais quase sempre exibem uma data completa (dd/mm/aaaa) — pedir ao modelo de IA para "raciocinar" apenas sobre mês/ano a partir do texto bruto é uma tarefa estritamente mais difícil e menos confiável do que extrair a data completa (already validado hoje por _is_plausible_purchase_date) e simplesmente descartar o dia depois. O mesmo vale para o regex DATE_RE (dd/mm/aaaa), que já funciona bem. Portanto: app/ai_extraction.py e lernotafiscal/extraction.py mantêm a extração de data completa como está; a função de normalização (app/dates.py) é que passa a devolver (mes, ano) em vez de uma string de data, descartando o componente de dia. Alternativa considerada: mudar o prompt da IA para pedir diretamente mes_compra/ano_compra — rejeitada por risco de o modelo confundir nomes de mês/abreviações ou é induzido a "inventar" quando o texto está truncado, perdendo a checagem de plausibilidade que hoje já opera sobre data completa.
3. app/dates.py ganha resolve_competencia(date_raw, reference=None) -> tuple[int, int], substituindo resolve_purchase_date.
Racional: mantém o mesmo ponto único de normalização/fallback já estabelecido (usado tanto no upload quanto no CRUD manual), apenas trocando o tipo de retorno. O fallback "1º dia do mês corrente" vira "mês/ano corrente" — mesma semântica, um passo mais simples (não precisa mais inventar um dia fictício). parse_date é ajustado para aceitar os formatos existentes (ISO, dd/mm/aaaa) e devolver (mes, ano); format_br_date é substituído por format_competencia(mes, ano) -> str (ex. "jul/2026"), reaproveitando a lista de abreviações de mês que já existe em _MESES (dashboard_routes.py) — essa lista é promovida para app/dates.py como fonte única, e dashboard_routes.py/_mes_label passam a importá-la de lá em vez de duplicar.
4. Migração de schema via rebuild de tabela (não ALTER TABLE ... DROP COLUMN), para não depender da versão do SQLite.
Racional: DROP COLUMN só existe a partir do SQLite 3.35.0 (2021); o projeto não fixa/verifica versão mínima de SQLite hoje, e usar rebuild (criar tabela nova com o schema final, copiar dados com INSERT INTO nova_tabela SELECT ... computando mes/ano a partir de purchase_date, DROP TABLE antiga, ALTER TABLE ... RENAME TO, recriar índices) é portável para qualquer versão e é a técnica recomendada pela própria documentação do SQLite para mudanças estruturais. A migração roda dentro de init_db(), guardada por uma checagem PRAGMA table_info (mesma convenção já usada para categoria_id): se purchase_date ainda existir em fiscal_documents/detected_documents, executa o rebuild uma única vez. Alternativa considerada: tentar ALTER TABLE ... DROP COLUMN purchase_date diretamente — rejeitada pelo risco de quebrar em instalações com SQLite mais antigo (ex. bibliotecas do sistema operacional legadas empacotadas com Python).
5. Índice idx_fiscal_date é substituído por idx_fiscal_competencia ON fiscal_documents(ano, mes).
Racional: mesma finalidade (acelerar filtro/ordenação por período), agora alinhado às colunas nativas; ordem (ano, mes) favorece tanto filtro por ano isolado quanto por intervalo completo de competência.
6. Filtro por intervalo (start/end) em /documents é substituído por filtro de competência usando a expressão (ano * 12 + mes) como chave comparável, sem nova coluna derivada.
Racional: preserva a capacidade de "listar de Jan/2026 até Jun/2026" com uma única expressão SQL indexável de forma equivalente (ano*12+mes BETWEEN ? AND ?), sem precisar manter uma terceira coluna redundante sincronizada com mes/ano. A UI de filtro em documents_list.html passa a ter dois pares de <select> Mês/Ano ("De" e "Até") em vez de dois <input type="date">. FISCAL_SORT_COLUMNS passa a expor ano/mes (ordenação primária por ano, mes) em vez de purchase_date. Alternativa considerada: manter apenas filtro por ano (sem intervalo) — rejeitada por reduzir uma funcionalidade hoje existente sem necessidade.
7. monthly_totals agrupa diretamente por ano, mes (SQL GROUP BY ano, mes ORDER BY ano, mes), eliminando o substr(purchase_date, 1, 7).
Racional: elimina dependência de formatação de string para agregação, mais robusto e mais rápido (usa o novo índice). O label exibido (_mes_label) passa a montar a partir das colunas nativas via format_competencia.
8. Formato de exibição segue exatamente o exemplo dado pelo usuário: mês abreviado capitalizado + ano com 2 dígitos (ex. "Jun/26", "Jul/26"), substituindo o formato hoje usado por _mes_label ("jul/2026", minúsculo/4 dígitos).
Racional: o pedido original especifica esse formato de forma explícita e com dois exemplos consistentes entre si, então é tratado como requisito e não como abreviação informal. format_competencia(mes, ano) -> str monta a string a partir da lista _MESES (capitalizada, ex. ["Jan","Fev","Mar",...]) e do ano truncado para 2 dígitos (ano % 100, formatado com zero à esquerda). _mes_label/dashboard.html passam a usar este novo formato, uma mudança visual em relação ao Dashboard atual.
Risks / Trade-offs
- [Risk] Rebuild de tabela em
init_db()roda em toda conexão até a checagemPRAGMA table_infoconfirmar que a migração já ocorreu; se o processo for interrompido no meio do rebuild (INSERT ... SELECT+DROP+RENAME), o banco pode ficar em estado inconsistente (tabela temporária órfã). → Mitigação: envolver os passos do rebuild em uma única transação (BEGIN/COMMIT), e usar nomes de tabela temporária previsíveis (fiscal_documents_new) verificados/limpos no início da rotina caso uma execução anterior tenha falhado a meio caminho. - [Risk] Perda de granularidade: filtros/relatórios que hoje poderiam (em teoria) restringir por dia específico deixam de ser possíveis. → Mitigação: aceito como consequência intencional da mudança (já sinalizado como BREAKING no proposal); nenhum uso atual do produto depende de granularidade diária (Dashboard e listagem já operam em nível de mês/intervalo).
- [Risk]
scripts/migrate_notas.pye testes (tests/test_app.py,tests/test_ingestion.py,tests/test_categorization.py) chamamcreate_fiscal/update_fiscal/resolve_purchase_datecompurchase_date=...diretamente; ficarão quebrados até serem atualizados. → Mitigação: listado explicitamente nas tasks; não há uso em produção desses scripts fora de execução manual, então não bloqueiam o deploy da aplicação principal, mas devem ser corrigidos antes de considerar a mudança completa. - [Trade-off] Descartar o dia na normalização (Decisão 2) em vez de mudar o prompt da IA mantém mais código de extração inalterado, mas significa que a "plausibilidade" (
_is_plausible_purchase_date) continua avaliando uma data completa que depois é truncada — a validação de mês/ano futuro/passado precisa ser revisada para operar em termos de mês/ano (ex. "mês/ano não pode ser mais que 3 meses no futuro" em vez de "3 dias").
Migration Plan
- Adicionar rebuild de
fiscal_documentsedetected_documentsemapp/database.py/init_db(): criar tabelas_newcommes INTEGER/ano INTEGER(NOT NULL emfiscal_documents, nullable emdetected_documents), copiar dados computandomes = CAST(substr(purchase_date,6,2) AS INTEGER),ano = CAST(substr(purchase_date,1,4) AS INTEGER), remover a tabela antiga e renomear; recriaridx_fiscal_competencia; tudo guardado porPRAGMA table_info(só roda sepurchase_dateainda existir) e dentro de uma transação. - Atualizar
app/dates.py(resolve_competencia,format_competencia, ajuste de_is_plausible_purchase_datepara mês/ano) e promover_MESESpara lá. - Atualizar
app/database.py:insert_detected,update_staged,confirm_batch,create_fiscal,update_fiscal,_fiscal_where,FISCAL_SORT_COLUMNS,monthly_totals,category_totalspara usarmes/ano. - Atualizar
app/ingestion.py(_candidate_to_raw) e dataclasses (RawExtraction,DetectedDocumentCandidate) apenas na nomeação/tipo de campo repassado aresolve_competencia, sem mudar a extração de data em si (Decisão 2). - Atualizar
app/routes/upload_routes.pyeapp/templates/staging.html: novos<select>Mês/Ano por linha, tornando a escolha de competência um passo obrigatório antes de habilitar a confirmação do lote. - Atualizar
app/routes/documents_routes.pyeapp/templates/document_form.html(mesmo padrão de<select>), eapp/templates/documents_list.html/dashboard.html(filtros e exibição por competência). - Atualizar
app/routes/dashboard_routes.py(monthly_totals,_mes_label→format_competencia). - Atualizar
scripts/migrate_notas.pye os testes afetados (tests/test_app.py,tests/test_ingestion.py,tests/test_categorization.py) para o novo schema/campos. - Rollback: como o rebuild remove
purchase_datepermanentemente, o rollback de código sem rollback de dados perde a competência de mês/ano recém-adotada; recomenda-se backup do arquivo SQLite antes do deploy desta mudança em produção, e reverter apenas em ambiente onde a perda de granularidade de dia (já ocorrida) é aceitável.
Open Questions
- O intervalo fixo de anos no seletor (2026-2030) deve ser estático no template ou calculado dinamicamente (ex. ano atual até ano atual + 4) para não exigir alteração de código todo ano? Assumido estático por ora, conforme literalmente pedido, com nota para revisão futura.
Resolved
- Escopo de "filtros em todas as rotas": confirmado com o usuário — significa apenas adaptar os filtros/agregações existentes (Dashboard por mês, listagem
/documentspor intervalo) para usar competência nativa. Nenhum filtro de período novo é adicionado ao Dashboard (que continua filtrando só por categoria). - Alterações não commitadas no working tree: usuário não utiliza controle de versão git neste projeto; desprezado como preocupação — não há necessidade de reconciliar/commitar nada antes da implementação.