Files
LerNota/openspec/changes/archive/2026-07-24-competencia/design.md
T

76 lines
14 KiB
Markdown

## 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) e `ano` (ex. 2026) como colunas nativas inteiras em `fiscal_documents` (obrigatórias) e `detected_documents` (opcionais, como `purchase_date` era), substituindo `purchase_date` por 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`/`ano` a partir do `purchase_date` atual) 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 constante `PREVIEW_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 checagem `PRAGMA table_info` confirmar 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.py` e testes (`tests/test_app.py`, `tests/test_ingestion.py`, `tests/test_categorization.py`) chamam `create_fiscal`/`update_fiscal`/`resolve_purchase_date` com `purchase_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
1. Adicionar rebuild de `fiscal_documents` e `detected_documents` em `app/database.py`/`init_db()`: criar tabelas `_new` com `mes INTEGER`/`ano INTEGER` (NOT NULL em `fiscal_documents`, nullable em `detected_documents`), copiar dados computando `mes = CAST(substr(purchase_date,6,2) AS INTEGER)`, `ano = CAST(substr(purchase_date,1,4) AS INTEGER)`, remover a tabela antiga e renomear; recriar `idx_fiscal_competencia`; tudo guardado por `PRAGMA table_info` (só roda se `purchase_date` ainda existir) e dentro de uma transação.
2. Atualizar `app/dates.py` (`resolve_competencia`, `format_competencia`, ajuste de `_is_plausible_purchase_date` para mês/ano) e promover `_MESES` para lá.
3. Atualizar `app/database.py`: `insert_detected`, `update_staged`, `confirm_batch`, `create_fiscal`, `update_fiscal`, `_fiscal_where`, `FISCAL_SORT_COLUMNS`, `monthly_totals`, `category_totals` para usar `mes`/`ano`.
4. Atualizar `app/ingestion.py` (`_candidate_to_raw`) e dataclasses (`RawExtraction`, `DetectedDocumentCandidate`) apenas na nomeação/tipo de campo repassado a `resolve_competencia`, sem mudar a extração de data em si (Decisão 2).
5. Atualizar `app/routes/upload_routes.py` e `app/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.
6. Atualizar `app/routes/documents_routes.py` e `app/templates/document_form.html` (mesmo padrão de `<select>`), e `app/templates/documents_list.html`/`dashboard.html` (filtros e exibição por competência).
7. Atualizar `app/routes/dashboard_routes.py` (`monthly_totals`, `_mes_label``format_competencia`).
8. Atualizar `scripts/migrate_notas.py` e os testes afetados (`tests/test_app.py`, `tests/test_ingestion.py`, `tests/test_categorization.py`) para o novo schema/campos.
9. Rollback: como o rebuild remove `purchase_date` permanentemente, 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 `/documents` por 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.