Commit inicial - upload de todos os arquivos da pasta

This commit is contained in:
2026-07-24 23:55:21 -03:00
commit 9d4d395881
88 changed files with 9484 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-24
+33
View File
@@ -0,0 +1,33 @@
## Context
`fiscal_documents` já possui a coluna `categoria_id` (FK nullable para `categoria`), preenchida hoje apenas no fluxo de importação em lote (`confirm_batch`, auto-categorização por palavra-chave). O cadastro manual (`/documents/new`) e as duas telas de listagem (Dashboard "Últimos documentos" e `/documents`) não expõem categoria. Não há ORM — o acesso a dados é feito com `sqlite3` puro em `app/database.py`. O padrão de select populado a partir de tabela já existe no filtro de categoria da Dashboard (`list_categorias`, ordenado por `id ASC`).
## Goals / Non-Goals
**Goals:**
- Permitir escolher a categoria ao criar um documento manualmente em `/documents/new`.
- Persistir essa escolha em `fiscal_documents.categoria_id`.
- Exibir a coluna "Categoria" (nome, não id) logo após "Fornecedor" nas tabelas da Dashboard e de `/documents`.
- Ordenar o select de categorias por nome (`categoria` ASC), não por `id`, para facilitar a localização pelo usuário.
**Non-Goals:**
- Não altera o fluxo de auto-categorização por palavra-chave já existente em `confirm_batch`.
- Não adiciona edição de categoria em documentos já existentes fora do formulário atual (sem tela de edição dedicada nesta mudança, a menos que já exista `update_fiscal` chamada por uma tela de edição — nesse caso o mesmo campo é reaproveitado).
- Não altera o schema do banco (coluna já existe).
## Decisions
1. **Ordenação do select por nome, não por id**: `list_categorias` atual faz `ORDER BY id ASC`. Para o novo select em `/documents/new` será usada uma consulta (nova função `list_categorias_ordenadas_por_nome` ou parâmetro de ordenação em `list_categorias`) com `ORDER BY categoria ASC`, conforme pedido explícito do usuário. Optamos por não alterar o `ORDER BY` do `list_categorias` existente (usado no filtro da Dashboard) para não mudar comportamento não solicitado; em vez disso adicionamos uma variante/parâmetro.
2. **Exposição do nome da categoria nas listagens via LEFT JOIN**: em vez de fazer uma segunda query por linha (N+1), a consulta usada por Dashboard e `/documents` (`list_fiscal` ou equivalente) passa a fazer `LEFT JOIN categoria ON fiscal_documents.categoria_id = categoria.id`, trazendo `categoria.categoria AS categoria_nome`. LEFT JOIN (não INNER) para não esconder documentos sem categoria.
3. **Campo opcional no formulário**: o `<select name="categoria_id">` inclui uma opção vazia/"Sem categoria" para não obrigar o usuário a categorizar manualmente, mantendo compatibilidade com documentos sem categoria.
4. **Reuso de `create_fiscal`/`update_fiscal`**: adicionar parâmetro opcional `categoria_id=None` a essas funções em vez de criar novas funções, minimizando duplicação.
## Risks / Trade-offs
- [Alterar `list_fiscal` para incluir JOIN pode impactar outros chamadores que dependem do shape atual do retorno (linha como tupla/Row)] → Mitigação: adicionar apenas colunas extras ao final do SELECT (não remover/reordenar colunas existentes) e verificar todos os call sites de `list_fiscal` antes de alterar.
- [Footer com colspan fixo em `documents_list.html` pode quebrar visualmente ao adicionar coluna] → Mitigação: ajustar o colspan do footer/total ao adicionar a nova `<th>`.
- [Usuário pode confundir "Sem categoria" com a categoria "Não Encontrado" (id=1) já existente] → Mitigação: usar valor vazio (NULL) para "Sem categoria" no select, distinto da categoria seedada "Não Encontrado".
## Migration Plan
Sem migração de dados necessária (coluna já existe). Deploy é apenas código: rotas, templates e função de listagem. Rollback trivial (reverter os arquivos alterados), pois nenhuma escrita de schema é feita.
+26
View File
@@ -0,0 +1,26 @@
## Why
Atualmente a categoria de um documento fiscal só é atribuída automaticamente durante a importação em lote (`confirm_batch`), com base em palavra-chave. Ao cadastrar um documento manualmente pela tela "Novo Documento" (`/documents/new`) não há como escolher a categoria, então o registro fica sempre com `categoria_id` nulo. Além disso, nem a Dashboard ("Últimos documentos") nem a listagem `/documents` exibem a categoria do lançamento, dificultando a conferência visual de como os documentos foram classificados.
## What Changes
- Adicionar campo select "Categoria" ao formulário de novo documento (`/documents/new`), populado a partir da tabela `categoria` ordenada por nome (ASC), permitindo escolher a categoria no cadastro manual.
- Persistir o `categoria_id` selecionado ao criar (e editar, quando aplicável) o documento fiscal, passando a informação para `create_fiscal`/`update_fiscal`.
- Incluir coluna "Categoria" na tabela "Últimos documentos" do Dashboard, posicionada logo após a coluna "Fornecedor".
- Incluir coluna "Categoria" na tabela de listagem da rota `/documents`, posicionada logo após a coluna "Fornecedor".
- Ajustar a consulta de listagem de documentos (`list_fiscal` ou variante) para trazer o nome da categoria via LEFT JOIN com a tabela `categoria`, já que a consulta atual não expõe esse dado.
## Capabilities
### New Capabilities
- `document-categorization`: Cobre a seleção de categoria no formulário de novo documento e a persistência do vínculo documento-categoria.
- `document-listing-category-column`: Cobre a exibição da coluna "Categoria" nas listagens de documentos (Dashboard "Últimos documentos" e rota `/documents`), logo após a coluna "Fornecedor".
### Modified Capabilities
(nenhuma — não há specs existentes em `openspec/specs/`; os itens acima são tratados como novas capacidades)
## Impact
- **Código afetado**: `app/routes/documents_routes.py` (rota `/documents/new` e `create_document`), `app/templates/document_form.html`, `app/database.py` (`create_fiscal`, `update_fiscal`, `list_fiscal`, uso de `list_categorias`), `app/routes/dashboard_routes.py`, `app/templates/dashboard.html`, `app/templates/documents_list.html`.
- **Banco de dados**: nenhuma alteração de schema — a coluna `fiscal_documents.categoria_id` já existe; apenas passa a ser preenchida também no fluxo manual.
- **Compatibilidade**: campo "Categoria" no formulário é opcional (documentos sem categoria continuam válidos, exibindo "Não Encontrado" ou vazio); sem impacto em dados existentes.
@@ -0,0 +1,19 @@
## ADDED Requirements
### Requirement: Seleção de categoria no cadastro de documento
O formulário de novo documento, na rota `/documents/new`, SHALL exibir um campo select "Categoria" populado com todos os registros da tabela `categoria`, ordenados por nome (`categoria`) em ordem ascendente (ASC).
#### Scenario: Formulário exibe categorias ordenadas por nome
- **WHEN** o usuário acessa a rota `/documents/new`
- **THEN** o select "Categoria" é exibido com as opções carregadas da tabela `categoria`, ordenadas alfabeticamente (ASC) pelo campo `categoria`
#### Scenario: Campo categoria é opcional
- **WHEN** o usuário envia o formulário de novo documento sem selecionar nenhuma categoria
- **THEN** o documento é criado com sucesso e `categoria_id` fica nulo (sem categoria)
### Requirement: Persistência da categoria selecionada
Ao submeter o formulário de novo documento com uma categoria selecionada, o sistema SHALL salvar o `id` da categoria escolhida no campo `categoria_id` do documento fiscal criado.
#### Scenario: Categoria selecionada é persistida
- **WHEN** o usuário seleciona uma categoria no formulário e submete o novo documento
- **THEN** o registro criado em `fiscal_documents` possui `categoria_id` igual ao id da categoria selecionada
@@ -0,0 +1,23 @@
## ADDED Requirements
### Requirement: Coluna Categoria na Dashboard
A tabela "Últimos documentos" da Dashboard SHALL exibir uma coluna "Categoria" posicionada imediatamente após a coluna "Fornecedor", mostrando o nome da categoria do documento (ou vazio/"Não Encontrado" quando não houver categoria associada).
#### Scenario: Coluna Categoria aparece após Fornecedor na Dashboard
- **WHEN** o usuário acessa a Dashboard
- **THEN** a tabela "Últimos documentos" exibe as colunas na ordem: Data, Fornecedor, Categoria, Valor (demais colunas mantidas), com "Categoria" logo após "Fornecedor"
#### Scenario: Documento sem categoria exibido corretamente
- **WHEN** um documento listado na Dashboard possui `categoria_id` nulo
- **THEN** a célula da coluna "Categoria" é exibida vazia ou com um indicador de "sem categoria", sem gerar erro
### Requirement: Coluna Categoria na listagem de documentos
A tabela de listagem da rota `/documents` SHALL exibir uma coluna "Categoria" posicionada imediatamente após a coluna "Fornecedor", mostrando o nome da categoria do documento.
#### Scenario: Coluna Categoria aparece após Fornecedor em /documents
- **WHEN** o usuário acessa a rota `/documents`
- **THEN** a tabela de documentos exibe as colunas na ordem: Data, Fornecedor, Categoria, Valor, Origem, Ações (demais colunas mantidas), com "Categoria" logo após "Fornecedor"
#### Scenario: Documento sem categoria exibido corretamente em /documents
- **WHEN** um documento listado em `/documents` possui `categoria_id` nulo
- **THEN** a célula da coluna "Categoria" é exibida vazia ou com um indicador de "sem categoria", sem gerar erro
+34
View File
@@ -0,0 +1,34 @@
## 1. Camada de dados (app/database.py)
- [x] 1.1 Adicionar função (ou parâmetro) para listar categorias ordenadas por nome ASC (`ORDER BY categoria ASC`), reutilizando `list_categorias` como referência sem alterar seu comportamento atual.
- [x] 1.2 Adicionar parâmetro opcional `categoria_id=None` em `create_fiscal` e persistir o valor no INSERT de `fiscal_documents`.
- [x] 1.3 Adicionar parâmetro opcional `categoria_id` em `update_fiscal` (se existir fluxo de edição), persistindo no UPDATE.
- [x] 1.4 Levantar todos os call sites de `list_fiscal` (ou função equivalente usada por Dashboard e `/documents`) e confirmar o shape de retorno atual antes de alterar.
- [x] 1.5 Alterar a consulta usada pela listagem (`list_fiscal` ou variante) para incluir `LEFT JOIN categoria ON fiscal_documents.categoria_id = categoria.id`, adicionando `categoria.categoria AS categoria_nome` ao final do SELECT sem remover/reordenar colunas existentes.
## 2. Formulário de novo documento (/documents/new)
- [x] 2.1 Em `app/routes/documents_routes.py`, na rota GET de `/documents/new`, buscar a lista de categorias ordenadas por nome (via 1.1) e passar ao template.
- [x] 2.2 Em `app/templates/document_form.html`, adicionar `<select name="categoria_id">` com opção vazia "Sem categoria" seguida das opções carregadas, próximo aos demais campos do formulário.
- [x] 2.3 Na rota POST `create_document`, ler `categoria_id` do form (tratando string vazia como `None`) e repassar para `create_fiscal`.
- [x] 2.4 Testar manualmente: criar documento sem categoria e criar documento com categoria selecionada; confirmar persistência via consulta ao banco.
## 3. Coluna Categoria na Dashboard
- [x] 3.1 Em `app/routes/dashboard_routes.py`, confirmar que os dados de "Últimos documentos" já incluem `categoria_nome` após a alteração da query (item 1.5); ajustar se necessário.
- [x] 3.2 Em `app/templates/dashboard.html`, adicionar `<th>Categoria</th>` logo após `<th>Fornecedor</th>` no cabeçalho da tabela "Últimos documentos".
- [x] 3.3 Adicionar a célula correspondente (`{{ item.categoria_nome or '' }}` ou equivalente) na mesma posição nas linhas da tabela.
- [x] 3.4 Validar visualmente que a coluna aparece corretamente para documentos com e sem categoria, e que o restante do layout não quebra.
## 4. Coluna Categoria na listagem /documents
- [x] 4.1 Confirmar que a rota `/documents` (`list_documents`) já recebe `categoria_nome` via a query alterada (item 1.5).
- [x] 4.2 Em `app/templates/documents_list.html`, adicionar `<th>Categoria</th>` logo após `<th>Fornecedor</th>` no cabeçalho.
- [x] 4.3 Adicionar a célula correspondente na mesma posição nas linhas da tabela.
- [x] 4.4 Ajustar o `colspan` do footer/linha de total, se existir, para refletir a coluna adicional.
- [x] 4.5 Validar visualmente a listagem completa, incluindo documentos com e sem categoria.
## 5. Verificação final
- [x] 5.1 Rodar a aplicação localmente e testar o fluxo completo: cadastrar documento com categoria em `/documents/new`, confirmar exibição correta em `/documents` e na Dashboard.
- [x] 5.2 Rodar a suíte de testes existente (`tests/`) e corrigir eventuais quebras relacionadas às funções alteradas em `database.py`.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-25
@@ -0,0 +1,75 @@
## 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.
@@ -0,0 +1,36 @@
## Why
Hoje o LerNotaFiscal extrai e persiste uma data completa (`purchase_date`, `YYYY-MM-DD`) do documento fiscal via OCR/IA, mas o valor real de negócio para o usuário é apenas o **mês de referência do gasto** (competência), não o dia exato. A extração de dia é frequentemente a fonte de baixa confiança/ilegibilidade no OCR (regex `DATE_RE` e checagem `_is_plausible_purchase_date`), o dashboard já converte a data para "mês/ano" para exibição (`_mes_label`, `substr(purchase_date, 1, 7)`), e o formulário manual já reforça a convenção "1º dia do mês corrente" como fallback. Trocar o campo de data por competência (mês/ano) simplifica a extração, elimina uma fonte de erro/ilegibilidade desnecessária e alinha o dado armazenado com o que o Dashboard e os relatórios realmente precisam.
## What Changes
- **BREAKING**: Remover a coluna `purchase_date` (data completa) de `fiscal_documents` e `detected_documents`, substituindo por dois campos: `mes` (inteiro 1-12) e `ano` (inteiro, ex. 2026).
- Atualizar a extração via IA (`app/ai_extraction.py`) e o fallback OCR (`lernotafiscal/extraction.py`) para não mais extrair dia — apenas identificar mês/ano do documento quando possível (ex. a partir da mesma data encontrada no texto, descartando o dia).
- Adicionar uma etapa de confirmação obrigatória antes do botão "Enviar/Revisar": perguntar explicitamente a **Competência: Mês/Ano** de cada documento na tela de revisão (`staging.html`), com dois campos `<select>` — Mês (Jan, Fev, Mar, Abr, Mai, Jun, Jul, Ago, Set, Out, Nov, Dez) e Ano (2026, 2027, 2028, 2029, 2030) — pré-selecionados com o valor detectado quando disponível, permitindo correção manual antes de confirmar o lote.
- Aplicar o mesmo par de selects (Mês/Ano) no formulário manual de documento (`document_form.html`), substituindo o `<input type="date">` atual.
- Atualizar `confirm_batch`/`create_fiscal`/`update_fiscal` para gravar `mes`/`ano` em vez de `purchase_date`, com `mes` e `ano` obrigatórios (mesma garantia de não-nulo que `purchase_date` tinha).
- Atualizar todas as agregações e filtros que hoje dependem de `purchase_date`:
- `monthly_totals` (Dashboard: "Gastos por mês") passa a agrupar diretamente por `ano`/`mes` em vez de `substr(purchase_date, 1, 7)`.
- `category_totals`, `_fiscal_where`, `FISCAL_SORT_COLUMNS` e a listagem `/documents` (`documents_list.html`) passam a filtrar/ordenar por `ano`/`mes` em vez de intervalo de datas (`start`/`end` do tipo date).
- Exibição de "Competência" (ex. `Jun/2026`) substitui a coluna/célula "Data" no Dashboard e em `/documents`.
- Atualizar scripts e testes que hoje gravam/leem `purchase_date` diretamente (`scripts/migrate_notas.py`, `tests/test_app.py`, `tests/test_ingestion.py`, `tests/test_categorization.py`) para o novo par de campos.
## Capabilities
### New Capabilities
- `document-competencia`: Cobre a captura, revisão, persistência, exibição e filtragem da competência (mês/ano) de um documento fiscal, substituindo o conceito de data completa de compra em todo o fluxo (upload → revisão → confirmação → dashboard → listagem/filtros).
### Modified Capabilities
(nenhuma — não há specs existentes em `openspec/specs/`; o item acima é tratado como nova capability, que substitui integralmente o comportamento de data hoje implícito no código)
## Impact
- **Banco de dados** (`app/database.py`): remoção de `purchase_date` (e do índice `idx_fiscal_date`) de `fiscal_documents` e `detected_documents`; novas colunas `mes INTEGER NOT NULL` e `ano INTEGER NOT NULL` (com índice equivalente `idx_fiscal_competencia` em `(ano, mes)`); rebuild de tabela (SQLite não remove coluna com `ALTER TABLE` em todas as versões-alvo) com backfill dos dados existentes a partir do `purchase_date` atual antes de descartá-lo.
- **Extração** (`app/ai_extraction.py`, `lernotafiscal/extraction.py`, `app/ingestion.py`): prompt de IA e regex OCR passam a reportar mês/ano em vez de dia completo; `RawExtraction`/`DetectedDocumentCandidate` trocam `purchase_date_raw`/`purchase_date` por `mes_raw`/`ano_raw`.
- **Normalização** (`app/dates.py`): `resolve_purchase_date`/`parse_date`/`format_br_date` são substituídos ou adaptados para resolver/validar/formatar competência (`mes`/`ano` → "Jun/2026"), reaproveitando a lógica de fallback "mês corrente" já existente.
- **Rotas e templates de upload/revisão** (`app/routes/upload_routes.py`, `app/templates/staging.html`): novo par de `<select>` Mês/Ano por linha, com pergunta explícita de competência antes de habilitar "Enviar"/confirmar lote.
- **Rotas e templates de CRUD manual** (`app/routes/documents_routes.py`, `app/templates/document_form.html`): mesmo par de `<select>` Mês/Ano substituindo `<input type="date">`.
- **Dashboard** (`app/routes/dashboard_routes.py`, `app/templates/dashboard.html`): `monthly_totals` e exibição de "Últimos documentos" passam a usar `ano`/`mes` nativos; `_mes_label` deixa de derivar de string e passa a formatar diretamente a partir das colunas.
- **Listagem/filtros** (`app/routes/documents_routes.py`, `app/templates/documents_list.html`): filtros `start`/`end` (datas) são substituídos por filtro de competência (mês/ano ou intervalo de competência); ordenação por `purchase_date` substituída por ordenação por `(ano, mes)`.
- **Scripts e testes**: `scripts/migrate_notas.py`, `tests/test_app.py`, `tests/test_ingestion.py`, `tests/test_categorization.py` precisam ser atualizados para o novo schema/campos.
- **Compatibilidade**: mudança é destrutiva para dados existentes no formato antigo — exige migração/backfill de `purchase_date` para `mes`/`ano` antes da remoção da coluna; documentos sem data reconhecível hoje (fallback "1º dia do mês corrente") migram diretamente para o mês/ano correspondente.
@@ -0,0 +1,81 @@
## ADDED Requirements
### Requirement: Persistência de Competência (Mês/Ano)
O sistema SHALL persistir a competência de um documento fiscal como dois campos inteiros, `mes` (1-12) e `ano` (ex. 2026), em vez de uma data completa de compra. Em `fiscal_documents`, `mes` e `ano` SHALL ser obrigatórios (não nulos). Em `detected_documents` (staging), `mes` e `ano` SHALL permanecer opcionais enquanto o documento não tiver sido revisado/confirmado.
#### Scenario: Documento confirmado exige competência
- **WHEN** um documento em staging é promovido para `fiscal_documents` via confirmação de lote
- **THEN** o registro criado possui `mes` e `ano` preenchidos com valores válidos (mes entre 1 e 12, ano numérico)
#### Scenario: Documento em staging pode estar sem competência
- **WHEN** um documento acabou de ser detectado por OCR/IA e ainda não foi revisado
- **THEN** `mes` e/ou `ano` podem estar nulos em `detected_documents` até que o usuário revise e preencha a competência
### Requirement: Extração Não Persiste Mais Data Completa
O sistema SHALL deixar de persistir o dia da compra em qualquer tabela. A extração via IA ou OCR pode continuar identificando uma data completa no texto/imagem do documento, mas o dia SHALL ser descartado antes da persistência, mantendo-se apenas mês e ano.
#### Scenario: Data completa detectada no documento
- **WHEN** a extração (IA ou OCR) identifica uma data completa (dia/mês/ano) no documento
- **THEN** o sistema armazena apenas o mês e o ano correspondentes, descartando o dia
### Requirement: Confirmação Obrigatória de Competência Antes do Envio
Na tela de revisão pós-upload, o sistema SHALL exigir que o usuário confirme explicitamente a Competência (Mês/Ano) de cada documento, através de dois campos de seleção — Mês (Jan a Dez) e Ano (2026 a 2030) — antes de permitir a confirmação do lote.
#### Scenario: Competência pré-selecionada a partir da extração
- **WHEN** a extração identificou um mês/ano válido para o documento
- **THEN** os seletores de Mês e Ano na tela de revisão vêm pré-selecionados com esses valores, permitindo correção manual pelo usuário
#### Scenario: Competência ausente bloqueia confirmação do lote
- **WHEN** um ou mais documentos do lote não têm Mês e Ano selecionados
- **THEN** o sistema não permite confirmar o lote até que a competência de todos os documentos pendentes seja preenchida
### Requirement: Seleção de Competência no Cadastro Manual
O formulário manual de criação/edição de documento fiscal (`document_form.html`) SHALL substituir o campo de data por dois seletores — Mês (Jan a Dez) e Ano (2026 a 2030) — para definir a competência do documento.
#### Scenario: Criação manual de documento
- **WHEN** o usuário cria um novo documento fiscal manualmente
- **THEN** o formulário exige a seleção de Mês e Ano em vez de uma data completa
#### Scenario: Edição de documento existente
- **WHEN** o usuário edita um documento fiscal existente
- **THEN** os seletores de Mês e Ano vêm pré-preenchidos com a competência atual do documento, podendo ser alterados
### Requirement: Exibição de Competência no Dashboard e Listagens
O Dashboard e a listagem de documentos (`/documents`) SHALL exibir a competência de cada documento no formato "Mês abreviado/Ano com 2 dígitos" (ex. "Jun/26", "Jul/26") no lugar da antiga coluna "Data".
#### Scenario: Tabela "Últimos documentos" no Dashboard
- **WHEN** o usuário visualiza o Dashboard
- **THEN** cada linha da tabela "Últimos documentos" exibe a competência (mês/ano) do documento, em vez da data completa
#### Scenario: Coluna na listagem de documentos
- **WHEN** o usuário visualiza a listagem `/documents`
- **THEN** a coluna antes chamada "Data" exibe a competência (mês/ano) de cada documento, e continua ordenável
### Requirement: Filtro por Competência na Listagem de Documentos
A listagem `/documents` SHALL permitir filtrar documentos por intervalo de competência (Mês/Ano inicial e Mês/Ano final), substituindo o filtro anterior por intervalo de datas.
#### Scenario: Filtro por intervalo de competência
- **WHEN** o usuário seleciona uma competência inicial (ex. Jan/2026) e uma competência final (ex. Jun/2026) nos filtros da listagem
- **THEN** apenas documentos cuja competência esteja dentro desse intervalo (inclusive) são exibidos
#### Scenario: Ordenação por competência
- **WHEN** o usuário ordena a listagem pela coluna de competência
- **THEN** os documentos são ordenados por ano e mês (crescente ou decrescente conforme selecionado)
### Requirement: Agregação Mensal do Dashboard por Competência Nativa
O gráfico "Gastos por mês" do Dashboard SHALL agrupar os totais diretamente pelas colunas `ano`/`mes` de `fiscal_documents`, em vez de derivar o mês a partir de uma string de data.
#### Scenario: Totais mensais agrupados por competência
- **WHEN** o Dashboard calcula os totais mensais para exibição
- **THEN** o agrupamento é feito pelas colunas `ano` e `mes`, e o rótulo exibido (ex. "Jun/26") é formatado a partir desses valores nativos
### Requirement: Migração de Dados Existentes para Competência
Ao atualizar para esta mudança, o sistema SHALL migrar automaticamente todos os registros existentes de `fiscal_documents` e `detected_documents`, preenchendo `mes` e `ano` a partir da data de compra anteriormente armazenada, antes de remover o campo de data.
#### Scenario: Migração automática na inicialização
- **WHEN** a aplicação inicializa contra um banco de dados que ainda contém o campo de data completa
- **THEN** o sistema preenche `mes` e `ano` de cada registro a partir da data existente e remove o campo de data, sem exigir intervenção manual do usuário
#### Scenario: Migração já aplicada não é repetida
- **WHEN** a aplicação inicializa contra um banco de dados que já foi migrado (campo de data já removido)
- **THEN** o sistema não tenta migrar novamente e opera normalmente com `mes`/`ano`
@@ -0,0 +1,65 @@
## 1. Migração de Schema (Banco de Dados)
- [x] 1.1 Em `app/database.py`, implementar rebuild de `fiscal_documents` (tabela `_new` com `mes INTEGER NOT NULL`, `ano INTEGER NOT NULL`, demais colunas iguais, sem `purchase_date`), copiando dados existentes com `mes = CAST(substr(purchase_date,6,2) AS INTEGER)` e `ano = CAST(substr(purchase_date,1,4) AS INTEGER)`, dentro de uma transação, guardado por checagem `PRAGMA table_info` (só roda se `purchase_date` ainda existir).
- [x] 1.2 Repetir o mesmo rebuild para `detected_documents`, com `mes INTEGER` e `ano INTEGER` nullable (mantendo o mesmo comportamento opcional que `purchase_date` tinha).
- [x] 1.3 Substituir o índice `idx_fiscal_date` por `idx_fiscal_competencia ON fiscal_documents(ano, mes)`.
- [x] 1.4 Testar a migração rodando `init_db()` contra uma cópia do banco atual (com dados de `purchase_date` já existentes) e validar que `mes`/`ano` foram preenchidos corretamente e que rodar `init_db()` de novo não falha nem duplica/recria o rebuild.
## 2. Normalização de Competência (`app/dates.py`)
- [x] 2.1 Promover a lista `_MESES` (hoje em `dashboard_routes.py`) para `app/dates.py` como fonte única de abreviações de mês.
- [x] 2.2 Implementar `resolve_competencia(date_raw, reference=None) -> tuple[int, int]`, substituindo `resolve_purchase_date`, com fallback para mês/ano corrente quando o valor for inválido/ausente.
- [x] 2.3 Implementar `format_competencia(mes: int, ano: int) -> str` no formato `"Jun/26"` (mês abreviado capitalizado + ano com 2 dígitos), substituindo `format_br_date`, e atualizar o filtro Jinja registrado em `app/templating.py` (`brdate` → filtro de competência).
- [x] 2.4 Ajustar `_is_plausible_purchase_date` (ou equivalente) para validar plausibilidade em termos de mês/ano (ex. não mais que alguns meses no futuro, ano não muito no passado), em vez de dias.
## 3. Extração (IA e OCR)
- [x] 3.1 Confirmar que `app/ai_extraction.py` e `lernotafiscal/extraction.py` continuam extraindo a data completa do documento sem alteração de prompt/regex (Decisão de design: descarte do dia acontece na normalização, não na extração).
- [x] 3.2 Renomear/ajustar `RawExtraction.purchase_date_raw` e `DetectedDocumentCandidate.purchase_date` apenas na camada de repasse (`app/ingestion.py:_candidate_to_raw`), garantindo que o valor bruto siga até `resolve_competencia` sem mudança de comportamento de extração.
## 4. Persistência e Consultas (`app/database.py`)
- [x] 4.1 Atualizar `insert_detected` e `update_staged` para gravar `mes`/`ano` (via `resolve_competencia`) em vez de `purchase_date`.
- [x] 4.2 Atualizar `confirm_batch` para exigir `mes`/`ano` válidos (em vez de `purchase_date` truthy) antes de promover um documento de staging para `fiscal_documents`.
- [x] 4.3 Atualizar `create_fiscal` e `update_fiscal` para receber `mes`/`ano` como parâmetros obrigatórios em vez de `purchase_date`.
- [x] 4.4 Atualizar `_fiscal_where` para filtrar por intervalo de competência usando a expressão `(ano * 12 + mes) BETWEEN ? AND ?`, substituindo o filtro por `purchase_date >= / <=`.
- [x] 4.5 Atualizar `FISCAL_SORT_COLUMNS` para expor ordenação por `(ano, mes)` no lugar de `purchase_date`.
- [x] 4.6 Atualizar `monthly_totals` para agrupar diretamente por `ano, mes` (`GROUP BY ano, mes ORDER BY ano, mes`), eliminando `substr(purchase_date, 1, 7)`.
- [x] 4.7 Atualizar `category_totals` para filtrar por competência usando a mesma expressão `(ano * 12 + mes)`.
## 5. Fluxo de Upload e Revisão (Staging)
- [x] 5.1 Em `app/routes/upload_routes.py`, atualizar o handler de upload (`POST /upload`) para gravar `mes`/`ano` (via `resolve_competencia`) em vez de `purchase_date` ao inserir em `detected_documents`.
- [x] 5.2 Atualizar `app/templates/staging.html`: substituir o `<input type="date" name="purchase_date">` por dois `<select>` — Mês (Jan-Dez) e Ano (2026-2030) — pré-selecionados com o valor detectado quando disponível.
- [x] 5.3 Atualizar o handler `POST /import/{batch_id}/update/{detected_id}` para receber e persistir `mes`/`ano` do formulário de revisão.
- [x] 5.4 Garantir que o cálculo de `summary["pendentes"]` (usado para bloquear o botão de confirmação do lote) passe a considerar `mes`/`ano` ausentes/inválidos como pendência, em vez de `purchase_date`.
## 6. Cadastro Manual de Documento
- [x] 6.1 Atualizar `app/templates/document_form.html`: substituir o `<input type="date" name="purchase_date">` pelos mesmos dois `<select>` de Mês/Ano usados na revisão, pré-preenchidos na edição.
- [x] 6.2 Atualizar os handlers de criação/edição em `app/routes/documents_routes.py` (`/documents/new`, `/documents/{id}/edit`) para ler `mes`/`ano` do formulário e repassar a `create_fiscal`/`update_fiscal`.
## 7. Listagem e Filtros (`/documents`)
- [x] 7.1 Atualizar `app/templates/documents_list.html`: substituir os filtros `<input type="date" name="start">`/`name="end"` por dois pares de `<select>` Mês/Ano ("De" e "Até").
- [x] 7.2 Atualizar o handler `GET /documents` em `app/routes/documents_routes.py` para ler os novos parâmetros de filtro de competência e repassá-los a `list_fiscal`/`fiscal_summary`.
- [x] 7.3 Atualizar a coluna "Data" em `documents_list.html` para exibir a competência formatada (`format_competencia`) e manter a ordenação por competência (`sort_url` apontando para `ano`/`mes`).
## 8. Dashboard
- [x] 8.1 Atualizar `app/routes/dashboard_routes.py`: `_mes_label` passa a usar `format_competencia`/`_MESES` centralizados em `app/dates.py`, consumindo `ano`/`mes` nativos vindos de `monthly_totals`.
- [x] 8.2 Atualizar `app/templates/dashboard.html`: tabela "Últimos documentos" exibe a competência (`format_competencia`) no lugar de `d.purchase_date | brdate`.
## 9. Scripts e Testes
- [x] 9.1 Atualizar `scripts/migrate_notas.py` para gravar `mes`/`ano` em vez de `purchase_date` ao chamar `create_fiscal`.
- [x] 9.2 Atualizar `tests/test_app.py` (usos de `resolve_purchase_date`, `create_fiscal`, `update_fiscal` com `purchase_date=...`) para o novo par de campos.
- [x] 9.3 Atualizar `tests/test_ingestion.py` (`docs[0].purchase_date`) para verificar `mes`/`ano` em vez de data completa.
- [x] 9.4 Atualizar `tests/test_categorization.py` (`db.create_fiscal(..., purchase_date=...)`) para o novo schema.
- [x] 9.5 Rodar a suíte de testes completa e corrigir quaisquer quebras remanescentes relacionadas a `purchase_date`.
## 10. Validação Manual
- [x] 10.1 Rodar a aplicação localmente, fazer upload de um documento de teste, confirmar que a tela de revisão exige seleção de Mês/Ano antes de habilitar a confirmação do lote, e validar que o documento aparece corretamente no Dashboard e em `/documents` após confirmado.
- [x] 10.2 Validar cadastro manual (criar/editar) de um documento fiscal usando os novos seletores de Mês/Ano.
- [x] 10.3 Validar o filtro por intervalo de competência em `/documents` e a ordenação pela coluna de competência.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-24
+81
View File
@@ -0,0 +1,81 @@
## Context
LerNotaFiscal is a FastAPI app that ingests Brazilian invoices (PDF/image), extracts `supplier_name`, `purchase_date`, `total_paid` via `extract_with_ai` (`app/ai_extraction.py`, OpenAI `chat.completions.create` with vision, JSON mode), with an OCR/heuristic fallback in `ingestion.py` when AI is disabled or fails. Persistence is raw `sqlite3` (`app/database.py`): no ORM, no migrations framework — the `SCHEMA` string is executed via `executescript()` on every connection open, so new tables/columns must be added additively (`CREATE TABLE IF NOT EXISTS`, `ALTER TABLE ... ADD COLUMN` guarded by a `PRAGMA table_info` check, since SQLite's `ADD COLUMN IF NOT EXISTS` isn't available in older syntax). The Dashboard (`app/routes/dashboard_routes.py` + `app/templates/dashboard.html`) currently shows KPIs, a monthly bar chart, and a top-8 supplier bar chart, with no filter controls; filtering by supplier/date exists only on the documents list page (`documents_routes.py`, via `list_fiscal(conn, start, end, supplier)`). There is currently no rate-limiting, retry, or caching layer anywhere in the codebase — AI safety today is a bare `try/except` around the extraction call.
This change adds category classification to fiscal documents, with AI as the primary classifier and a user-maintained keyword table as deterministic fallback, plus safety limits so the new AI call path cannot loop or blow up token spend.
## Goals / Non-Goals
**Goals:**
- Persist a category per fiscal document, derived automatically (AI first, keyword fallback second, reserved "Não Encontrado" category otherwise — never left unclassified for documents that go through the flow).
- Let the categorization result be visible and filterable on the Dashboard.
- Guarantee the AI categorization call can run at most once per document per ingestion, with a supplier-level cache and a per-batch call ceiling, so it can never loop or run away on cost.
- Keep the `categoria` table simple and editable (id, categoria, palavra_chave) so non-technical users can extend keyword matching without code changes.
**Non-Goals:**
- No multi-category-per-document (one category per fiscal document in this change).
- No retraining/fine-tuning of the AI model; categorization uses the existing OpenAI client with a prompt, not a separate ML pipeline.
- No UI for bulk re-categorization of historical documents (existing documents predating this change simply show "Sem categoria" (`categoria_id` still `NULL`) until reprocessed; a manual "re-run categorization" action is out of scope unless trivial to add in tasks).
- No category hierarchy/subcategories.
## Decisions
**1. Categorization is a separate step after extraction, not baked into `extract_with_ai`'s prompt.**
Rationale: `extract_with_ai` already has a fixed JSON schema for fornecedor/data/valor and is also exercised by the OCR-fallback path (where there is no AI call at all). Categorization needs its own safety limits (cache, per-batch ceiling) that are orthogonal to extraction, so it's cleaner as a new module `app/categorization.py` with a function like `categorize_supplier(supplier_name: str, conn, batch_id) -> int`, called from `ingestion.py` right before/at the same time `fiscal_documents` is created. The function always returns a valid `categoria.id` — a real category, or `1` ("Não Encontrado") when AI and keyword matching both fail — never `None`. Alternative considered: extend the extraction prompt to also return a category — rejected because it would apply AI categorization even when the caller only wants OCR fallback, and would entangle unrelated retry/cache logic with extraction.
**2. `categoria_id` lives on `fiscal_documents` (nullable FK), not a separate join table.**
Rationale: one category per document is sufficient (see Non-Goals); a nullable integer FK matches the existing schema style (`INTEGER PRIMARY KEY AUTOINCREMENT`, snake_case). Added via `ALTER TABLE fiscal_documents ADD COLUMN categoria_id INTEGER REFERENCES categoria(id)`, guarded by a `PRAGMA table_info(fiscal_documents)` check before running the ALTER, consistent with the additive-schema pattern already used in `database.py`. `detected_documents` is left unchanged since categorization only needs to run once a document is confirmed into `fiscal_documents`. The column stays nullable at the schema level purely so pre-existing rows (created before this migration) don't need a backfill — the categorization flow itself never writes `NULL`; it always writes either a real category id or the reserved `1` (see Decision 3).
**3. Reserved row `categoria.id = 1` = "Não Encontrado", auto-ensured, undeletable.**
Rationale: the user will populate the `categoria` table with their own categories/keywords, but the categorization flow needs a guaranteed, stable target to write to when AI and keyword matching both fail — it cannot depend on the user having remembered to seed anything. So the schema initialization step (the same `executescript`/setup path that creates the `categoria` table) also runs an idempotent `INSERT OR IGNORE INTO categoria (id, categoria, palavra_chave) VALUES (1, 'Não Encontrado', '')`, guaranteeing the row exists on first run without ever overwriting it if the user has since edited its `categoria`/`palavra_chave` values. `delete_categoria` rejects deletion when `categoria_id == 1` (checked before running the delete). Alternative considered: have the app raise/500 if row 1 is missing and require the user to seed it manually first — rejected as too fragile; a one-line idempotent insert removes an entire class of "why isn't categorization working" support issues at negligible cost.
**4. AI categorization is a lightweight, separate OpenAI call scoped to just the supplier name.**
Rationale: sending only `supplier_name` (plus the list of known category names as allowed options, sourced from distinct `categoria.categoria` values already in the table) keeps the prompt tiny and cheap compared to the vision-based extraction call, and lets us validate the AI's answer against a closed set of categories. If the AI returns a value outside that set (or the call fails), it's treated as "no usable category" and the flow falls to keyword fallback. Alternative considered: let the AI invent free-text categories — rejected, since ungoverned category proliferation would break the keyword table concept and the Dashboard grouping.
**5. Keyword fallback matching is case-insensitive substring match of `palavra_chave` in `supplier_name`, first match wins, excluding the reserved row.**
Rationale: simplest possible rule that a non-technical user can reason about when populating the `categoria` table; matches the existing `LIKE`-based supplier filter style already in `list_fiscal`. Row order (`id ASC`, `id != 1`) determines precedence when multiple keywords could match — documented so admins can order entries if needed. **Important**: `find_categoria_by_keyword` must exclude `id = 1` from its search. Since the reserved row's `palavra_chave` is normally an empty string, and an empty string is a substring of every value, including it in the ordered scan would make it match first (lowest id) on every call and defeat all real keyword matching. `id = 1` is only ever reached as the final, explicit fallback in `categorize_supplier` when `find_categoria_by_keyword` (over `id != 1`) returns no match — never as a keyword-table hit itself. Alternative considered: token-based/fuzzy matching — deferred as unnecessary complexity for v1.
**6. Safety limits are implemented as a small in-process module, not an external rate-limiter.**
Rationale: the app has no existing rate-limiting infrastructure and volume is modest (single-user/small-team invoice processing), so a simple in-memory cache dict (`supplier_name -> categoria_id`) plus a per-import-batch counter (reset at the start of each `import_batches` row) is sufficient and avoids new dependencies. Concretely:
- **No retry**: the categorization call wraps a single `try/except`, mirroring `extract_with_ai`'s existing pattern — on any exception, treat as "no category from AI" and continue.
- **Supplier cache**: keyed by normalized (`strip().upper()`) supplier name, populated the first time a supplier is categorized (whether by AI, keyword, or the `1`/"Não Encontrado" fallback) within the process lifetime; on cache hit, skip the AI call entirely and reuse the cached `categoria_id`. This is an in-memory dict for this change (module-level), acceptable since it degrades gracefully (worst case: re-categorize on process restart, still bounded by the per-batch limit).
- **Per-batch ceiling**: a configurable `CATEGORIZATION_MAX_AI_CALLS_PER_BATCH` (default e.g. 50) in `app/config.py`; a counter tied to the current `import_batches.id` is incremented per AI call and checked before each call; once reached, remaining documents in that batch use keyword fallback (then `1`/"Não Encontrado" if no keyword matches) with no further AI calls.
- **Logging**: each skip (cache hit or limit reached) logs at INFO/DEBUG with supplier name and reason, using the existing logging setup.
Alternative considered: persisting the cache in a DB table — deferred; in-memory is enough to satisfy "no infinite loop / no runaway cost" and keeps the change additive and low-risk.
**7. Dashboard category breakdown reuses the existing chart/query pattern (`supplier_totals`-style function), plus a new `category` query param alongside `start`/`end`.**
Rationale: `dashboard_routes.py` already computes `monthly_totals` and `supplier_totals` from `fiscal_documents`; a new `category_totals(conn, start, end)` follows the same shape, and threading an optional `category` filter through the existing query functions (`list_fiscal`, `monthly_totals`, `supplier_totals`, new `category_totals`) is consistent with how `supplier` filtering already works. The category filter control is added to the Dashboard template, populated from `list_categorias` (which includes "Não Encontrado") plus a separate "Sem categoria" option for any legacy `categoria_id IS NULL` rows.
**8. Categoria CRUD admin UI mirrors the existing "Documentos" list/form pattern exactly — new files, same conventions, no new UI framework.**
Rationale: the project already has an established, working pattern for list + create/edit + delete pages (`documents_list.html` + `document_form.html`, driven by `documents_routes.py`), all server-rendered Jinja2, plain HTML forms (POST-only, no JS/AJAX), CSRF via a hidden `csrf_token` field checked with `auth.check_csrf`, and a shared stylesheet (`app/static/styles.css`, classes like `card`, `table`, `table-scroll`, `page-head`, `btn btn-primary/ghost/sm`, `linkbtn danger`, `field`, `filters`). Reusing it exactly (rather than introducing a component library, modal dialogs, or AJAX) keeps the new admin screens visually and behaviorally indistinguishable from the rest of the app. Concretely:
- New router `app/routes/categoria_routes.py` (`router = APIRouter()`), imported and registered in `app/main.py` alongside the existing routers, following the same `GET/POST` route-pair convention used for documents:
- `GET /categorias` — list (reuses `card`/`table`/`table-scroll` markup)
- `GET /categorias/new`, `POST /categorias/new` — create form + handler
- `GET /categorias/{categoria_id}/edit`, `POST /categorias/{categoria_id}/edit` — edit form + handler
- `POST /categorias/{categoria_id}/delete` — delete (inline per-row form with `onsubmit="return confirm(...)"` and hidden `csrf_token`, exactly like the documents list's delete action)
- New templates `app/templates/categorias_list.html` and `app/templates/categoria_form.html`, both `{% extends "base.html" %}`, the form template reused for both create and edit via a `mode` variable (`"new"`/`"edit"`) exactly as `document_form.html` does.
- `app/database.py` gains `create_categoria(conn, categoria, palavra_chave)`, `get_categoria(conn, categoria_id)`, `update_categoria(conn, categoria_id, categoria, palavra_chave)`, `delete_categoria(conn, categoria_id)`, alongside the already-planned `list_categorias`/`find_categoria_by_keyword`, matching the naming style of `create_fiscal`/`update_fiscal`.
- A new "Categorias" link is added to the nav bar in `base.html` (`<nav class="nav">`), alongside "Dashboard"/"Documentos", using the same active-link `request.url.path.startswith(...)` pattern.
- `delete_categoria` first checks `categoria_id == 1` and rejects the delete (flash error, no DB change) per Decision 3's reserved-row protection. Otherwise, deleting a `categoria` that is referenced by existing `fiscal_documents.categoria_id` must not orphan those rows or raise a foreign-key error: `delete_categoria` explicitly runs `UPDATE fiscal_documents SET categoria_id = 1 WHERE categoria_id = ?` (reassigning affected documents to "Não Encontrado") before deleting the `categoria` row, in the same transaction, rather than relying on `ON DELETE SET NULL` — SQLite foreign key enforcement (`PRAGMA foreign_keys`) is off by default and this codebase does not currently enable it, so the app must enforce this itself.
Alternative considered: leave orphaned documents' `categoria_id` as `NULL` on delete instead of reassigning to `1` — rejected for consistency: `1` ("Não Encontrado") is now the single canonical "not properly classified" bucket for anything the categorization flow processes or re-processes, while `NULL` is reserved strictly for legacy pre-migration rows never touched by this flow.
## Risks / Trade-offs
- **[Risk]** In-memory supplier cache and per-batch counter are lost on process restart (e.g., app redeploy mid-batch) → could allow a few extra AI calls right after restart. **Mitigation**: the per-batch ceiling is intentionally conservative (default well below any reasonable per-import volume), and the ceiling still applies from the moment the process restarts, bounding worst-case cost; a persistent cache can be added later if needed.
- **[Risk]** AI-suggested categories drifting from the closed set (typos, casing) could be silently rejected, causing over-reliance on keyword fallback. **Mitigation**: normalize AI output (trim/case-fold) before validating against known categories; log rejected AI suggestions so gaps in the `categoria` table are visible for the admin to fix.
- **[Risk]** Existing fiscal documents (created before this change) will show "Sem categoria" (`categoria_id` `NULL`) until reprocessed, distinct from "Não Encontrado" (`categoria_id = 1`) used for documents the flow actively tried and failed to classify. **Mitigation**: acceptable for this change (Non-Goal: no bulk re-categorization); documented as an intentional distinction so it isn't mistaken for a bug; can be revisited if users need a backfill.
- **[Risk]** If the operator edits the reserved row's `categoria`/`palavra_chave` values (e.g. changes `palavra_chave` to something non-empty), it could start matching real suppliers via the keyword fallback path, diluting its meaning as a pure "nothing matched" bucket. **Mitigation**: document that `id = 1` is reserved for "no match" semantics and its `palavra_chave` should normally stay empty; the app does not enforce this beyond the initial seed since the user is expected to manage the table's content.
- **[Trade-off]** Choosing "first keyword match wins" instead of "most specific match wins" is simpler but could misclassify if keywords overlap (e.g., "MERCADO" matching both a generic and specific entry). Documented as a known limitation; admins should keep keywords reasonably distinct.
## Migration Plan
1. Add `categoria` table (with the idempotent `id = 1` "Não Encontrado" seed insert) and `fiscal_documents.categoria_id` column via additive `executescript`/`ALTER TABLE` in `app/database.py` (guarded by existence checks so it's safe to run against existing databases).
2. Ship `app/categorization.py` with the AI-then-keyword-then-none flow and safety limits, wired into `ingestion.py` at document confirmation time.
3. Update `dashboard_routes.py`/`dashboard.html` to add category totals + filter.
4. Ship `app/routes/categoria_routes.py` + `categorias_list.html`/`categoria_form.html` for CRUD admin management of the `categoria` table, registered in `app/main.py` and linked from `base.html`'s nav.
5. Deploy is a normal code + schema update; no data backfill required (existing rows simply have `categoria_id = NULL`).
6. Rollback: revert code; the added column/table can remain harmless if rolled back (nullable, unused), or be dropped manually if desired.
## Open Questions
- Should users be able to manually override/edit a document's category from the documents list UI in this change, or is that a follow-up? (Assumed follow-up unless trivial.)
+37
View File
@@ -0,0 +1,37 @@
## Why
Hoje o LerNotaFiscal extrai fornecedor, data e valor de cada nota fiscal, mas não classifica a despesa por categoria (ex.: Alimentação, Transporte, Saúde). Sem categoria, o usuário não consegue entender para onde o dinheiro está indo pelo Dashboard, apenas por fornecedor ou período. Precisamos categorizar automaticamente cada nota no momento da extração, com uma regra de negócio clara (AI primeiro, palavra-chave como fallback) e sem risco de gerar custo descontrolado de tokens de IA.
## What Changes
- Criar tabela `categoria` (`id`, `categoria`, `palavra_chave`) para mapear palavras-chave a categorias, usada como fallback e administrável pelo usuário. O registro `id = 1` é reservado pelo sistema para a categoria "Não Encontrado" e é garantido automaticamente na inicialização do schema; o usuário é responsável por cadastrar as demais categorias/palavras-chave pela tela de CRUD.
- Adicionar coluna `categoria_id` (FK para `categoria`) em `fiscal_documents` para persistir a classificação. Documentos processados pelo novo fluxo sempre recebem um `categoria_id` válido (nunca ficam nulos); apenas documentos já existentes antes desta mudança (não reprocessados) permanecem com `categoria_id` nulo ("Sem categoria").
- Implementar fluxo de categorização automática:
1. Ao processar uma nota, pedir à IA (mesma chamada de extração ou chamada dedicada) para sugerir a categoria a partir do nome do fornecedor.
2. Se a IA não retornar uma categoria válida/reconhecida, buscar na tabela `categoria` por correspondência de `palavra_chave` no `supplier_name` e usar a `categoria` encontrada.
3. Se a IA não conseguir categorizar **e** nenhuma `palavra_chave` corresponder, gravar `categoria_id = 1` ("Não Encontrado") sem bloquear o processamento.
- Ajustar o Dashboard (`app/templates/dashboard.html` + `dashboard_routes.py`) para exibir gastos agrupados por categoria (gráfico/lista) e permitir filtrar os dados exibidos por categoria, na mesma linha dos filtros de fornecedor/período já existentes na tela de documentos.
- Criar tela administrativa de CRUD (Create, Read, Update, Delete) para a tabela `categoria`, seguindo exatamente o mesmo layout/padrão já usado em "Documentos" (`documents_list.html` + `document_form.html`, mesmo `base.html`, mesmas classes CSS de `app/static/styles.css`, mesmo padrão de rotas `GET/POST /categorias`, `/categorias/new`, `/categorias/{id}/edit`, `POST /categorias/{id}/delete` com CSRF), para que o usuário possa cadastrar/editar/remover categorias e palavras-chave sem precisar de acesso direto ao banco.
- Criar mecanismo de segurança contra loop infinito / consumo excessivo de tokens ao acionar a IA para categorização:
- Limite de tentativas de chamada de IA por nota (ex.: no máximo 1 tentativa de categorização por nota, sem retry automático).
- Cache/memória de categorização por fornecedor (se já categorizamos "Fornecedor X" antes, não chamar a IA de novo — reusar resultado ou usar a tabela `categoria`).
- Circuit breaker/limite global (ex.: máximo de N chamadas de categorização por lote de importação ou por janela de tempo), com log e interrupção segura (fallback para palavra-chave) ao atingir o limite.
## Capabilities
### New Capabilities
- `expense-categorization`: Classificação automática de notas fiscais por categoria, com IA como fonte primária e a tabela `categoria` (palavra-chave) como fallback determinístico.
- `categorization-safety-limits`: Limites e proteções (retries, cache, circuit breaker) para chamadas de IA usadas na categorização, evitando loops e consumo excessivo de tokens.
### Modified Capabilities
- (nenhuma capability existente com spec.md hoje — projeto ainda não possui specs em `openspec/specs/`)
## Impact
- **Banco de dados** (`app/database.py`): nova tabela `categoria`; nova coluna `categoria_id` em `fiscal_documents` (e possivelmente `detected_documents`); nova migração aditiva no `SCHEMA`/`executescript`.
- **IA** (`app/ai_extraction.py` ou novo módulo `app/categorization.py`): nova função/chamada para sugerir categoria a partir do `supplier_name`; ajuste no fluxo de `extract_with_ai` ou chamada adicional pós-extração.
- **Ingestão** (`ingestion.py`): aplicar a lógica de categorização (IA → palavra-chave → `categoria_id = 1` "Não Encontrado") ao confirmar/criar `fiscal_documents`.
- **Rotas/Dashboard** (`app/routes/dashboard_routes.py`, `app/templates/dashboard.html`): novo agrupamento e filtro por categoria.
- **Nova rota de administração** (`app/routes/categoria_routes.py`, registrada em `app/main.py`; novos templates `app/templates/categorias_list.html` e `app/templates/categoria_form.html`, reaproveitando `base.html`): CRUD completo da tabela `categoria`, no mesmo padrão das rotas/telas de "Documentos".
- **Rotas de documentos** (`app/routes/documents_routes.py`): opcionalmente permitir filtrar/editar categoria manualmente por nota.
- **Configuração** (`app/config.py`): novos parâmetros para limites de segurança (ex.: `CATEGORIZATION_MAX_CALLS_PER_BATCH`, cache TTL).
@@ -0,0 +1,40 @@
## ADDED Requirements
### Requirement: Single AI Attempt Per Document
The system SHALL make at most one AI categorization call per fiscal document per ingestion attempt and SHALL NOT automatically retry a failed AI categorization call.
#### Scenario: AI categorization call fails
- **WHEN** the AI categorization call for a document errors or times out
- **THEN** the system does not retry the AI call for that document and proceeds directly to keyword fallback
### Requirement: Supplier Categorization Cache
The system SHALL cache the category determined for a given supplier name (in-memory or persisted) and SHALL reuse the cached category for subsequent documents from the same supplier instead of calling the AI again.
#### Scenario: Second document from a known supplier
- **WHEN** a fiscal document is processed for a supplier name that already has a cached category from a prior categorization
- **THEN** the system uses the cached category and does not issue a new AI categorization call
#### Scenario: Cache miss for a new supplier
- **WHEN** a fiscal document is processed for a supplier name with no cached category
- **THEN** the system proceeds with the normal AI-then-keyword categorization flow and stores the result in the cache
### Requirement: Per-Batch AI Call Limit
The system SHALL enforce a configurable maximum number of AI categorization calls within a single import batch (or time window). Once the limit is reached, remaining documents in that batch SHALL be categorized using only the keyword fallback, with no further AI calls, until the batch/window resets.
#### Scenario: Limit reached mid-batch
- **WHEN** the number of AI categorization calls in the current batch reaches the configured maximum
- **THEN** subsequent documents in the same batch skip the AI call and go directly to keyword fallback (or "Sem categoria" if no keyword matches)
### Requirement: Configurable Safety Limits
The maximum AI calls per batch and cache behavior SHALL be configurable via application configuration/environment variables, not hardcoded in the categorization logic.
#### Scenario: Operator changes the configured limit
- **WHEN** the configured maximum AI calls per batch is changed
- **THEN** the categorization flow honors the new limit on the next run without code changes
### Requirement: Observability of Skipped AI Calls
When an AI categorization call is skipped due to the cache or the per-batch limit, the system SHALL log the reason (cache hit or limit reached) so the behavior is observable and auditable.
#### Scenario: Skip logged
- **WHEN** the system skips an AI categorization call because of a cache hit or because the batch limit was reached
- **THEN** a log entry is recorded indicating which reason caused the skip and for which document/supplier
@@ -0,0 +1,113 @@
## ADDED Requirements
### Requirement: Categoria Table Schema
The system SHALL provide a `categoria` table with fields `id`, `categoria`, and `palavra_chave`, used to map keywords to category names as a fallback for automatic classification. The row with `id = 1` is reserved by the system for the "Não Encontrado" category and SHALL always exist; the system SHALL ensure this row is present (creating it if missing) during schema initialization, independent of any other categories the user registers.
#### Scenario: Table created on schema initialization
- **WHEN** the application initializes or migrates the database schema
- **THEN** the `categoria` table exists with columns `id`, `categoria`, `palavra_chave`, and a row with `id = 1` and `categoria = "Não Encontrado"` is present
#### Scenario: Reserved row already present
- **WHEN** the application initializes and a `categoria` row with `id = 1` already exists (e.g., the user has customized its `categoria`/`palavra_chave` values)
- **THEN** the system does not overwrite or duplicate that row
### Requirement: Automatic Categorization on Ingestion
The system SHALL attempt to determine the category of a fiscal document automatically at the moment it is confirmed/created, without requiring manual user input.
#### Scenario: New fiscal document confirmed triggers categorization
- **WHEN** a detected document is confirmed and a `fiscal_documents` row is created
- **THEN** the system runs the categorization flow (AI, then keyword fallback) before finishing the request
### Requirement: AI-Based Categorization by Supplier Name
The system SHALL first attempt to categorize a fiscal document by sending the supplier name to the AI service and requesting a category classification.
#### Scenario: AI returns a valid category
- **WHEN** the AI service returns a recognized, non-empty category for the supplier name
- **THEN** the system stores that category on the fiscal document and does not consult the `categoria` keyword table
#### Scenario: AI call fails or returns no usable category
- **WHEN** the AI call raises an error, times out, or returns an empty/unrecognized value
- **THEN** the system proceeds to keyword fallback categorization instead of failing the request
### Requirement: Keyword Fallback Categorization
When AI categorization does not produce a valid category, the system SHALL search the `categoria` table for a `palavra_chave` that matches (case-insensitive, substring) the supplier name, and use the corresponding `categoria` value.
#### Scenario: Keyword match found
- **WHEN** AI categorization did not yield a category and a `categoria.palavra_chave` is found as a substring of the supplier name (case-insensitive)
- **THEN** the system assigns the matching `categoria.categoria` value to the fiscal document
#### Scenario: No keyword match found
- **WHEN** AI categorization did not yield a category and no `palavra_chave` matches the supplier name
- **THEN** the system assigns `categoria_id = 1` ("Não Encontrado") to the fiscal document instead of guessing
### Requirement: Fallback to Reserved "Não Encontrado" Category
When neither AI nor keyword matching determines a category for a fiscal document being processed by the categorization flow, the system SHALL persist that document with `categoria_id = 1` (the reserved "Não Encontrado" category) and SHALL NOT block or fail document processing.
#### Scenario: No category determined by any method
- **WHEN** AI categorization fails/is inconclusive and no keyword match exists for a document going through the categorization flow
- **THEN** the fiscal document is saved successfully with `categoria_id = 1` and shown as "Não Encontrado" in the UI
#### Scenario: Legacy documents predating this change remain distinct from "Não Encontrado"
- **WHEN** a `fiscal_documents` row was created before this change and has never been run through the categorization flow
- **THEN** its `categoria_id` remains `NULL` and it is shown as "Sem categoria" (distinct from the explicit "Não Encontrado" outcome), until it is reprocessed
### Requirement: Dashboard Category Breakdown
The Dashboard SHALL display expenses aggregated by category (e.g., totals per category) alongside existing period and supplier breakdowns.
#### Scenario: Dashboard shows category totals
- **WHEN** a user opens the Dashboard
- **THEN** the page displays total spend grouped by category, including the "Não Encontrado" group (`categoria_id = 1`) for documents the categorization flow could not classify, and a separate "Sem categoria" group for any legacy documents with `categoria_id` still `NULL`
### Requirement: Dashboard Category Filter
The Dashboard SHALL allow the user to filter the displayed expenses by a single selected category.
#### Scenario: User filters by category
- **WHEN** the user selects a category from the Dashboard's category filter
- **THEN** all Dashboard figures (KPIs, charts, recent documents) update to reflect only fiscal documents in that category
#### Scenario: User clears the category filter
- **WHEN** the user clears the selected category filter
- **THEN** the Dashboard returns to showing all fiscal documents regardless of category
### Requirement: Categoria Administration List
The system SHALL provide an authenticated admin page listing all rows of the `categoria` table (`id`, `categoria`, `palavra_chave`), following the same page layout, table markup, and CSS classes already used by the existing "Documentos" list page.
#### Scenario: User views the categoria list
- **WHEN** an authenticated user navigates to the categorias admin page
- **THEN** the system displays all `categoria` rows in a table matching the existing list-page layout (`card`/`table`/`table-scroll` styling), with actions to create, edit, and delete a row
### Requirement: Categoria Creation
The system SHALL allow an authenticated user to create a new `categoria` row (`categoria`, `palavra_chave`) via a form that follows the same structure, validation, and CSRF protection as the existing document create/edit form.
#### Scenario: User creates a new categoria
- **WHEN** an authenticated user submits the "new categoria" form with a non-empty `categoria` and `palavra_chave`
- **THEN** the system inserts a new row into the `categoria` table and redirects to the categoria list showing the new entry
#### Scenario: User submits an invalid categoria form
- **WHEN** an authenticated user submits the "new categoria" form with a missing `categoria` or `palavra_chave`
- **THEN** the system re-displays the form with a validation error and does not create a row
### Requirement: Categoria Update
The system SHALL allow an authenticated user to edit an existing `categoria` row's `categoria` and `palavra_chave` values, reusing the same form template pattern used for creation (single form, `mode` toggling between new/edit).
#### Scenario: User edits an existing categoria
- **WHEN** an authenticated user submits the edit form for an existing `categoria` row with valid values
- **THEN** the system updates that row and redirects to the categoria list reflecting the new values
### Requirement: Categoria Deletion
The system SHALL allow an authenticated user to delete an existing `categoria` row (other than the reserved `id = 1` row) via a CSRF-protected POST action, following the same inline delete-form-with-confirmation pattern used on the existing "Documentos" list page.
#### Scenario: User deletes a categoria
- **WHEN** an authenticated user confirms deletion of a `categoria` row with `id != 1`
- **THEN** the system removes that row from the `categoria` table and redirects to the categoria list without it
#### Scenario: Deleting a categoria referenced by fiscal documents
- **WHEN** an authenticated user deletes a `categoria` row that is currently referenced by one or more `fiscal_documents.categoria_id`
- **THEN** the system completes the deletion and reassigns those fiscal documents' `categoria_id` to `1` ("Não Encontrado"), without errors or orphaned references
### Requirement: Reserved Categoria Cannot Be Deleted
The system SHALL prevent deletion of the `categoria` row with `id = 1` ("Não Encontrado"), since it is the required fallback target for automatic categorization.
#### Scenario: User attempts to delete the reserved categoria
- **WHEN** an authenticated user attempts to delete the `categoria` row with `id = 1`
- **THEN** the system rejects the deletion, shows an error message, and the row remains unchanged
+44
View File
@@ -0,0 +1,44 @@
## 1. Schema: tabela `categoria` e coluna `categoria_id`
- [x] 1.1 Adicionar `categoria` (`id INTEGER PRIMARY KEY AUTOINCREMENT`, `categoria TEXT NOT NULL`, `palavra_chave TEXT NOT NULL`) ao `SCHEMA`/`executescript` em `app/database.py` (`CREATE TABLE IF NOT EXISTS`)
- [x] 1.2 Adicionar coluna `categoria_id INTEGER REFERENCES categoria(id)` (nullable) em `fiscal_documents`, com checagem via `PRAGMA table_info(fiscal_documents)` antes do `ALTER TABLE` para não falhar em bancos já existentes
- [x] 1.3 Adicionar insert idempotente `INSERT OR IGNORE INTO categoria (id, categoria, palavra_chave) VALUES (1, 'Não Encontrado', '')` no mesmo passo de inicialização do schema, garantindo que a linha reservada `id = 1` sempre exista sem nunca sobrescrever edições feitas pelo usuário
- [x] 1.4 Adicionar helpers em `app/database.py`: `create_categoria(conn, categoria, palavra_chave)`, `get_categoria(conn, categoria_id)`, `list_categorias(conn)`, `update_categoria(conn, categoria_id, categoria, palavra_chave)`, `delete_categoria(conn, categoria_id)` (deve rejeitar `categoria_id == 1` sem alterar nada; caso contrário, fazer `UPDATE fiscal_documents SET categoria_id = 1 WHERE categoria_id = ?` antes de excluir a linha, na mesma transação), `find_categoria_by_keyword(conn, supplier_name)` (busca case-insensitive de `palavra_chave` como substring de `supplier_name`, **excluindo `id = 1`**, ordenado por `id ASC`, retorna a primeira correspondência — importante: `palavra_chave` vazia da linha reservada é substring de qualquer texto, então incluí-la quebraria o casamento de palavras-chave reais)
## 2. Módulo de categorização com limites de segurança
- [x] 2.1 Criar `app/categorization.py` com função `categorize_supplier(supplier_name: str, conn, batch_id) -> int` (sempre retorna um `categoria.id` válido, nunca `None`) implementando a ordem: cache em memória → limite por lote → IA → palavra-chave → `1` ("Não Encontrado")
- [x] 2.2 Implementar cache em memória por fornecedor normalizado (`strip().upper()`), populado após qualquer categorização (IA, palavra-chave, ou `1` quando nada é encontrado), consultado antes de qualquer chamada de IA
- [x] 2.3 Implementar contador de chamadas de IA por `import_batches.id`, comparado a `settings.CATEGORIZATION_MAX_AI_CALLS_PER_BATCH`; ao atingir o limite, pular direto para o fallback de palavra-chave (e depois `1` se nada corresponder)
- [x] 2.4 Implementar chamada de IA dedicada (OpenAI `chat.completions.create`, prompt curto com `supplier_name` + lista de categorias já existentes na tabela `categoria`), validando que a resposta pertence ao conjunto conhecido; qualquer exceção ou valor fora do conjunto é tratado como "sem categoria da IA" (sem retry)
- [x] 2.5 Adicionar `CATEGORIZATION_MAX_AI_CALLS_PER_BATCH` (default configurável) em `app/config.py`
- [x] 2.6 Adicionar logs (INFO/DEBUG) quando uma chamada de IA é pulada por cache hit ou por limite de lote atingido, incluindo fornecedor e motivo
## 3. Integração no fluxo de ingestão
- [x] 3.1 Chamar `categorize_supplier` no momento em que o `fiscal_documents` é criado/confirmado, persistindo o `categoria_id` retornado (sempre um valor válido, no mínimo `1`) — implementado em `database.py::confirm_batch` (não em `ingestion.py`: a criação/confirmação de `fiscal_documents` acontece em `database.py`, `ingestion.py` só extrai)
- [x] 3.2 Garantir que falha na categorização (qualquer exceção não tratada dentro do módulo) não impede a criação do `fiscal_documents` — a nota deve ser salva com `categoria_id = 1` ("Não Encontrado") nesse caso
## 4. Dashboard: exibição e filtro por categoria
- [x] 4.1 Criar função `category_totals(conn, start, end)` em `app/database.py` (mesmo padrão de `monthly_totals`/`supplier_totals`), agrupando por `categoria.categoria` (via LEFT JOIN, incluindo a linha `id = 1` "Não Encontrado") e um grupo separado "Sem categoria" apenas para `categoria_id IS NULL` (documentos legados não reprocessados)
- [x] 4.2 Adicionar parâmetro opcional `category` em `dashboard_routes.py`, propagando o filtro para `monthly_totals`, `supplier_totals`, `category_totals` e demais consultas da página
- [x] 4.3 Atualizar `app/templates/dashboard.html` com um gráfico/lista de gastos por categoria e um controle de filtro (select) populado a partir de `list_categorias` (inclui "Não Encontrado") + opção "Sem categoria" para os registros legados
- [x] 4.4 Validado (sem extensão de navegador disponível na sessão: verificado via requisições HTTP autenticadas + inspeção do HTML renderizado, e via chamadas diretas às funções de banco simulando um lote confirmado) — Dashboard exibe totais por categoria e o filtro reduz corretamente KPIs/gráficos/lista de documentos recentes
## 5. CRUD administrativo da tabela `categoria`
- [x] 5.1 Criar `app/routes/categoria_routes.py` com `router = APIRouter()` e as rotas `GET /categorias`, `GET/POST /categorias/new`, `GET/POST /categorias/{categoria_id}/edit`, `POST /categorias/{categoria_id}/delete`, seguindo exatamente o padrão de `documents_routes.py` (uso de `render()`, `flash()`, `auth.check_csrf`)
- [x] 5.2 Registrar `categoria_routes` em `app/main.py` (import + `app.include_router(categoria_routes.router)`), no mesmo bloco onde os demais routers são registrados
- [x] 5.3 Criar `app/templates/categorias_list.html` (`{% extends "base.html" %}`) reaproveitando o layout de `documents_list.html`: `.page-head` com botão "+ Nova", tabela (`card`/`table`/`table-scroll`) listando `id`, `categoria`, `palavra_chave`, e ações de editar/excluir por linha (form inline com `onsubmit="return confirm(...)"` e `csrf_token` oculto, igual ao padrão de exclusão de documentos); a linha `id = 1` ("Não Encontrado") mostra a ação de excluir desabilitada/oculta, já que a exclusão é sempre rejeitada
- [x] 5.4 Criar `app/templates/categoria_form.html` (`{% extends "base.html" %}`) reaproveitando o layout de `document_form.html`, com variável `mode` (`"new"`/`"edit"`) controlando a `action` do form entre `/categorias/new` e `/categorias/{id}/edit`
- [x] 5.5 Adicionar link "Categorias" na `<nav class="nav">` de `app/templates/base.html`, com a mesma lógica de classe `active` (`request.url.path.startswith('/categorias')`) usada pelos demais links
- [x] 5.6 Validado (sem extensão de navegador disponível na sessão: driver via requisições HTTP autenticadas contra o servidor real, cookies de sessão + CSRF) o fluxo completo: criar, listar, editar e excluir uma categoria; confirmado que excluir a categoria `id = 1` é rejeitado, e que excluir uma categoria em uso reatribui os documentos relacionados para "Não Encontrado" sem erro
## 6. Testes e validação
- [x] 6.1 Testes unitários para `find_categoria_by_keyword` (match, no match, case-insensitive, múltiplos candidatos → primeiro por `id`, e confirmando que `id = 1` nunca é retornado por essa função mesmo com `palavra_chave` vazia)
- [x] 6.2 Testes unitários para `categorize_supplier` cobrindo: cache hit (sem chamada de IA), limite de lote atingido (sem chamada de IA), IA retorna categoria válida, IA falha/retorna inválido → fallback por palavra-chave, nenhum método encontra categoria → retorna `1`
- [x] 6.3 Teste de integração do fluxo de ingestão ponta a ponta: nota processada resulta em `fiscal_documents.categoria_id` correto nos cenários de IA, fallback por palavra-chave e "Não Encontrado" (`1`)
- [x] 6.4 Testes unitários/integração para o CRUD de `categoria`: criar, ler, atualizar, excluir (incluindo excluir categoria referenciada por `fiscal_documents` → reatribuição para `1`, e tentar excluir `id = 1` → rejeitado)
- [x] 6.5 Automatizado como equivalente ao teste manual (`test_repeated_suppliers_call_ai_once_each_and_respect_batch_limit` em `tests/test_categorization.py`, usando mock + `assertLogs`): lote com fornecedores repetidos confirma que a IA é chamada apenas uma vez por fornecedor distinto e que o limite por lote é respeitado
+20
View File
@@ -0,0 +1,20 @@
schema: spec-driven
# Project context (optional)
# This is shown to AI when creating artifacts.
# Add your tech stack, conventions, style guides, domain knowledge, etc.
# Example:
# context: |
# Tech stack: TypeScript, React, Node.js
# We use conventional commits
# Domain: e-commerce platform
# Per-artifact rules (optional)
# Add custom rules for specific artifacts.
# Example:
# rules:
# proposal:
# - Keep proposals under 500 words
# - Always include a "Non-goals" section
# tasks:
# - Break tasks into chunks of max 2 hours
@@ -0,0 +1,87 @@
# document-competencia Specification
## Purpose
Define como o sistema representa e gerencia a competência (mês/ano) de um documento fiscal, substituindo a data completa de compra por dois campos inteiros (`mes`, `ano`) em toda a aplicação — persistência, extração, revisão pós-upload, cadastro manual, dashboard, listagens e migração de dados existentes.
## Requirements
### Requirement: Persistência de Competência (Mês/Ano)
O sistema SHALL persistir a competência de um documento fiscal como dois campos inteiros, `mes` (1-12) e `ano` (ex. 2026), em vez de uma data completa de compra. Em `fiscal_documents`, `mes` e `ano` SHALL ser obrigatórios (não nulos). Em `detected_documents` (staging), `mes` e `ano` SHALL permanecer opcionais enquanto o documento não tiver sido revisado/confirmado.
#### Scenario: Documento confirmado exige competência
- **WHEN** um documento em staging é promovido para `fiscal_documents` via confirmação de lote
- **THEN** o registro criado possui `mes` e `ano` preenchidos com valores válidos (mes entre 1 e 12, ano numérico)
#### Scenario: Documento em staging pode estar sem competência
- **WHEN** um documento acabou de ser detectado por OCR/IA e ainda não foi revisado
- **THEN** `mes` e/ou `ano` podem estar nulos em `detected_documents` até que o usuário revise e preencha a competência
### Requirement: Extração Não Persiste Mais Data Completa
O sistema SHALL deixar de persistir o dia da compra em qualquer tabela. A extração via IA ou OCR pode continuar identificando uma data completa no texto/imagem do documento, mas o dia SHALL ser descartado antes da persistência, mantendo-se apenas mês e ano.
#### Scenario: Data completa detectada no documento
- **WHEN** a extração (IA ou OCR) identifica uma data completa (dia/mês/ano) no documento
- **THEN** o sistema armazena apenas o mês e o ano correspondentes, descartando o dia
### Requirement: Confirmação Obrigatória de Competência Antes do Envio
Na tela de revisão pós-upload, o sistema SHALL exigir que o usuário confirme explicitamente a Competência (Mês/Ano) de cada documento, através de dois campos de seleção — Mês (Jan a Dez) e Ano (2026 a 2030) — antes de permitir a confirmação do lote.
#### Scenario: Competência pré-selecionada a partir da extração
- **WHEN** a extração identificou um mês/ano válido para o documento
- **THEN** os seletores de Mês e Ano na tela de revisão vêm pré-selecionados com esses valores, permitindo correção manual pelo usuário
#### Scenario: Competência ausente bloqueia confirmação do lote
- **WHEN** um ou mais documentos do lote não têm Mês e Ano selecionados
- **THEN** o sistema não permite confirmar o lote até que a competência de todos os documentos pendentes seja preenchida
### Requirement: Seleção de Competência no Cadastro Manual
O formulário manual de criação/edição de documento fiscal (`document_form.html`) SHALL substituir o campo de data por dois seletores — Mês (Jan a Dez) e Ano (2026 a 2030) — para definir a competência do documento.
#### Scenario: Criação manual de documento
- **WHEN** o usuário cria um novo documento fiscal manualmente
- **THEN** o formulário exige a seleção de Mês e Ano em vez de uma data completa
#### Scenario: Edição de documento existente
- **WHEN** o usuário edita um documento fiscal existente
- **THEN** os seletores de Mês e Ano vêm pré-preenchidos com a competência atual do documento, podendo ser alterados
### Requirement: Exibição de Competência no Dashboard e Listagens
O Dashboard e a listagem de documentos (`/documents`) SHALL exibir a competência de cada documento no formato "Mês abreviado/Ano com 2 dígitos" (ex. "Jun/26", "Jul/26") no lugar da antiga coluna "Data".
#### Scenario: Tabela "Últimos documentos" no Dashboard
- **WHEN** o usuário visualiza o Dashboard
- **THEN** cada linha da tabela "Últimos documentos" exibe a competência (mês/ano) do documento, em vez da data completa
#### Scenario: Coluna na listagem de documentos
- **WHEN** o usuário visualiza a listagem `/documents`
- **THEN** a coluna antes chamada "Data" exibe a competência (mês/ano) de cada documento, e continua ordenável
### Requirement: Filtro por Competência na Listagem de Documentos
A listagem `/documents` SHALL permitir filtrar documentos por intervalo de competência (Mês/Ano inicial e Mês/Ano final), substituindo o filtro anterior por intervalo de datas.
#### Scenario: Filtro por intervalo de competência
- **WHEN** o usuário seleciona uma competência inicial (ex. Jan/2026) e uma competência final (ex. Jun/2026) nos filtros da listagem
- **THEN** apenas documentos cuja competência esteja dentro desse intervalo (inclusive) são exibidos
#### Scenario: Ordenação por competência
- **WHEN** o usuário ordena a listagem pela coluna de competência
- **THEN** os documentos são ordenados por ano e mês (crescente ou decrescente conforme selecionado)
### Requirement: Agregação Mensal do Dashboard por Competência Nativa
O gráfico "Gastos por mês" do Dashboard SHALL agrupar os totais diretamente pelas colunas `ano`/`mes` de `fiscal_documents`, em vez de derivar o mês a partir de uma string de data.
#### Scenario: Totais mensais agrupados por competência
- **WHEN** o Dashboard calcula os totais mensais para exibição
- **THEN** o agrupamento é feito pelas colunas `ano` e `mes`, e o rótulo exibido (ex. "Jun/26") é formatado a partir desses valores nativos
### Requirement: Migração de Dados Existentes para Competência
Ao atualizar para esta mudança, o sistema SHALL migrar automaticamente todos os registros existentes de `fiscal_documents` e `detected_documents`, preenchendo `mes` e `ano` a partir da data de compra anteriormente armazenada, antes de remover o campo de data.
#### Scenario: Migração automática na inicialização
- **WHEN** a aplicação inicializa contra um banco de dados que ainda contém o campo de data completa
- **THEN** o sistema preenche `mes` e `ano` de cada registro a partir da data existente e remove o campo de data, sem exigir intervenção manual do usuário
#### Scenario: Migração já aplicada não é repetida
- **WHEN** a aplicação inicializa contra um banco de dados que já foi migrado (campo de data já removido)
- **THEN** o sistema não tenta migrar novamente e opera normalmente com `mes`/`ano`