45 lines
7.9 KiB
Markdown
45 lines
7.9 KiB
Markdown
## 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
|